From a45e88563981221dff6078395c3dbaf4ed818c9b Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 13:08:28 +0300 Subject: [PATCH 01/54] import POC --- examples/lua-multi-incr.js | 1 + packages/client/lib/cluster/index.ts | 10 +- .../command-router.ts | 15 + .../dynamic-policy-resolver-factory.ts | 109 ++++++ .../dynamic-policy-resolver.spec.ts | 317 ++++++++++++++++++ .../request-response-policies/index.ts | 10 + .../policies-constants.ts | 30 ++ .../static-policies-data.ts | 59 ++++ .../static-policy-resolver.ts | 61 ++++ .../request-response-policies/test.spec.ts | 9 + .../request-response-policies/types.ts | 22 ++ .../lib/commands/generic-transformers.ts | 12 +- 12 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/command-router.ts create mode 100644 packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts create mode 100644 packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/index.ts create mode 100644 packages/client/lib/cluster/request-response-policies/policies-constants.ts create mode 100644 packages/client/lib/cluster/request-response-policies/static-policies-data.ts create mode 100644 packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts create mode 100644 packages/client/lib/cluster/request-response-policies/test.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/types.ts diff --git a/examples/lua-multi-incr.js b/examples/lua-multi-incr.js index 8f872a1c0a5..645c41b6c5f 100644 --- a/examples/lua-multi-incr.js +++ b/examples/lua-multi-incr.js @@ -7,6 +7,7 @@ const client = createClient({ scripts: { mincr: defineScript({ NUMBER_OF_KEYS: 2, + // TODO add RequestPolicy: , SCRIPT: 'return {' + 'redis.pcall("INCRBY", KEYS[1], ARGV[1]),' + diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 43e82153847..8c1705f128b 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,6 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; +import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -29,7 +30,6 @@ type WithCommands< [P in keyof typeof NON_STICKY_COMMANDS]: CommandSignature<(typeof NON_STICKY_COMMANDS)[P], RESP, TYPE_MAPPING>; }; - interface ClusterCommander< M extends RedisModules, F extends RedisFunctions, @@ -186,6 +186,7 @@ export default class RedisCluster< return async function (this: ProxyCluster, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); + console.log(parser, parser.redisArgs[0]); return this._self._execute( parser.firstKey, @@ -319,6 +320,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`. @@ -399,6 +401,8 @@ export default class RedisCluster< this.on(RESUBSCRIBE_LISTENERS_EVENT, this.resubscribeAllPubSubListeners.bind(this)); this._commandOptions = { timeout: DEFAULT_COMMAND_TIMEOUT, ...options?.commandOptions }; + + this._policyResolver = new StaticPolicyResolver(POLICIES); } duplicate< @@ -498,7 +502,11 @@ export default class RedisCluster< options: ClusterCommandOptions | undefined, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { + console.log(`executing command `, firstKey, isReadonly, options); const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + const p = this._policyResolver.resolvePolicy("ping") + console.log(`ping policy `, p); + let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); let i = 0; diff --git a/packages/client/lib/cluster/request-response-policies/command-router.ts b/packages/client/lib/cluster/request-response-policies/command-router.ts new file mode 100644 index 00000000000..e7dacb51f85 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/command-router.ts @@ -0,0 +1,15 @@ +// import { RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from "../../RESP/types"; +// import { ShardNode } from "../cluster-slots"; +// import type { Either } from './types'; + +// export interface CommandRouter< +// M extends RedisModules, +// F extends RedisFunctions, +// S extends RedisScripts, +// RESP extends RespVersions, +// TYPE_MAPPING extends TypeMapping> { +// routeCommand( +// command: string, +// policy: RequestPolicy, +// ): Either, 'no-available-nodes' | 'routing-failed'>; +// } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts new file mode 100644 index 00000000000..1712390a802 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -0,0 +1,109 @@ +import type { CommandReply } from '../../commands/generic-transformers'; +import type { CommandPolicies } from './policies-constants'; +import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; +import type { PolicyResolver } from './types'; +import { StaticPolicyResolver } from './static-policy-resolver'; +import type { ModulePolicyRecords } from './static-policies-data'; + +/** + * Function type that returns command information from Redis + */ +export type CommandFetcher = () => Promise>; + +/** + * A factory for creating policy resolvers that dynamically build policies based on the Redis server's COMMAND response. + * + * This factory fetches command information from Redis and analyzes the response to determine + * appropriate routing policies for each command, returning a StaticPolicyResolver with the built policies. + */ +export class DynamicPolicyResolverFactory { + /** + * Creates a StaticPolicyResolver by fetching command information from Redis + * and building appropriate policies based on the command characteristics. + * + * @param commandFetcher Function to fetch command information from Redis + * @param fallbackResolver Optional fallback resolver to use when policies are not found + * @returns A new StaticPolicyResolver with the fetched policies + */ + static async create( + commandFetcher: CommandFetcher, + fallbackResolver?: PolicyResolver + ): Promise { + const commands = await commandFetcher(); + const policies: ModulePolicyRecords = {}; + + for (const command of commands) { + const parsed = DynamicPolicyResolverFactory.#parseCommandName(command.name); + + // Skip commands with invalid format (more than one dot) + if (!parsed) { + continue; + } + + const { moduleName, commandName } = parsed; + + // Initialize module if it doesn't exist + if (!policies[moduleName]) { + policies[moduleName] = {}; + } + + // Determine policies for this command + const commandPolicies = DynamicPolicyResolverFactory.#buildCommandPolicies(command); + policies[moduleName][commandName] = commandPolicies; + } + + return new StaticPolicyResolver(policies, fallbackResolver); + } + + /** + * Parses a command name to extract module and command components. + * + * Redis commands can be in format: + * - "ping" -> module: "std", command: "ping" + * - "ft.search" -> module: "ft", command: "search" + * + * Commands with more than one dot are invalid. + */ + static #parseCommandName(fullCommandName: string): { moduleName: string; commandName: string } | null { + const parts = fullCommandName.split('.'); + + if (parts.length === 1) { + return { moduleName: 'std', commandName: fullCommandName }; + } + + if (parts.length === 2) { + return { moduleName: parts[0], commandName: parts[1] }; + } + + // Commands with more than one dot are invalid in Redis + return null; + } + + /** + * Builds CommandPolicies for a command based on its characteristics. + * + * Priority order: + * 1. Use explicit policies from the command if available + * 2. Classify as DEFAULT_KEYLESS if keySpecification is empty + * 3. Classify as DEFAULT_KEYED if keySpecification is not empty + */ + static #buildCommandPolicies(command: CommandReply): CommandPolicies { + // Determine if command is keyless based on keySpecification + const isKeyless = command.keySpecifications === 'keyless'; + + // Determine default policies based on key specification + const defaultRequest = isKeyless + ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + const defaultResponse = isKeyless + ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + + return { + // request: command.policies.request ?? defaultRequest, + // response: command.policies.response ?? defaultResponse + request: defaultRequest, + response: defaultResponse + }; + } +} \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts new file mode 100644 index 00000000000..fe115addb37 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -0,0 +1,317 @@ +import { strict as assert } from 'node:assert'; +import type { CommandReply } from '../../commands/generic-transformers'; +import { DynamicPolicyResolverFactory, type CommandFetcher, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from '.'; +import testUtils, { GLOBAL } from '../../test-utils'; + + +const createMockCommandFetcher = (commands: Array): CommandFetcher => async () => commands; + +describe('DynamicPolicyResolverFactory', () => { + + describe('create', () => { + it('should create StaticPolicyResolver with empty policies', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + assert.ok(resolver instanceof StaticPolicyResolver); + }); + + it('should create StaticPolicyResolver with fallback', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const fallbackResolver = new StaticPolicyResolver({ + std: { + ping: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + } + } + }); + + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); + assert.ok(resolver instanceof StaticPolicyResolver); + + const result = resolver.resolvePolicy('ping'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('create with commands', () => { + it('should classify keyless commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'ping', + arity: -1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('ping'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('should classify keyed commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'get', + arity: 2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: [Buffer.from('key')] as any + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('get'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + }); + + it('should use explicit policies when available', async () => { + const mockCommands: Array = [ + { + name: 'dbsize', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: 'all_shards', response: 'agg_sum' }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('dbsize'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'agg_sum'); + } + }); + + it('should handle module commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'ft.search', + arity: -2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + // policies: { request: 'all_shards', response: 'special' }, + keySpecifications: [Buffer.from('key')] as any + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('ft.search'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'special'); + } + }); + + it('should handle valid module commands', async () => { + const mockCommands: Array = [ + { + name: 'json.get', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('json.get'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('resolvePolicy', () => { + it('should work with created resolver', async () => { + const mockCommands: Array = [ + { + name: 'test', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('test'); + assert.equal(result.ok, true); + }); + + it('should handle unknown commands', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('unknown'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-command'); + }); + + it('should handle unknown modules', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('unknown.command'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-module'); + }); + + it('should handle invalid command format', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('too.many.dots.here'); + assert.equal(result.ok, false); + assert.equal(result.error, 'wrong-command-or-module-name'); + }); + }); + + describe('edge cases', () => { + it('should handle commands with partial policies', async () => { + const mockCommands: Array = [ + { + name: 'partial-request', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: 'all_nodes', response: undefined }, + keySpecifications: [Buffer.from('key')] as any + }, + { + name: 'partial-response', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: 'agg_sum' }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + // Command with only request policy should fall back to defaults + let result = resolver.resolvePolicy('partial-request'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + + // Command with only response policy should fall back to defaults + result = resolver.resolvePolicy('partial-response'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM); + } + }); + + it('should handle empty command list', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + assert.ok(resolver instanceof StaticPolicyResolver); + + const result = resolver.resolvePolicy('any-command'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-command'); + }); + }); + + describe('integration tests', () => { + testUtils.testWithClient('should work with real Redis client', async client => { + const resolver = await DynamicPolicyResolverFactory.create(() => client.command()); + assert.ok(resolver instanceof StaticPolicyResolver); + + // Test that ping command is classified as keyless + const pingResult = resolver.resolvePolicy('ping'); + if (pingResult.ok) { + assert.equal(pingResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS); + assert.equal(pingResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED); + } else { + assert.fail('Expected pingResult.ok to be true'); + } + + // Test that get command is classified as keyed + const getResult = resolver.resolvePolicy('get'); + if (getResult.ok) { + assert.equal(getResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(getResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } else { + assert.fail('Expected getResult.ok to be true'); + } + + // Test that dbsize command uses explicit policies if available + const dbsizeResult = resolver.resolvePolicy('dbsize'); + + if (dbsizeResult.ok) { + assert.ok( + dbsizeResult.value.request === 'all_shards' && dbsizeResult.value.response === 'agg_sum' + ); + } else { + assert.fail('Expected dbsizeResult.ok to be true'); + } + }, GLOBAL.SERVERS.OPEN); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts new file mode 100644 index 00000000000..24a1761451a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -0,0 +1,10 @@ +export type { Either, PolicyResult, PolicyResolver } from './types'; + +export { StaticPolicyResolver } from './static-policy-resolver'; +export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; + +export * from './policies-constants'; +export type { ModulePolicyRecords, CommandPolicyRecords } from './static-policies-data'; +export { POLICIES } from './static-policies-data'; + +// export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts new file mode 100644 index 00000000000..d27c550d755 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -0,0 +1,30 @@ +export const REQUEST_POLICIES_WITH_DEFAULTS = { + ALL_NODES: "all_nodes", + ALL_SHARDS: "all_shards", + MULTI_SHARD: "multi_shard", + SPECIAL: "special", + DEFAULT_KEYLESS: "default-keyless", + DEFAULT_KEYED: "default-keyed" +} as const; + +export type RequestPolicyWithDefaults = typeof REQUEST_POLICIES_WITH_DEFAULTS[keyof typeof REQUEST_POLICIES_WITH_DEFAULTS]; + +export const RESPONSE_POLICIES_WITH_DEFAULTS = { + ONE_SUCCEEDED: "one_succeeded", + ALL_SUCCEEDED: "all_succeeded", + AGG_LOGICAL_AND: "agg_logical_and", + AGG_LOGICAL_OR: "agg_logical_or", + AGG_MIN: "agg_min", + AGG_MAX: "agg_max", + AGG_SUM: "agg_sum", + SPECIAL: "special", + DEFAULT_KEYLESS: "default-keyless", + DEFAULT_KEYED: "default-keyed" +} as const; + +export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof typeof RESPONSE_POLICIES_WITH_DEFAULTS]; + +export interface CommandPolicies { + readonly request: RequestPolicyWithDefaults; + readonly response: ResponsePolicyWithDefaults; +} \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts new file mode 100644 index 00000000000..68c44c3129d --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -0,0 +1,59 @@ +import { CommandPolicies, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; + +export type CommandPolicyRecords = Record; +// The response of the COMMAND command uses "." to separate the module name from the command name. +// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. +export type ModulePolicyRecords = Record; + +export const POLICIES: ModulePolicyRecords = { + ft: { + create: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + search: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + aggregate: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + sugadd: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + sugget: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + sugdel: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + suglen: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + spellcheck: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + cursor: { + request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + }, + dictadd: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + dictdel: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + dictdump: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + } + } +} as const; diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts new file mode 100644 index 00000000000..2d9472fd95d --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -0,0 +1,61 @@ +import type { PolicyResult, PolicyResolver } from './types'; +import { POLICIES } from './static-policies-data'; + +export class StaticPolicyResolver implements PolicyResolver { + private readonly fallbackResolver: PolicyResolver | null = null; + + constructor( + private readonly policies = POLICIES, + fallbackResolver?: PolicyResolver + ) { + this.fallbackResolver = fallbackResolver || null; + } + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new StaticPolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): StaticPolicyResolver { + return new StaticPolicyResolver(this.policies, fallbackResolver); + } + + resolvePolicy(command: string): PolicyResult { + const parts = command.split('.'); + + if (parts.length > 2) { + return { ok: false, error: 'wrong-command-or-module-name' }; + } + + const [moduleName, commandName] = parts.length === 1 + ? ['std', command] + : parts; + + if (!this.policies[moduleName]) { + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(command); + } + + // For std module commands, return 'unknown-command' instead of 'unknown-module' + // to provide better UX for single-word commands + if (moduleName === 'std') { + return { ok: false, error: 'unknown-command' }; + } + return { ok: false, error: 'unknown-module' }; + } + + if (!this.policies[moduleName][commandName]) { + // Try fallback resolver if available + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(command); + } + return { ok: false, error: 'unknown-command' }; + } + + return { + ok: true, + value: this.policies[moduleName][commandName] + } + } +} diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts new file mode 100644 index 00000000000..837a837d74a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -0,0 +1,9 @@ +import testUtils, { GLOBAL } from '../../test-utils'; + +describe('Cluster Request-Response Policies', () => { + testUtils.testWithCluster('should resolve policies correctly', async cluster => { + + await cluster.get('foo') + + }, GLOBAL.CLUSTERS.OPEN); +}); diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts new file mode 100644 index 00000000000..27d2cae8a7b --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -0,0 +1,22 @@ +import type { CommandPolicies } from './policies-constants'; + +export type Either = + | { readonly ok: true; readonly value: TOk } + | { readonly ok: false; readonly error: TError }; + +export type PolicyResult = Either; + +export interface PolicyResolver { + /** + * The response of the COMMAND command uses "." to separate the module name from the command name. + */ + resolvePolicy(command: string): PolicyResult; + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new PolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): PolicyResolver; +} diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 82479e6c1fb..d2a16cee59c 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -342,7 +342,9 @@ export type CommandRawReply = [ firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Array + categories: Array, + tips: Array, + keySpecifications: string ]; export type CommandReply = { @@ -352,12 +354,13 @@ export type CommandReply = { firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Set + categories: Set, + keySpecifications: string }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, _tips, keySpecifications]: CommandRawReply ): CommandReply { return { name, @@ -366,7 +369,8 @@ export function transformCommandReply( firstKeyIndex, lastKeyIndex, step, - categories: new Set(categories) + categories: new Set(categories), + keySpecifications }; } From 050ec9492ac43b4f2d9c79c12025b06470f79abe Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:46:25 +0300 Subject: [PATCH 02/54] use lowercase for command matching --- .../static-policy-resolver.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 2d9472fd95d..9d7772d40f5 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -22,19 +22,22 @@ export class StaticPolicyResolver implements PolicyResolver { } resolvePolicy(command: string): PolicyResult { - const parts = command.split('.'); + const parts = command.toLowerCase().split('.'); + if (parts.length > 2) { return { ok: false, error: 'wrong-command-or-module-name' }; } const [moduleName, commandName] = parts.length === 1 - ? ['std', command] + ? ['std', parts[0]] : parts; + console.log(`module name `, moduleName, `command name `, commandName); + if (!this.policies[moduleName]) { if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(command); + return this.fallbackResolver.resolvePolicy(commandName); } // For std module commands, return 'unknown-command' instead of 'unknown-module' @@ -48,7 +51,7 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName][commandName]) { // Try fallback resolver if available if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(command); + return this.fallbackResolver.resolvePolicy(commandName); } return { ok: false, error: 'unknown-command' }; } From f1a2044916ea83d13594ee46182bc9bcb13f9a9a Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:46:39 +0300 Subject: [PATCH 03/54] expose commandName getter --- packages/client/lib/client/parser.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index bc45f5e4651..74ddaea5920 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -90,6 +90,14 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } + get commandName(): string | undefined { + let cmdName = this.#redisArgs[0]; + if (cmdName instanceof Buffer) { + return cmdName.toString(); + } + return cmdName; + } + push(...arg: Array) { this.#redisArgs.push(...arg); }; From cc42cb2cd79c509255bfcb4fb6e5b6dad0eba971 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:48:00 +0300 Subject: [PATCH 04/54] pass down command name and search policy --- packages/client/lib/cluster/index.ts | 16 ++++++--- .../request-response-policies/test.spec.ts | 35 +++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 8c1705f128b..2883549cb35 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -186,12 +186,12 @@ export default class RedisCluster< return async function (this: ProxyCluster, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - console.log(parser, parser.redisArgs[0]); return this._self._execute( parser.firstKey, command.IS_READ_ONLY, this._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -208,6 +208,7 @@ export default class RedisCluster< parser.firstKey, command.IS_READ_ONLY, this._self._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -226,6 +227,7 @@ export default class RedisCluster< parser.firstKey, fn.IS_READ_ONLY, this._self._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) ); }; @@ -244,6 +246,7 @@ export default class RedisCluster< parser.firstKey, script.IS_READ_ONLY, this._commandOptions, + parser.commandName!, (client, opts) => client._executeScript(script, parser, opts, transformReply) ); }; @@ -500,12 +503,16 @@ export default class RedisCluster< firstKey: RedisArgument | undefined, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, + commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { - console.log(`executing command `, firstKey, isReadonly, options); const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - const p = this._policyResolver.resolvePolicy("ping") - console.log(`ping policy `, p); + const policyResult = this._policyResolver.resolvePolicy(commandName) + if(policyResult.ok) { + //TODO + } else { + //TODO + } let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); let i = 0; @@ -596,6 +603,7 @@ export default class RedisCluster< firstKey, isReadonly, opts, + args[0] instanceof Buffer ? args[0].toString() : args[0], (client, opts) => client.sendCommand(args, opts) ); } diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts index 837a837d74a..2e3f15c389a 100644 --- a/packages/client/lib/cluster/request-response-policies/test.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -1,9 +1,38 @@ import testUtils, { GLOBAL } from '../../test-utils'; +import RediSearch from '@redis/search'; + +import RedisBloomModules from '@redis/bloom'; +import RedisJSON from '@redis/json'; +import RedisTimeSeries from '@redis/time-series'; describe('Cluster Request-Response Policies', () => { - testUtils.testWithCluster('should resolve policies correctly', async cluster => { + testUtils.testWithClient('should resolve policies correctly', async client => { + await client.ft.SUGADD('index', 'string', 1); + await client.ft.dictAdd('index', 'foo'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + modules: { + ft: RediSearch + } + } + }); - await cluster.get('foo') + testUtils.testWithCluster('should resolve policies correctly', async cluster => { - }, GLOBAL.CLUSTERS.OPEN); + await cluster.ft.SUGADD('index', 'string', 1); + await cluster.ft.DICTADD('index', 'foo'); + await cluster.sendCommand(undefined, true, ['ft.dictadd', 'index', 'string']); + + }, { + ...GLOBAL.CLUSTERS.OPEN, + clusterConfiguration: { + modules: { + ft: RediSearch, + // ...RedisBloomModules, + // json: RedisJSON, + // ts: RedisTimeSeries + }, + } + }); }); From 77f28989084ff119be4ac660e7c53a11d5dcb3b2 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 11:52:49 +0300 Subject: [PATCH 05/54] partially parse tips and key specs [skip ci] for now we only extract: - request and response policy -> from tips - is the command keyless -> from key specs --- .../dynamic-policy-resolver-factory.ts | 2 +- .../dynamic-policy-resolver.spec.ts | 32 ++-- packages/client/lib/commands/COMMAND.spec.ts | 138 ++++++++++++++++-- .../lib/commands/generic-transformers.ts | 26 +++- 4 files changed, 162 insertions(+), 36 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 1712390a802..03d26c98598 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -89,7 +89,7 @@ export class DynamicPolicyResolverFactory { */ static #buildCommandPolicies(command: CommandReply): CommandPolicies { // Determine if command is keyless based on keySpecification - const isKeyless = command.keySpecifications === 'keyless'; + const isKeyless = command.isKeyless // Determine default policies based on key specification const defaultRequest = isKeyless diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index fe115addb37..f1a4feb16b2 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -49,8 +49,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -75,8 +75,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 1, step: 1, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: undefined, response: undefined }, + isKeyless: false } ]; @@ -101,8 +101,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: 'all_shards', response: 'agg_sum' }, - keySpecifications: 'keyless' + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true } ]; @@ -127,8 +127,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 1, step: 1, categories: new Set(), - // policies: { request: 'all_shards', response: 'special' }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: 'all_shards', response: 'special' }, + isKeyless: false } ]; @@ -153,8 +153,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -181,8 +181,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -232,8 +232,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: 'all_nodes', response: undefined }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false }, { name: 'partial-response', @@ -243,8 +243,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: 'agg_sum' }, - keySpecifications: 'keyless' + policies: { request: undefined, response: 'agg_sum' }, + isKeyless: true } ]; diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 860ffc30685..8a3900d9d66 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,17 +1,125 @@ -// import { strict as assert } from 'node:assert'; -// import testUtils, { GLOBAL } from '../test-utils'; -// import { transformArguments } from './COMMAND'; -// import { assertPingCommand } from './COMMAND_INFO.spec'; +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { parseArgs, transformCommandReply, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; +import COMMAND from './COMMAND'; -// describe('COMMAND', () => { -// it('transformArguments', () => { -// assert.deepEqual( -// transformArguments(), -// ['COMMAND'] -// ); -// }); +describe('COMMAND', () => { + it('transformArguments', () => { + assert.deepEqual( + parseArgs(COMMAND), + ['COMMAND'] + ); + }); -// testUtils.testWithClient('client.command', async client => { -// assertPingCommand((await client.command()).find(command => command.name === 'ping')); -// }, GLOBAL.SERVERS.OPEN); -// }); + describe('transformCommandReply', () => { + const testCases = [ + { + name: 'without policies', + input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], []] satisfies CommandRawReply, + expected: { + name: 'ping', + arity: -1, + flags: new Set([CommandFlags.STALE]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([CommandCategories.FAST]), + policies: { request: undefined, response: undefined }, + isKeyless: true + } + }, + { + name: 'with valid policies', + input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], []] satisfies CommandRawReply, + expected: { + name: 'dbsize', + arity: 1, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true + } + }, + { + name: 'with invalid policies', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification']] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: undefined }, + isKeyless: false + } + }, + { + name: 'with request policy only', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification']] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false + } + }, + { + name: 'with response policy only', + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: 'agg_max' }, + isKeyless: true + } + }, + { + name: 'with response policy only', + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: 'agg_max' }, + isKeyless: true + } + } + ]; + + testCases.forEach(testCase => { + it(testCase.name, () => { + assert.deepEqual( + transformCommandReply(testCase.input), + testCase.expected + ); + }); + }); + }); + + testUtils.testWithClient('client.command', async client => { + const result = ((await client.command()).find(command => command.name === 'dbsize')); + assert.equal(result?.name, 'dbsize'); + assert.equal(result?.arity, 1); + assert.equal(result?.policies?.request, 'all_shards'); + assert.equal(result?.policies?.response, 'agg_sum'); + }, GLOBAL.SERVERS.OPEN); +}); \ No newline at end of file diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index d2a16cee59c..ccc990d6797 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -1,4 +1,5 @@ import { BasicCommandParser, CommandParser } from '../client/parser'; +import { REQUEST_POLICIES_WITH_DEFAULTS, RequestPolicyWithDefaults, RESPONSE_POLICIES_WITH_DEFAULTS, ResponsePolicyWithDefaults } from '../cluster/request-response-policies'; import { RESP_TYPES } from '../RESP/decoder'; import { UnwrapReply, ArrayReply, BlobStringReply, BooleanReply, CommandArguments, DoubleReply, NullReply, NumberReply, RedisArgument, ReplyUnion, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; @@ -344,9 +345,10 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: string + keySpecifications: Array ]; + export type CommandReply = { name: string, arity: number, @@ -355,13 +357,25 @@ export type CommandReply = { lastKeyIndex: number, step: number, categories: Set, - keySpecifications: string + policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } + isKeyless: boolean }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, _tips, keySpecifications]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications]: CommandRawReply ): CommandReply { + + const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); + const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) + ? requestPolicyRaw as RequestPolicyWithDefaults + : undefined; + + const responsePolicyRaw = tips[1]?.replace('response_policy:', ''); + const responsePolicy = responsePolicyRaw && Object.values(RESPONSE_POLICIES_WITH_DEFAULTS).includes(responsePolicyRaw as ResponsePolicyWithDefaults) + ? responsePolicyRaw as ResponsePolicyWithDefaults + : undefined; + return { name, arity, @@ -370,7 +384,11 @@ export function transformCommandReply( lastKeyIndex, step, categories: new Set(categories), - keySpecifications + policies: { + request: requestPolicy, + response: responsePolicy + }, + isKeyless: keySpecifications.length === 0 }; } From 74d64c5015015cda19cfaff8bb77127ad511c310 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 12:15:31 +0300 Subject: [PATCH 06/54] use response policies [skip ci] --- .../dynamic-policy-resolver-factory.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 03d26c98598..993d97d9a7e 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -100,10 +100,8 @@ export class DynamicPolicyResolverFactory { : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; return { - // request: command.policies.request ?? defaultRequest, - // response: command.policies.response ?? defaultResponse - request: defaultRequest, - response: defaultResponse + request: command.policies.request ?? defaultRequest, + response: command.policies.response ?? defaultResponse }; } } \ No newline at end of file From ccc20b4303038373ed51ad26eaeda7a39148f81f Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 14:23:44 +0300 Subject: [PATCH 07/54] parse subcommands, update static policies actually fetched from server using the dynamic resolver --- packages/client/lib/cluster/index.ts | 2 + .../dynamic-policy-resolver-factory.ts | 25 +- .../request-response-policies/index.ts | 3 +- .../policies-constants.ts | 2 + .../static-policies-data.ts | 2914 ++++++++++++++++- .../request-response-policies/test.spec.ts | 13 - .../request-response-policies/types.ts | 5 + .../lib/commands/generic-transformers.ts | 14 +- 8 files changed, 2901 insertions(+), 77 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 2883549cb35..349ac3324e6 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -506,8 +506,10 @@ export default class RedisCluster< commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { + const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; const policyResult = this._policyResolver.resolvePolicy(commandName) + if(policyResult.ok) { //TODO } else { diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 993d97d9a7e..750ee491f8a 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -1,9 +1,8 @@ import type { CommandReply } from '../../commands/generic-transformers'; import type { CommandPolicies } from './policies-constants'; import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; -import type { PolicyResolver } from './types'; +import type { PolicyResolver, ModulePolicyRecords } from './types'; import { StaticPolicyResolver } from './static-policy-resolver'; -import type { ModulePolicyRecords } from './static-policies-data'; /** * Function type that returns command information from Redis @@ -28,7 +27,7 @@ export class DynamicPolicyResolverFactory { static async create( commandFetcher: CommandFetcher, fallbackResolver?: PolicyResolver - ): Promise { + ): Promise { const commands = await commandFetcher(); const policies: ModulePolicyRecords = {}; @@ -98,10 +97,28 @@ export class DynamicPolicyResolverFactory { const defaultResponse = isKeyless ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + + let subcommands: Record | undefined; + if(command.subcommands.length > 0) { + subcommands = {}; + for (const subcommand of command.subcommands) { + + // Subcommands are in format "parentCommand|subcommand" + const parts = subcommand.name.split("\|") + if(parts.length !== 2) { + throw new Error(`Invalid subcommand name: ${subcommand.name}`); + } + const subcommandName = parts[1]; + + subcommands[subcommandName] = DynamicPolicyResolverFactory.#buildCommandPolicies(subcommand); + } + } return { request: command.policies.request ?? defaultRequest, - response: command.policies.response ?? defaultResponse + response: command.policies.response ?? defaultResponse, + isKeyless, + subcommands }; } } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index 24a1761451a..e4b2410ba89 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -1,10 +1,9 @@ -export type { Either, PolicyResult, PolicyResolver } from './types'; +export type { Either, PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; export { StaticPolicyResolver } from './static-policy-resolver'; export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; export * from './policies-constants'; -export type { ModulePolicyRecords, CommandPolicyRecords } from './static-policies-data'; export { POLICIES } from './static-policies-data'; // export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index d27c550d755..4045d9955fc 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -27,4 +27,6 @@ export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[ export interface CommandPolicies { readonly request: RequestPolicyWithDefaults; readonly response: ResponsePolicyWithDefaults; + readonly subcommands?: Record; + readonly isKeyless: boolean; } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 68c44c3129d..327541fcd6c 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1,59 +1,2865 @@ -import { CommandPolicies, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; - -export type CommandPolicyRecords = Record; -// The response of the COMMAND command uses "." to separate the module name from the command name. -// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. -export type ModulePolicyRecords = Record; +import { ModulePolicyRecords } from "./types"; export const POLICIES: ModulePolicyRecords = { - ft: { - create: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - search: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - aggregate: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - sugadd: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - sugget: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - sugdel: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - suglen: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - spellcheck: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - cursor: { - request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS - }, - dictadd: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - dictdel: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - dictdump: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + "std": { + "getrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zlexcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "waitaof": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true + }, + "psubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "geodist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "flushdb": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "lpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xreadgroup": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "eval": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "substr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebyrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "memory": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "purge": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "malloc-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "usage": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } + }, + "hgetdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpersist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "persist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "llen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hello": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exec": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hpexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "acl": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "deluser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "genpass": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dryrun": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "save": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "cat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "users": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "whoami": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "log": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setuser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "getuser": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "sort": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "latency": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "history": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "histogram": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "latest": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "graph": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "zincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "auth": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "echo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "georadiusbymember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zcard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hsetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "restore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geoadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "subscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "watch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "fcall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sort_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "discard": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hrandfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hstrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xinfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "groups": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "consumers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "stream": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "flushall": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "linsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geopos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ping": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "zscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ssubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slaveof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bitcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "evalsha_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "touch": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "bgsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pfcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pubsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "shardnumsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "numpat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "numsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "channels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shardchannels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "lindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geohash": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xgroup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "setid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "destroy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } + }, + "xadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "randomkey": { + "request": "all_shards", + "response": "special", + "isKeyless": true + }, + "bzpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitfield_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hsetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rename": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "shutdown": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slowlog": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { + "request": "all_nodes", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "len": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "setex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xack": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "client": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "caching": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setinfo": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "setname": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "no-evict": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reply": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "tracking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unblock": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "trackinginfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unpause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "id": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getredir": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getname": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "no-touch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "unsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "multi": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrevrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "psetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xsetid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xautoclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrangestore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "replconf": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keys": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true + }, + "command": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysandflags": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "count": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "docs": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "exists": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "sismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "function": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "dump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delete": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "restore": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "load": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "flush": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "xread": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "append": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "move": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "lmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunsubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "migrate": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scan": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "lcs": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "quit": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "cluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "addslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "links": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "addslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keyslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "meet": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "countkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "count-failure-reports": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shards": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myshardid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "flushslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slaves": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "nodes": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "saveconfig": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set-config-epoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bumpepoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicas": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "forget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "spop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xpending": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "select": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "sintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "srandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "msetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "script": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "load": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "exists": { + "request": "all_shards", + "response": "agg_logical_and", + "isKeyless": true + }, + "flush": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "zrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "save": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "smove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "spublish": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "fcall_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lolwut": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bzpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ltrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "asking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrevrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "restore-asking": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "smembers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "srem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "httl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lastsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hmget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "module": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unload": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "loadex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "sadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "monitor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "geosearch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "copy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "publish": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bgrewriteaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "config": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "set": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "resetstat": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "get": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rewrite": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "punsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "xclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearchstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfdebug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "unwatch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unlink": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "renamenx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "object": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "encoding": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "refcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "idletime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "freq": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "time": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "role": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfselftest": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zmscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "smismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "readonly": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "readwrite": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pfmerge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "dbsize": { + "request": "all_shards", + "response": "agg_sum", + "isKeyless": true + }, + "dump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "multi_shard", + "response": "default-keyed", + "isKeyless": false + }, + "mset": { + "request": "multi_shard", + "response": "all_succeeded", + "isKeyless": false + }, + "wait": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true + }, + "xdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "evalsha": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "psync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "swapdb": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hvals": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "replicaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "eval_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "FT": { + "ALIASADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALIASUPDATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SPELLCHECK": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_DROPIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DROP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "EXPLAINCLI": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGGET": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "SYNADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TAGVALS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "EXPLAIN": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALTER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CURSOR": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_LIST": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_CREATEIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTDEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALIASDEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SYNDUMP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGDEL": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "SUGADD": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "_DROPINDEXIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SYNUPDATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "MGET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGLEN": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "DEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALIASDELIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALIASADDIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DROPINDEX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALTERIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "PROFILE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CREATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTDUMP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "json": { + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "clear": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "objkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "strappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "numincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "forget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "nummultby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "objlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "numpowby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "toggle": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "resp": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cms": { + "initbyprob": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "initbydim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "bf": { + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "card": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "ts": { + "mrevrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "alter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "revrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createrule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "range": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "queryindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "deleterule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "tdigest": { + "max": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "byrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "byrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "trimmed_mean": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "revrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "min": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "cdf": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "quantile": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cf": { + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "compact": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insertnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "addnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "topk": { + "list": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "search": { + "CLUSTERSET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLUSTERINFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLUSTERREFRESH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "_FT": { + "CONFIG": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SAFEADD": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "DEBUG": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "INFO_TAGIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "VECSIM_INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SPEC_INVIDXES_INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_HNSW": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL_PAUSE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_FORCEBGINVOKE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SHARD_CONNECTION_STATES": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_STOP_SCHEDULE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_TAGIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SET_MONITOR_EXPIRATION": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_TERMS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_CONTINUE_SCHEDULE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_NUMIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_CLEAN_NUMERIC": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "HELP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "FT.AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_SUFFIX_TRIE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DOCINFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_FT.AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "WORKERS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "RESUME_TOPOLOGY_UPDATER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_NUMIDXTREE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_INVIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLEAR_PENDING_TOPOLOGY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_FT.SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "BG_SCAN_CONTROLLER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "PAUSE_TOPOLOGY_UPDATER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GIT_SHA": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "IDTODOCID": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "INVIDX_SUMMARY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_GEOMIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_FORCEINVOKE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL_EXPIRE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "FT.SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_PHONETIC_HASH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DOCIDTOID": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_PREFIX_TRIE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DELETE_LOCAL_CURSORS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_WAIT_FOR_JOBS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "NUMIDX_SUMMARY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + } + }, + "timeseries": { + "REFRESHCLUSTER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true } } } as const; diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts index 2e3f15c389a..739d892ecbe 100644 --- a/packages/client/lib/cluster/request-response-policies/test.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -6,23 +6,10 @@ import RedisJSON from '@redis/json'; import RedisTimeSeries from '@redis/time-series'; describe('Cluster Request-Response Policies', () => { - testUtils.testWithClient('should resolve policies correctly', async client => { - await client.ft.SUGADD('index', 'string', 1); - await client.ft.dictAdd('index', 'foo'); - }, { - ...GLOBAL.SERVERS.OPEN, - clientOptions: { - modules: { - ft: RediSearch - } - } - }); testUtils.testWithCluster('should resolve policies correctly', async cluster => { await cluster.ft.SUGADD('index', 'string', 1); - await cluster.ft.DICTADD('index', 'foo'); - await cluster.sendCommand(undefined, true, ['ft.dictadd', 'index', 'string']); }, { ...GLOBAL.CLUSTERS.OPEN, diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts index 27d2cae8a7b..187c0348c22 100644 --- a/packages/client/lib/cluster/request-response-policies/types.ts +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -20,3 +20,8 @@ export interface PolicyResolver { */ withFallback(fallbackResolver: PolicyResolver): PolicyResolver; } + +export type CommandPolicyRecords = Record; +// The response of the COMMAND command uses "." to separate the module name from the command name. +// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. +export type ModulePolicyRecords = Record; diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index ccc990d6797..65a452bdb4f 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -345,7 +345,8 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: Array + keySpecifications: Array, + subcommands: Array ]; @@ -358,14 +359,16 @@ export type CommandReply = { step: number, categories: Set, policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } - isKeyless: boolean + isKeyless: boolean, + subcommands: Array }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply ): CommandReply { + const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) ? requestPolicyRaw as RequestPolicyWithDefaults @@ -376,6 +379,8 @@ export function transformCommandReply( ? responsePolicyRaw as ResponsePolicyWithDefaults : undefined; + const subcommands = subcommandsReply.map(transformCommandReply); + return { name, arity, @@ -388,7 +393,8 @@ export function transformCommandReply( request: requestPolicy, response: responsePolicy }, - isKeyless: keySpecifications.length === 0 + isKeyless: keySpecifications.length === 0, + subcommands }; } From 49975d1c7d8da0bec14ae7d4bb23afaddfb8a232 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 16:25:37 +0300 Subject: [PATCH 08/54] fix tests --- .../dynamic-policy-resolver.spec.ts | 27 +++++++++++------ packages/client/lib/commands/COMMAND.spec.ts | 30 +++++++++++-------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index f1a4feb16b2..6f05b62111b 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -21,7 +21,8 @@ describe('DynamicPolicyResolverFactory', () => { std: { ping: { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true } } }); @@ -50,7 +51,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -76,7 +78,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 1, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } ]; @@ -102,7 +105,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: 'all_shards', response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -128,7 +132,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 1, categories: new Set(), policies: { request: 'all_shards', response: 'special' }, - isKeyless: false + isKeyless: false, + subcommands: [] } ]; @@ -154,7 +159,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -182,7 +188,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -233,7 +240,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: 'all_nodes', response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] }, { name: 'partial-response', @@ -244,7 +252,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 8a3900d9d66..7ace4de3c70 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -15,7 +15,7 @@ describe('COMMAND', () => { const testCases = [ { name: 'without policies', - input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], []] satisfies CommandRawReply, + input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], [], []] satisfies CommandRawReply, expected: { name: 'ping', arity: -1, @@ -25,12 +25,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([CommandCategories.FAST]), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with valid policies', - input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], []] satisfies CommandRawReply, + input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], [], []] satisfies CommandRawReply, expected: { name: 'dbsize', arity: 1, @@ -40,12 +41,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: 'all_shards', response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with invalid policies', - input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification']] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification'], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -55,12 +57,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } }, { name: 'with request policy only', - input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification']] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification'], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -70,12 +73,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: 'all_nodes', response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } }, { name: 'with response policy only', - input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], [], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -85,12 +89,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with response policy only', - input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], [], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -100,7 +105,8 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, - isKeyless: true + isKeyless: true, + subcommands: [] } } ]; From 0f9b7b56ca9309fc013f874aa43d6ab9b5e789e9 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:38:12 +0300 Subject: [PATCH 09/54] resolve commands and subcommands --- packages/client/lib/client/parser.ts | 13 ++++++----- .../static-policy-resolver.ts | 23 +++++++++++++++---- .../request-response-policies/types.ts | 4 +++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 74ddaea5920..0da883db7cd 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -33,11 +33,14 @@ export function prefixKeys(keyPrefix: RedisArgument | undefined, keys: RedisVari : [prefixKey(keyPrefix, keys)]; } +export type CommandIdentifier = { command: string, subcommand: string }; + export interface CommandParser { redisArgs: ReadonlyArray; keys: ReadonlyArray; firstKey: RedisArgument | undefined; preserve: unknown; + commandIdentifier: CommandIdentifier; push: (...arg: Array) => unknown; pushVariadic: (vals: RedisVariadicArgument) => unknown; @@ -90,12 +93,10 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } - get commandName(): string | undefined { - let cmdName = this.#redisArgs[0]; - if (cmdName instanceof Buffer) { - return cmdName.toString(); - } - return cmdName; + get commandIdentifier(): CommandIdentifier { + const command = this.#redisArgs[0] instanceof Buffer ? this.#redisArgs[0].toString() : this.#redisArgs[0]; + const subcommand = this.#redisArgs[1] instanceof Buffer ? this.#redisArgs[1].toString() : this.#redisArgs[1]; + return { command, subcommand }; } push(...arg: Array) { diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 9d7772d40f5..6847ef735d6 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -1,5 +1,6 @@ import type { PolicyResult, PolicyResolver } from './types'; import { POLICIES } from './static-policies-data'; +import { CommandIdentifier } from '../../client/parser'; export class StaticPolicyResolver implements PolicyResolver { private readonly fallbackResolver: PolicyResolver | null = null; @@ -21,8 +22,8 @@ export class StaticPolicyResolver implements PolicyResolver { return new StaticPolicyResolver(this.policies, fallbackResolver); } - resolvePolicy(command: string): PolicyResult { - const parts = command.toLowerCase().split('.'); + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { + const parts = commandIdentifier.command.toLowerCase().split('.'); if (parts.length > 2) { @@ -37,7 +38,7 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName]) { if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(commandName); + return this.fallbackResolver.resolvePolicy(commandIdentifier); } // For std module commands, return 'unknown-command' instead of 'unknown-module' @@ -51,14 +52,26 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName][commandName]) { // Try fallback resolver if available if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(commandName); + return this.fallbackResolver.resolvePolicy(commandIdentifier); } return { ok: false, error: 'unknown-command' }; } + const policy = this.policies[moduleName][commandName]; + + if(policy.subcommands) { + const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand]; + if(subcommandPolicy) { + return { + ok: true, + value: subcommandPolicy + } + } + } + return { ok: true, - value: this.policies[moduleName][commandName] + value: policy } } } diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts index 187c0348c22..027746710a6 100644 --- a/packages/client/lib/cluster/request-response-policies/types.ts +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -1,3 +1,4 @@ +import { CommandIdentifier } from '../../client/parser'; import type { CommandPolicies } from './policies-constants'; export type Either = @@ -6,11 +7,12 @@ export type Either = export type PolicyResult = Either; + export interface PolicyResolver { /** * The response of the COMMAND command uses "." to separate the module name from the command name. */ - resolvePolicy(command: string): PolicyResult; + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult; /** * Sets a fallback resolver to use when policies are not found in this resolver. From 653a189561691d929dd09453cff84e9813850530 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:43:22 +0300 Subject: [PATCH 10/54] add comments to all policies --- .../policies-constants.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index 4045d9955fc..fff861c56af 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -1,24 +1,108 @@ export const REQUEST_POLICIES_WITH_DEFAULTS = { + /** + * The client should execute the command on all nodes - masters and replicas alike. + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ ALL_NODES: "all_nodes", + /** + * The client should execute the command on all master shards (e.g., the DBSIZE command). + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ ALL_SHARDS: "all_shards", + /** + * The client should execute the command on several shards. + * The client should split the inputs according to the hash slots of its input key name arguments. + * For example, the command DEL {foo} {foo}1 bar should be split to DEL {foo} {foo}1 and DEL bar. + * If the keys are hashed to more than a single slot, + * the command must be split even if all the slots are managed by the same shard. + * Examples for such commands include MSET, MGET and DEL. + * However, note that SUNIONSTORE isn't considered as multi_shard because all of its keys must belong to the same hash slot. + */ MULTI_SHARD: "multi_shard", + /** + * Indicates a non-trivial form of the client's request policy, such as the SCAN command. + */ SPECIAL: "special", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 1. The command doesn't accept key name arguments: + * the client can execute the command on an arbitrary shard. + */ DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 2. For commands that accept one or more key name arguments: + * the client should route the command to a single shard, + * as determined by the hash slot of the input keys. + */ DEFAULT_KEYED: "default-keyed" } as const; export type RequestPolicyWithDefaults = typeof REQUEST_POLICIES_WITH_DEFAULTS[keyof typeof REQUEST_POLICIES_WITH_DEFAULTS]; export const RESPONSE_POLICIES_WITH_DEFAULTS = { + /** + * The client should return success if at least one shard didn't reply with an error. + * The client should reply with the first non-error reply it obtains. + * If all shards return an error, the client can reply with any one of these. + * Example: SCRIPT KILL command that's sent to all shards. + */ ONE_SUCCEEDED: "one_succeeded", + /** + * The client should return successfully only if there are no error replies. + * Even a single error reply should disqualify the aggregate and be returned. + * Otherwise, the client should return one of the non-error replies. + * Examples: CONFIG SET, SCRIPT FLUSH and SCRIPT LOAD commands. + */ ALL_SUCCEEDED: "all_succeeded", + /** + * The client should return the result of a logical AND operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + * Example: SCRIPT EXISTS command returns 1 only when all shards report that a given script SHA1 sum is in their cache. + */ AGG_LOGICAL_AND: "agg_logical_and", + /** + * The client should return the result of a logical OR operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + */ AGG_LOGICAL_OR: "agg_logical_or", + /** + * The client should return the minimal value from the replies. + * Only applies to numerical replies. + * Example: WAIT command should return the minimal number of synchronized replicas from all shards. + */ AGG_MIN: "agg_min", + /** + * The client should return the maximal value from the replies. + * Only applies to numerical replies. + */ AGG_MAX: "agg_max", + /** + * The client should return the sum of replies. + * Only applies to numerical replies. + * Example: DBSIZE command. + */ AGG_SUM: "agg_sum", + /** + * Indicates a non-trivial form of reply policy. + * Example: INFO command with complex aggregation logic. + */ SPECIAL: "special", + /** + * The default behavior for commands without a response_policy tip that don't accept key name arguments: + * the client can aggregate all replies within a single nested data structure. + * Example: KEYS command replies should be packed in a single array in no particular order. + */ DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior for commands without a response_policy tip that accept one or more key name arguments: + * the client needs to retain the same order of replies as the input key names. + * Example: MGET's aggregated reply should maintain key order. + */ DEFAULT_KEYED: "default-keyed" } as const; From 3555f87f77ce7a8b17228b1f6022cde98bd0a1ea Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:43:51 +0300 Subject: [PATCH 11/54] POC partial implementation of request-response routing and aggregation --- packages/client/lib/cluster/cluster-slots.ts | 14 + packages/client/lib/cluster/index.ts | 227 +++++++++----- .../generic-aggregators.ts | 126 ++++++++ readonly-discrepancies.md | 205 +++++++++++++ scripts/readonly-discrepancies.mjs | 288 ++++++++++++++++++ 5 files changed, 791 insertions(+), 69 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/generic-aggregators.ts create mode 100644 readonly-discrepancies.md create mode 100644 scripts/readonly-discrepancies.mjs diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index a3d69688162..51a79b50d90 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -818,6 +818,20 @@ export default class RedisClusterSlots< } } + getAllClients() { + return Array.from(this.#clients()); + } + + getAllMasterClients() { + const result = []; + for (const master of this.masters) { + if (master.client) { + result.push(master.client); + } + } + return result; + } + async getClientAndSlotNumber( firstKey: RedisArgument | undefined, isReadonly: boolean | undefined diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 349ac3324e6..4f74dd6c275 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -10,13 +10,14 @@ import { PubSubListener, PubSubListeners } from '../client/pub-sub'; import { ErrorReply } from '../errors'; import { RedisTcpSocketOptions } from '../client/socket'; import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/cache'; -import { BasicCommandParser } from '../client/parser'; +import { BasicCommandParser, CommandParser } from '../client/parser'; import { ASKING_CMD } from '../commands/ASKING'; import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; +import { aggregateLogicalAnd, aggregateLogicalOr, aggregateMax, aggregateMerge, aggregateMin, aggregateSum } from './request-response-policies/generic-aggregators'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -188,10 +189,9 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -205,10 +205,9 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._self._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -224,10 +223,9 @@ export default class RedisCluster< fn.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, fn.IS_READ_ONLY, this._self._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) ); }; @@ -243,10 +241,9 @@ export default class RedisCluster< script.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, script.IS_READ_ONLY, this._commandOptions, - parser.commandName!, (client, opts) => client._executeScript(script, parser, opts, transformReply) ); }; @@ -500,92 +497,180 @@ export default class RedisCluster< } async _execute( - firstKey: RedisArgument | undefined, + parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, - commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - const policyResult = this._policyResolver.resolvePolicy(commandName) - if(policyResult.ok) { - //TODO - } else { - //TODO + const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); + + if(!policyResult.ok) { + throw new Error(`Policy resolution error for ${parser.commandIdentifier}: ${policyResult.error}`); } - let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); - let i = 0; + const requestPolicy = policyResult.value.request + const responsePolicy = policyResult.value.response - let myFn = fn; + let clients: Array>; + // https://redis.io/docs/latest/develop/reference/command-tips + switch (requestPolicy) { - while (true) { - try { - const opts: ClusterCommandOptions = { ...options, slotNumber }; - return await myFn(client, opts); - } catch (_err) { - const err = _err as Error; - myFn = fn; + case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: + clients = this._slots.getAllClients() + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: + clients = this._slots.getAllMasterClients() + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: + clients = await Promise.all( + parser.keys.map(async (key) => (await this._slots.getClientAndSlotNumber(key, isReadonly)).client) + ); + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: + throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: + //TODO handle undefined case? + clients = [this._slots.getRandomNode().client!] + break; - // TODO: error class - if (++i > maxCommandRedirections || !(err instanceof Error)) { - if (err instanceof Error) { + case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: + clients = [(await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client] + break; + + default: + throw new Error(`Unknown request policy ${requestPolicy}`); + } + + const responsePromises = clients.map(async client => { + + let i = 0; + + let myFn = fn; + + while (true) { + try { + return await myFn(client, options); + } catch (_err) { + const err = _err as Error; + myFn = fn; + + // TODO: error class + if (++i > maxCommandRedirections || !(err instanceof Error)) { + if (err instanceof Error) { + publish(CHANNELS.ERROR, () => ({ + error: err, + origin: 'cluster', + internal: false, + clientId: client._clientId, + retryCount: i, + })); + } + throw err; + } + + if (err.message.startsWith('ASK')) { publish(CHANNELS.ERROR, () => ({ error: err, origin: 'cluster', - internal: false, + internal: true, clientId: client._clientId, retryCount: i, })); + const address = err.message.substring(err.message.lastIndexOf(' ') + 1); + let redirectTo = await this._slots.getMasterByAddress(address); + if (!redirectTo) { + await this._slots.rediscover(client); + redirectTo = await this._slots.getMasterByAddress(address); + } + + if (!redirectTo) { + throw new Error(`Cannot find node ${address}`); + } + + client = redirectTo; + myFn = this._handleAsk(fn); + continue; } - throw err; - } - if (err.message.startsWith('ASK')) { - publish(CHANNELS.ERROR, () => ({ - error: err, - origin: 'cluster', - internal: true, - clientId: client._clientId, - retryCount: i, - })); - const address = err.message.substring(err.message.lastIndexOf(' ') + 1); - let redirectTo = await this._slots.getMasterByAddress(address); - if (!redirectTo) { + if (err.message.startsWith('MOVED')) { + publish(CHANNELS.ERROR, () => ({ + error: err, + origin: 'cluster', + internal: true, + clientId: client._clientId, + retryCount: i, + })); await this._slots.rediscover(client); - redirectTo = await this._slots.getMasterByAddress(address); - } - - if (!redirectTo) { - throw new Error(`Cannot find node ${address}`); + client = (await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client; + continue; } - client = redirectTo; - myFn = this._handleAsk(fn); - continue; + throw err; } + } - if (err.message.startsWith('MOVED')) { - publish(CHANNELS.ERROR, () => ({ - error: err, - origin: 'cluster', - internal: true, - clientId: client._clientId, - retryCount: i, - })); - await this._slots.rediscover(client); - const clientAndSlot = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); - client = clientAndSlot.client; - slotNumber = clientAndSlot.slotNumber; - continue; - } + }) + + switch (responsePolicy) { + case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: { + return Promise.any(responsePromises); + } - throw err; + case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: { + const responses = await Promise.all(responsePromises); + return responses[0] + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: { + const responses = await Promise.all(responsePromises) + return aggregateLogicalAnd(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: { + const responses = await Promise.all(responsePromises) + return aggregateLogicalOr(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: { + const responses = await Promise.all(responsePromises); + return aggregateMin(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: { + const responses = await Promise.all(responsePromises); + return aggregateMax(responses); } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: { + const responses = await Promise.all(responsePromises); + return aggregateSum(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: { + throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: { + const responses = await Promise.all(responsePromises); + return aggregateMerge(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: { + const responses = await Promise.all(responsePromises); + return responses as T; + } + + default: + throw new Error(`Unknown response policy ${responsePolicy}`); } + } async sendCommand( @@ -601,11 +686,15 @@ export default class RedisCluster< ...this._commandOptions, ...options } + + const parser = new BasicCommandParser(); + firstKey && parser.push(firstKey) + args.forEach(arg => parser.push(arg)); + return this._self._execute( - firstKey, + parser, isReadonly, opts, - args[0] instanceof Buffer ? args[0].toString() : args[0], (client, opts) => client.sendCommand(args, opts) ); } diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts new file mode 100644 index 00000000000..de4202d2d44 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -0,0 +1,126 @@ +/** + * Aggregates multiple arrays of numbers using logical AND operation. + * @remarks + * This implementation is specifically designed for Array> type only, + * despite the generic type parameter. It is currently used by the SCRIPT EXISTS command + * which returns an array of 0s and 1s from each shard. + * The generic type parameter T is provided for usage ergonomy, but the actual input structure + * will be validated at runtime. + */ +export const aggregateLogicalAnd = (replies: Array): T => { + if (replies.length === 0) return [] as T; + if ( + !replies.every( + (reply): reply is number[] => + Array.isArray(reply) && + reply.every((value): value is number => typeof value === 'number') + ) + ) { + throw new Error( + 'All replies must be array of numbers for logical AND aggregation' + ); + } + + const result = Array(replies[0].length).fill(1); + + for (const reply of replies) { + for (let i = 0; i < reply.length; i++) { + result[i] = result[i] && reply[i]; + } + } + + return result as T; +}; + +//TODO fix this +export const aggregateLogicalOr = ( + replies: Array +): T => { + const result = Array((replies[0] as Array).length).fill(1); + for (const reply of replies) { + for (let i = 0; i < (reply as Array).length; i++) { + result[i] = result[i] || (reply as Array)[i]; + } + } + return result as T; +}; + +/** + * Aggregates multiple numbers by finding the minimum value. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. It is used by commands like WAIT + * which returns the minimal number of synchronized replicas from all shards. + * The generic type parameter T is provided for usage ergonomy, but the actual input structure + * will be validated at runtime. + */ +export const aggregateMin = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for min aggregation'); + } + return Math.min(...replies) as T; +}; + +/** + * Aggregates multiple numbers by finding the maximum value. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. The generic type parameter T is provided + * for usage ergonomy, but the actual input structure will be validated at runtime. + */ +export const aggregateMax = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for max aggregation'); + } + return Math.max(...replies) as T; +}; + +/** + * Aggregates multiple numbers by finding the sum of all values. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. The generic type parameter T is provided + * for usage ergonomy, but the actual input structure will be validated at runtime. + */ +export const aggregateSum = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for sum aggregation'); + } + return replies.reduce((acc, reply) => acc + reply, 0) as T; +}; + + +export const aggregateMerge = (replies: Array): T => { + if(replies.length === 0) return undefined as T; + + const firstReply = replies[0] + + if(Array.isArray(firstReply)) { + const set = new Set() + for(const reply of replies) { + for(const item of reply as Array) { + set.add(item); + } + } + return Array.from(set) as T; + } + + //TODO, maybe this needs to be plain object + if(firstReply instanceof Map) { + const map = new Map(); + for(const reply of replies) { + for(const [key, value] of reply as Map) { + map.set(key, value); + } + } + return map as T; + } + + //TODO remove + console.log('firstReply', firstReply, typeof firstReply); + throw new Error('Unsupported reply type for merge aggregation'); + +}; diff --git a/readonly-discrepancies.md b/readonly-discrepancies.md new file mode 100644 index 00000000000..dce9168e59c --- /dev/null +++ b/readonly-discrepancies.md @@ -0,0 +1,205 @@ + + +## BUG_WRITE_AS_RO (34) + +we mark IS_READ_ONLY but server flags `write` (cluster would route to a replica) + +| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File | +| --- | --- | --- | --- | --- | +| `FT.ALIASADD` | true | false | write, denyoom, module | `packages/search/lib/commands/ALIASADD.ts` | +| `FT.ALIASDEL` | true | false | write, module | `packages/search/lib/commands/ALIASDEL.ts` | +| `FT.ALIASUPDATE` | true | false | write, denyoom, module | `packages/search/lib/commands/ALIASUPDATE.ts` | +| `FT.ALTER` | true | false | write, denyoom, module | `packages/search/lib/commands/ALTER.ts` | +| `FT.CONFIG\|SET` | true | false | write, module | `packages/search/lib/commands/CONFIG_SET.ts` | +| `FT.CREATE` | true | false | write, denyoom, module | `packages/search/lib/commands/CREATE.ts` | +| `FT.DICTADD` | true | false | write, denyoom, module | `packages/search/lib/commands/DICTADD.ts` | +| `FT.DICTDEL` | true | false | write, module | `packages/search/lib/commands/DICTDEL.ts` | +| `FT.DROPINDEX` | true | false | write, module | `packages/search/lib/commands/DROPINDEX.ts` | +| `FT.SUGADD` | true | false | write, denyoom, module | `packages/search/lib/commands/SUGADD.ts` | +| `FT.SUGDEL` | true | false | write, module | `packages/search/lib/commands/SUGDEL.ts` | +| `FT.SYNUPDATE` | true | false | write, denyoom, module | `packages/search/lib/commands/SYNUPDATE.ts` | +| `blpop` | true | false | write, blocking | `packages/client/lib/commands/BLPOP.ts` | +| `brpop` | true | false | write, blocking | `packages/client/lib/commands/BRPOP.ts` | +| `getdel` | true | false | write, fast | `packages/client/lib/commands/GETDEL.ts` | +| `getex` | true | false | write, fast | `packages/client/lib/commands/GETEX.ts` | +| `getset` | true | false | write, denyoom, fast | `packages/client/lib/commands/GETSET.ts` | +| `hpexpireat` | true | false | write, fast | `packages/client/lib/commands/HPEXPIREAT.ts` | +| `hsetnx` | true | false | write, denyoom, fast | `packages/client/lib/commands/HSETNX.ts` | +| `linsert` | true | false | write, denyoom | `packages/client/lib/commands/LINSERT.ts` | +| `lrem` | true | false | write | `packages/client/lib/commands/LREM.ts` | +| `lset` | true | false | write, denyoom | `packages/client/lib/commands/LSET.ts` | +| `mset` | true | false | write, denyoom | `packages/client/lib/commands/MSET.ts` | +| `msetnx` | true | false | write, denyoom | `packages/client/lib/commands/MSETNX.ts` | +| `pexpire` | true | false | write, fast | `packages/client/lib/commands/PEXPIRE.ts` | +| `pexpireat` | true | false | write, fast | `packages/client/lib/commands/PEXPIREAT.ts` | +| `pfadd` | true | false | write, denyoom, fast | `packages/client/lib/commands/PFADD.ts` | +| `rename` | true | false | write | `packages/client/lib/commands/RENAME.ts` | +| `renamenx` | true | false | write, fast | `packages/client/lib/commands/RENAMENX.ts` | +| `restore-asking` | true | false | write, denyoom, asking | `packages/client/lib/commands/RESTORE-ASKING.ts` | +| `sort` | true | false | write, denyoom, movablekeys | `packages/client/lib/commands/SORT.ts` | +| `xreadgroup` | true | false | write, blocking, movablekeys | `packages/client/lib/commands/XREADGROUP.ts` | +| `zdiffstore` | true | false | write, denyoom, movablekeys | `packages/client/lib/commands/ZDIFFSTORE.ts` | +| `bf.reserve` | true | false | write, denyoom, module | `packages/bloom/lib/commands/bloom/RESERVE.ts` | + +## MISSED_RO (44) + +we do not mark IS_READ_ONLY but server flags `readonly` (lost replica routing) + +| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File | +| --- | --- | --- | --- | --- | +| `ts.info` | false | true | readonly, module | `packages/time-series/lib/commands/INFO_DEBUG.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts` | +| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts` | +| `ts.revrange` | false | true | readonly, module | `packages/time-series/lib/commands/REVRANGE.ts` | +| `ts.revrange` | false | true | readonly, module | `packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts` | +| `FT.AGGREGATE` | false | true | readonly, module | `packages/search/lib/commands/AGGREGATE.ts` | +| `FT.AGGREGATE` | false | true | readonly, module | `packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts` | +| `FT.SEARCH` | false | true | readonly, module | `packages/search/lib/commands/SEARCH_NOCONTENT.ts` | +| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts` | +| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHSCORES.ts` | +| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts` | +| `json.debug` | false | true | readonly, module | `packages/json/lib/commands/DEBUG_MEMORY.ts` | +| `json.get` | false | true | readonly, module | `packages/json/lib/commands/GET.ts` | +| `json.objkeys` | false | true | readonly, module | `packages/json/lib/commands/OBJKEYS.ts` | +| `fcall_ro` | false | true | readonly, noscript, stale, skip_monitor, no_mandatory_keys, movablekeys | `packages/client/lib/commands/FCALL_RO.ts` | +| `geosearch` | false | true | readonly | `packages/client/lib/commands/GEOSEARCH_WITH.ts` | +| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_IDX.ts` | +| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts` | +| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_LEN.ts` | +| `lpos` | false | true | readonly | `packages/client/lib/commands/LPOS_COUNT.ts` | +| `srandmember` | false | true | readonly | `packages/client/lib/commands/SRANDMEMBER_COUNT.ts` | +| `touch` | false | true | readonly, fast | `packages/client/lib/commands/TOUCH.ts` | +| `VLINKS` | false | true | readonly, module, fast | `packages/client/lib/commands/VLINKS_WITHSCORES.ts` | +| `VSIM` | false | true | readonly, module | `packages/client/lib/commands/VSIM_WITHSCORES.ts` | +| `xrevrange` | false | true | readonly | `packages/client/lib/commands/XREVRANGE.ts` | +| `zdiff` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZDIFF_WITHSCORES.ts` | +| `zinter` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZINTER_WITHSCORES.ts` | +| `zrandmember` | false | true | readonly | `packages/client/lib/commands/ZRANDMEMBER_COUNT.ts` | +| `zrandmember` | false | true | readonly | `packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts` | +| `zrangebyscore` | false | true | readonly | `packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts` | +| `zrange` | false | true | readonly | `packages/client/lib/commands/ZRANGE_WITHSCORES.ts` | +| `zrank` | false | true | readonly, fast | `packages/client/lib/commands/ZRANK_WITHSCORE.ts` | +| `zunion` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZUNION_WITHSCORES.ts` | +| `topk.query` | false | true | readonly, module | `packages/bloom/lib/commands/top-k/QUERY.ts` | +| `tdigest.byrevrank` | false | true | readonly, module | `packages/bloom/lib/commands/t-digest/BYREVRANK.ts` | +| `tdigest.revrank` | false | true | readonly, module | `packages/bloom/lib/commands/t-digest/REVRANK.ts` | +| `cf.exists` | false | true | readonly, module, fast | `packages/bloom/lib/commands/cuckoo/EXISTS.ts` | + +## NOISE (104) + +server has neither `readonly` nor `write` (admin/conn/pubsub/cluster) — likely by-design + +| Command | Ours IS_READ_ONLY | Server readonly | Server flags | RO ok? | Why | File | +| --- | --- | --- | --- | --- | --- | --- | +| `acl\|cat` | true | false | noscript, loading, stale | Yes | reads static ACL categories | `packages/client/lib/commands/ACL_CAT.ts` | +| `acl\|deluser` | true | false | admin, noscript, loading, stale | No | mutates ACL | `packages/client/lib/commands/ACL_DELUSER.ts` | +| `acl\|dryrun` | true | false | admin, noscript, loading, stale | Yes | simulates, no mutation | `packages/client/lib/commands/ACL_DRYRUN.ts` | +| `acl\|genpass` | true | false | noscript, loading, stale | Yes | pure RNG, node-local | `packages/client/lib/commands/ACL_GENPASS.ts` | +| `acl\|getuser` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_GETUSER.ts` | +| `acl\|list` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_LIST.ts` | +| `acl\|load` | true | false | admin, noscript, loading, stale | No | reloads ACL from file | `packages/client/lib/commands/ACL_LOAD.ts` | +| `acl\|log` | true | false | admin, noscript, loading, stale | Yes | reads ACL security log | `packages/client/lib/commands/ACL_LOG.ts` | +| `acl\|save` | true | false | admin, noscript, loading, stale | No | writes ACL to file | `packages/client/lib/commands/ACL_SAVE.ts` | +| `acl\|setuser` | true | false | admin, noscript, loading, stale | No | mutates ACL | `packages/client/lib/commands/ACL_SETUSER.ts` | +| `acl\|users` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_USERS.ts` | +| `acl\|whoami` | true | false | noscript, loading, stale | Yes | connection identity read | `packages/client/lib/commands/ACL_WHOAMI.ts` | +| `asking` | true | false | fast | Yes | connection-local cluster redirect marker | `packages/client/lib/commands/ASKING.ts` | +| `auth` | true | false | noscript, loading, stale, fast, no_auth, allow_busy | Yes | connection-local auth | `packages/client/lib/commands/AUTH.ts` | +| `bgrewriteaof` | true | false | admin, noscript, no_async_loading | No | triggers AOF rewrite on the node | `packages/client/lib/commands/BGREWRITEAOF.ts` | +| `bgsave` | true | false | admin, noscript, no_async_loading | No | triggers RDB save on the node | `packages/client/lib/commands/BGSAVE.ts` | +| `client\|caching` | true | false | noscript, loading, stale | Yes | connection-local tracking toggle | `packages/client/lib/commands/CLIENT_CACHING.ts` | +| `client\|getname` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_GETNAME.ts` | +| `client\|getredir` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_GETREDIR.ts` | +| `client\|id` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_ID.ts` | +| `client\|info` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_INFO.ts` | +| `client\|kill` | true | false | admin, noscript, loading, stale | No | mutates other connections (admin) | `packages/client/lib/commands/CLIENT_KILL.ts` | +| `client\|list` | true | false | admin, noscript, loading, stale | Yes | reads connections (per-node view) | `packages/client/lib/commands/CLIENT_LIST.ts` | +| `client\|no-evict` | true | false | admin, noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_NO-EVICT.ts` | +| `client\|no-touch` | true | false | noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_NO-TOUCH.ts` | +| `client\|pause` | true | false | admin, noscript, loading, stale | No | pauses command processing (server state) | `packages/client/lib/commands/CLIENT_PAUSE.ts` | +| `client\|setname` | true | false | noscript, loading, stale | Yes | sets own connection name | `packages/client/lib/commands/CLIENT_SETNAME.ts` | +| `client\|tracking` | true | false | noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_TRACKING.ts` | +| `client\|trackinginfo` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_TRACKINGINFO.ts` | +| `client\|unblock` | true | false | admin, noscript, loading, stale | No | mutates another client (admin) | `packages/client/lib/commands/CLIENT_UNBLOCK.ts` | +| `client\|unpause` | true | false | admin, noscript, loading, stale | No | resumes command processing (server state) | `packages/client/lib/commands/CLIENT_UNPAUSE.ts` | +| `cluster\|addslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_ADDSLOTS.ts` | +| `cluster\|addslotsrange` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts` | +| `cluster\|bumpepoch` | true | false | admin, stale, no_async_loading | No | mutates cluster epoch | `packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts` | +| `cluster\|count-failure-reports` | true | false | admin, loading, stale | Yes | reads failure reports | `packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts` | +| `cluster\|countkeysinslot` | true | false | stale | Yes | reads (per-node) | `packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts` | +| `cluster\|delslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_DELSLOTS.ts` | +| `cluster\|delslotsrange` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts` | +| `cluster\|failover` | true | false | admin, stale, no_async_loading | No | triggers failover | `packages/client/lib/commands/CLUSTER_FAILOVER.ts` | +| `cluster\|flushslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts` | +| `cluster\|forget` | true | false | admin, stale, no_async_loading | No | mutates node set | `packages/client/lib/commands/CLUSTER_FORGET.ts` | +| `cluster\|getkeysinslot` | true | false | stale | Yes | reads keys in slot (per-node) | `packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts` | +| `cluster\|info` | true | false | loading, stale | Yes | reads cluster state | `packages/client/lib/commands/CLUSTER_INFO.ts` | +| `cluster\|keyslot` | true | false | loading, stale | Yes | pure hash computation | `packages/client/lib/commands/CLUSTER_KEYSLOT.ts` | +| `cluster\|links` | true | false | loading, stale | Yes | reads links | `packages/client/lib/commands/CLUSTER_LINKS.ts` | +| `cluster\|meet` | true | false | admin, stale, no_async_loading | No | mutates node set | `packages/client/lib/commands/CLUSTER_MEET.ts` | +| `cluster\|myid` | true | false | loading, stale | Yes | reads node id | `packages/client/lib/commands/CLUSTER_MYID.ts` | +| `cluster\|myshardid` | true | false | loading, stale | Yes | reads shard id | `packages/client/lib/commands/CLUSTER_MYSHARDID.ts` | +| `cluster\|nodes` | true | false | loading, stale | Yes | reads topology (per-node view) | `packages/client/lib/commands/CLUSTER_NODES.ts` | +| `cluster\|replicas` | true | false | admin, loading, stale | Yes | reads replicas | `packages/client/lib/commands/CLUSTER_REPLICAS.ts` | +| `cluster\|replicate` | true | false | admin, stale, no_async_loading | No | changes replication target | `packages/client/lib/commands/CLUSTER_REPLICATE.ts` | +| `cluster\|reset` | true | false | admin, noscript, stale | No | resets cluster node | `packages/client/lib/commands/CLUSTER_RESET.ts` | +| `cluster\|saveconfig` | true | false | admin, stale, no_async_loading | No | writes nodes.conf | `packages/client/lib/commands/CLUSTER_SAVECONFIG.ts` | +| `cluster\|set-config-epoch` | true | false | admin, stale, no_async_loading | No | mutates epoch | `packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts` | +| `cluster\|setslot` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_SETSLOT.ts` | +| `cluster\|slots` | true | false | loading, stale | Yes | reads slot map | `packages/client/lib/commands/CLUSTER_SLOTS.ts` | +| `command` | true | false | loading, stale | Yes | static command metadata, node-local | `packages/client/lib/commands/COMMAND.ts` | +| `command\|count` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_COUNT.ts` | +| `command\|getkeys` | true | false | loading, stale | Yes | pure arg parse | `packages/client/lib/commands/COMMAND_GETKEYS.ts` | +| `command\|getkeysandflags` | true | false | loading, stale | Yes | pure arg parse | `packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts` | +| `command\|info` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_INFO.ts` | +| `command\|list` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_LIST.ts` | +| `config\|get` | true | false | admin, noscript, loading, stale | Yes | reads config (per-node) | `packages/client/lib/commands/CONFIG_GET.ts` | +| `config\|resetstat` | true | false | admin, noscript, loading, stale | No | resets stats counters | `packages/client/lib/commands/CONFIG_RESETSTAT.ts` | +| `config\|rewrite` | true | false | admin, noscript, loading, stale | No | writes config file | `packages/client/lib/commands/CONFIG_REWRITE.ts` | +| `config\|set` | true | false | admin, noscript, loading, stale | No | mutates config | `packages/client/lib/commands/CONFIG_SET.ts` | +| `echo` | true | false | loading, stale, fast | Yes | node-local no-op | `packages/client/lib/commands/ECHO.ts` | +| `function\|dump` | true | false | noscript | Yes | reads function payload | `packages/client/lib/commands/FUNCTION_DUMP.ts` | +| `function\|kill` | true | false | noscript, allow_busy | No | kills running function | `packages/client/lib/commands/FUNCTION_KILL.ts` | +| `function\|stats` | true | false | noscript, allow_busy | Yes | reads runtime (per-node) | `packages/client/lib/commands/FUNCTION_STATS.ts` | +| `hotkeys\|get` | true | false | admin, noscript | Yes | reads hotkey stats | `packages/client/lib/commands/HOTKEYS_GET.ts` | +| `info` | true | false | loading, stale | Yes | reads server stats (per-node) | `packages/client/lib/commands/INFO.ts` | +| `lastsave` | true | false | loading, stale, fast | Yes | reads last-save time (per-node) | `packages/client/lib/commands/LASTSAVE.ts` | +| `latency\|doctor` | true | false | admin, noscript, loading, stale | Yes | reads latency report | `packages/client/lib/commands/LATENCY_DOCTOR.ts` | +| `latency\|graph` | true | false | admin, noscript, loading, stale | Yes | reads latency graph | `packages/client/lib/commands/LATENCY_GRAPH.ts` | +| `latency\|histogram` | true | false | admin, noscript, loading, stale | Yes | reads latency histogram | `packages/client/lib/commands/LATENCY_HISTOGRAM.ts` | +| `latency\|history` | true | false | admin, noscript, loading, stale | Yes | reads latency history | `packages/client/lib/commands/LATENCY_HISTORY.ts` | +| `latency\|latest` | true | false | admin, noscript, loading, stale | Yes | reads latency samples | `packages/client/lib/commands/LATENCY_LATEST.ts` | +| `memory\|doctor` | true | false | | Yes | reads memory report | `packages/client/lib/commands/MEMORY_DOCTOR.ts` | +| `memory\|malloc-stats` | true | false | | Yes | reads allocator stats | `packages/client/lib/commands/MEMORY_MALLOC-STATS.ts` | +| `memory\|stats` | true | false | | Yes | reads memory stats | `packages/client/lib/commands/MEMORY_STATS.ts` | +| `module\|list` | true | false | admin, noscript | Yes | reads loaded modules | `packages/client/lib/commands/MODULE_LIST.ts` | +| `module\|load` | true | false | admin, noscript, no_async_loading | No | loads module (server state) | `packages/client/lib/commands/MODULE_LOAD.ts` | +| `module\|unload` | true | false | admin, noscript, no_async_loading | No | unloads module (server state) | `packages/client/lib/commands/MODULE_UNLOAD.ts` | +| `ping` | true | false | fast | Yes | node-local no-op | `packages/client/lib/commands/PING.ts` | +| `publish` | true | false | pubsub, loading, stale, fast | Yes | keyless; propagates cluster-wide via bus | `packages/client/lib/commands/PUBLISH.ts` | +| `pubsub\|channels` | true | false | pubsub, loading, stale | Yes | reads pubsub state (per-node) | `packages/client/lib/commands/PUBSUB_CHANNELS.ts` | +| `pubsub\|numpat` | true | false | pubsub, loading, stale | Yes | reads pubsub state | `packages/client/lib/commands/PUBSUB_NUMPAT.ts` | +| `pubsub\|numsub` | true | false | pubsub, loading, stale | Yes | reads pubsub state | `packages/client/lib/commands/PUBSUB_NUMSUB.ts` | +| `pubsub\|shardchannels` | true | false | pubsub, loading, stale | Yes | reads shard pubsub state | `packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts` | +| `pubsub\|shardnumsub` | true | false | pubsub, loading, stale | Yes | reads shard pubsub state | `packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts` | +| `readonly` | true | false | loading, stale, fast | Yes | connection-local cluster mode toggle | `packages/client/lib/commands/READONLY.ts` | +| `readwrite` | true | false | loading, stale, fast | Yes | connection-local cluster mode toggle | `packages/client/lib/commands/READWRITE.ts` | +| `replicaof` | true | false | admin, noscript, stale, no_async_loading | No | changes replication topology | `packages/client/lib/commands/REPLICAOF.ts` | +| `role` | true | false | noscript, loading, stale, fast | Yes | reads role (per-node) | `packages/client/lib/commands/ROLE.ts` | +| `save` | true | false | admin, noscript, no_async_loading, no_multi | No | blocking RDB save | `packages/client/lib/commands/SAVE.ts` | +| `script\|debug` | true | false | noscript | Yes | connection-local debug toggle | `packages/client/lib/commands/SCRIPT_DEBUG.ts` | +| `script\|exists` | true | false | noscript | Yes | reads script cache (per-node) | `packages/client/lib/commands/SCRIPT_EXISTS.ts` | +| `script\|flush` | true | false | noscript | No | flushes script cache | `packages/client/lib/commands/SCRIPT_FLUSH.ts` | +| `script\|kill` | true | false | noscript, allow_busy | No | kills running script | `packages/client/lib/commands/SCRIPT_KILL.ts` | +| `script\|load` | true | false | noscript, stale | No | writes to node script cache; primary needed for EVALSHA | `packages/client/lib/commands/SCRIPT_LOAD.ts` | +| `spublish` | true | false | pubsub, loading, stale, fast | Yes | keyless; shard pubsub | `packages/client/lib/commands/SPUBLISH.ts` | +| `time` | true | false | loading, stale, fast | Yes | reads node clock, node-local | `packages/client/lib/commands/TIME.ts` | +| `wait` | true | false | blocking | No | waits for replica acks; must run on primary | `packages/client/lib/commands/WAIT.ts` | + diff --git a/scripts/readonly-discrepancies.mjs b/scripts/readonly-discrepancies.mjs new file mode 100644 index 00000000000..a278019572d --- /dev/null +++ b/scripts/readonly-discrepancies.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// Find discrepancies between our IS_READ_ONLY command flag and the server's +// `readonly` command flag (from COMMAND INFO). +// +// Command name is derived from the FILE NAME (not parser.push), then resolved +// against the server by trimming trailing tokens until COMMAND INFO recognizes +// it. This collapses variant files (e.g. ZRANGE_WITHSCORES -> zrange) onto +// their base command without hardcoding a suffix list. +// +// Usage: node scripts/readonly-discrepancies.mjs (SHOW_UNKNOWN=1 to list unknowns) +// Requires a running redis at 127.0.0.1:6379 (redis-cli on PATH). + +import { readFileSync, globSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { basename } from 'node:path'; + +// Module command prefix by package dir (+ bloom subfolder). '' = core, no prefix. +const PACKAGE_PREFIX = { + client: '', + search: 'ft', + json: 'json', + 'time-series': 'ts' +}; +const BLOOM_SUBDIR_PREFIX = { + bloom: 'bf', + cuckoo: 'cf', + 'top-k': 'topk', + 'count-min-sketch': 'cms', + 't-digest': 'tdigest' +}; + +function prefixFor(path) { + const parts = path.split('/'); + const pkg = parts[1]; // packages//lib/commands/... + if (pkg === 'bloom') { + const sub = parts[4]; // packages/bloom/lib/commands//FILE.ts + return BLOOM_SUBDIR_PREFIX[sub] ?? null; + } + return PACKAGE_PREFIX[pkg] ?? null; +} + +// Filenames that glue command + arg with no separator, so token-splitting +// can't recover the real command name. Map file basename -> server name. +const NAME_OVERRIDES = { + INCREXBYFLOAT: 'increx' // pushes INCREX ... BYFLOAT +}; + +const files = globSync('packages/*/lib/commands/**/*.ts', { cwd: process.cwd() }) + .filter(f => !f.endsWith('.spec.ts') && !f.endsWith('index.ts')); + +const commands = []; +for (const f of files) { + const base = basename(f, '.ts'); + // Command files are UPPERCASE (GET, ACL_CAT, ZRANGE_WITHSCORES); skip helpers etc. + if (base !== base.toUpperCase()) continue; + + const prefix = prefixFor(f); + if (prefix === null) continue; // unknown package/subdir + + const roMatch = readFileSync(f, 'utf8').match(/IS_READ_ONLY\s*:\s*(true|false)/); + const isReadOnly = roMatch ? roMatch[1] === 'true' : false; + + // Filename tokens: split on '_', lowercase. Hyphens kept (count-failure-reports). + // Overrides bypass token-splitting for glued names (single token, no prefix). + const override = NAME_OVERRIDES[base]; + const tokens = override ? [override] : base.toLowerCase().split('_'); + commands.push({ file: f, prefix: override ? '' : prefix, tokens, isReadOnly }); +} + +// Build a server name from a prefix + the first `len` filename tokens joined +// by `sep`. Format: `.` for modules, `` for core. +// sep '_' -> matches names with underscores (bitfield_ro, tdigest.trimmed_mean) +// sep '|' -> matches container subcommands (acl|cat, object|encoding) +function nameFor(c, len, sep) { + const joined = c.tokens.slice(0, len).join(sep); + return c.prefix ? `${c.prefix}.${joined}` : joined; +} + +// Batched trim-until-found. Query current-length names for all unresolved +// commands; keep the hits, decrement length on misses, repeat. +function redisCommandInfo(names) { + const raw = execFileSync('redis-cli', ['--json', 'COMMAND', 'INFO', ...names], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 64 + }); + return JSON.parse(raw); +} + +let pending = commands.map(c => ({ c, len: c.tokens.length })); +const resolved = new Map(); // command obj -> server info entry +while (pending.length) { + const active = pending.filter(p => p.len >= 1); + if (!active.length) break; + // Try both separators at this length: '_' first (bitfield_ro before bitfield), + // then '|' (acl|cat). Query both in one batch. + const usNames = active.map(p => nameFor(p.c, p.len, '_')); + const barNames = active.map(p => nameFor(p.c, p.len, '|')); + const infos = redisCommandInfo([...usNames, ...barNames]); + const n = active.length; + const next = []; + active.forEach((p, i) => { + const info = infos[i] ?? infos[i + n]; // '_' hit preferred, else '|' + if (info) resolved.set(p.c, info); + else if (p.len > 1) next.push({ c: p.c, len: p.len - 1 }); + // len===1 and still null -> genuinely unknown, drop + }); + pending = next; +} + +const discrepancies = []; +const unknown = []; +for (const c of commands) { + const info = resolved.get(c); + if (!info) { unknown.push(c); continue; } + const flags = info[2] || []; + const serverReadOnly = flags.includes('readonly'); + const serverWrite = flags.includes('write'); + if (serverReadOnly === c.isReadOnly) continue; + + let bucket; + if (c.isReadOnly && serverWrite) bucket = 'BUG_WRITE_AS_RO'; + else if (!c.isReadOnly && serverReadOnly) bucket = 'MISSED_RO'; + else bucket = 'NOISE'; + discrepancies.push({ + command: info[0], + file: c.file, + ours: c.isReadOnly, + server: serverReadOnly, + serverFlags: flags, + bucket + }); +} + +console.log(`\n`); + +const BUCKET_DESC = { + BUG_WRITE_AS_RO: 'we mark IS_READ_ONLY but server flags `write` (cluster would route to a replica)', + MISSED_RO: 'we do not mark IS_READ_ONLY but server flags `readonly` (lost replica routing)', + NOISE: 'server has neither `readonly` nor `write` (admin/conn/pubsub/cluster) — likely by-design' +}; + +// Manual verdict for each NOISE (keyless) command: is IS_READ_ONLY=true +// (i.e. safe to route to a replica / does not require the primary) correct? +// Yes = read-only introspection OR connection/node-local -> replica-safe +// No = mutates server/cluster/replication/persistence state OR needs primary +// Server `readonly` flag is absent for ALL of these because it only tags +// KEYSPACE reads; these are keyless, so the flag says nothing about them. +const NOISE_VERDICT = { + 'acl|cat': ['Yes', 'reads static ACL categories'], + 'acl|deluser': ['No', 'mutates ACL'], + 'acl|dryrun': ['Yes', 'simulates, no mutation'], + 'acl|genpass': ['Yes', 'pure RNG, node-local'], + 'acl|getuser': ['Yes', 'reads ACL'], + 'acl|list': ['Yes', 'reads ACL'], + 'acl|load': ['No', 'reloads ACL from file'], + 'acl|log': ['Yes', 'reads ACL security log'], + 'acl|save': ['No', 'writes ACL to file'], + 'acl|setuser': ['No', 'mutates ACL'], + 'acl|users': ['Yes', 'reads ACL'], + 'acl|whoami': ['Yes', 'connection identity read'], + 'asking': ['Yes', 'connection-local cluster redirect marker'], + 'auth': ['Yes', 'connection-local auth'], + 'bgrewriteaof': ['No', 'triggers AOF rewrite on the node'], + 'bgsave': ['No', 'triggers RDB save on the node'], + 'client|caching': ['Yes', 'connection-local tracking toggle'], + 'client|getname': ['Yes', 'connection-local read'], + 'client|getredir': ['Yes', 'connection-local read'], + 'client|id': ['Yes', 'connection-local read'], + 'client|info': ['Yes', 'connection-local read'], + 'client|kill': ['No', 'mutates other connections (admin)'], + 'client|list': ['Yes', 'reads connections (per-node view)'], + 'client|no-evict': ['Yes', 'connection-local toggle'], + 'client|no-touch': ['Yes', 'connection-local toggle'], + 'client|pause': ['No', 'pauses command processing (server state)'], + 'client|setname': ['Yes', 'sets own connection name'], + 'client|tracking': ['Yes', 'connection-local toggle'], + 'client|trackinginfo': ['Yes', 'connection-local read'], + 'client|unblock': ['No', 'mutates another client (admin)'], + 'client|unpause': ['No', 'resumes command processing (server state)'], + 'cluster|addslots': ['No', 'mutates slot map'], + 'cluster|addslotsrange': ['No', 'mutates slot map'], + 'cluster|bumpepoch': ['No', 'mutates cluster epoch'], + 'cluster|count-failure-reports': ['Yes', 'reads failure reports'], + 'cluster|countkeysinslot': ['Yes', 'reads (per-node)'], + 'cluster|delslots': ['No', 'mutates slot map'], + 'cluster|delslotsrange': ['No', 'mutates slot map'], + 'cluster|failover': ['No', 'triggers failover'], + 'cluster|flushslots': ['No', 'mutates slot map'], + 'cluster|forget': ['No', 'mutates node set'], + 'cluster|getkeysinslot': ['Yes', 'reads keys in slot (per-node)'], + 'cluster|info': ['Yes', 'reads cluster state'], + 'cluster|keyslot': ['Yes', 'pure hash computation'], + 'cluster|links': ['Yes', 'reads links'], + 'cluster|meet': ['No', 'mutates node set'], + 'cluster|myid': ['Yes', 'reads node id'], + 'cluster|myshardid': ['Yes', 'reads shard id'], + 'cluster|nodes': ['Yes', 'reads topology (per-node view)'], + 'cluster|replicas': ['Yes', 'reads replicas'], + 'cluster|replicate': ['No', 'changes replication target'], + 'cluster|reset': ['No', 'resets cluster node'], + 'cluster|saveconfig': ['No', 'writes nodes.conf'], + 'cluster|set-config-epoch': ['No', 'mutates epoch'], + 'cluster|setslot': ['No', 'mutates slot map'], + 'cluster|slots': ['Yes', 'reads slot map'], + 'command': ['Yes', 'static command metadata, node-local'], + 'command|count': ['Yes', 'static metadata'], + 'command|getkeys': ['Yes', 'pure arg parse'], + 'command|getkeysandflags': ['Yes', 'pure arg parse'], + 'command|info': ['Yes', 'static metadata'], + 'command|list': ['Yes', 'static metadata'], + 'config|get': ['Yes', 'reads config (per-node)'], + 'config|resetstat': ['No', 'resets stats counters'], + 'config|rewrite': ['No', 'writes config file'], + 'config|set': ['No', 'mutates config'], + 'echo': ['Yes', 'node-local no-op'], + 'function|dump': ['Yes', 'reads function payload'], + 'function|kill': ['No', 'kills running function'], + 'function|stats': ['Yes', 'reads runtime (per-node)'], + 'hotkeys|get': ['Yes', 'reads hotkey stats'], + 'info': ['Yes', 'reads server stats (per-node)'], + 'lastsave': ['Yes', 'reads last-save time (per-node)'], + 'latency|doctor': ['Yes', 'reads latency report'], + 'latency|graph': ['Yes', 'reads latency graph'], + 'latency|histogram': ['Yes', 'reads latency histogram'], + 'latency|history': ['Yes', 'reads latency history'], + 'latency|latest': ['Yes', 'reads latency samples'], + 'memory|doctor': ['Yes', 'reads memory report'], + 'memory|malloc-stats': ['Yes', 'reads allocator stats'], + 'memory|stats': ['Yes', 'reads memory stats'], + 'module|list': ['Yes', 'reads loaded modules'], + 'module|load': ['No', 'loads module (server state)'], + 'module|unload': ['No', 'unloads module (server state)'], + 'ping': ['Yes', 'node-local no-op'], + 'publish': ['Yes', 'keyless; propagates cluster-wide via bus'], + 'pubsub|channels': ['Yes', 'reads pubsub state (per-node)'], + 'pubsub|numpat': ['Yes', 'reads pubsub state'], + 'pubsub|numsub': ['Yes', 'reads pubsub state'], + 'pubsub|shardchannels': ['Yes', 'reads shard pubsub state'], + 'pubsub|shardnumsub': ['Yes', 'reads shard pubsub state'], + 'readonly': ['Yes', 'connection-local cluster mode toggle'], + 'readwrite': ['Yes', 'connection-local cluster mode toggle'], + 'replicaof': ['No', 'changes replication topology'], + 'role': ['Yes', 'reads role (per-node)'], + 'save': ['No', 'blocking RDB save'], + 'script|debug': ['Yes', 'connection-local debug toggle'], + 'script|exists': ['Yes', 'reads script cache (per-node)'], + 'script|flush': ['No', 'flushes script cache'], + 'script|kill': ['No', 'kills running script'], + 'script|load': ['No', 'writes to node script cache; primary needed for EVALSHA'], + 'spublish': ['Yes', 'keyless; shard pubsub'], + 'time': ['Yes', 'reads node clock, node-local'], + 'wait': ['No', 'waits for replica acks; must run on primary'] +}; + +for (const bucket of ['BUG_WRITE_AS_RO', 'MISSED_RO', 'NOISE']) { + const rows = discrepancies.filter(d => d.bucket === bucket); + console.log(`## ${bucket} (${rows.length})`); + console.log(`\n${BUCKET_DESC[bucket]}\n`); + const noise = bucket === 'NOISE'; + if (noise) { + console.log('| Command | Ours IS_READ_ONLY | Server readonly | Server flags | RO ok? | Why | File |'); + console.log('| --- | --- | --- | --- | --- | --- | --- |'); + } else { + console.log('| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File |'); + console.log('| --- | --- | --- | --- | --- |'); + } + for (const d of rows) { + const cmd = d.command.replaceAll('|', '\\|'); // escape pipe for md cell + if (noise) { + const [ok, why] = NOISE_VERDICT[d.command] ?? ['?', 'UNMAPPED — review']; + console.log( + `| \`${cmd}\` | ${d.ours} | ${d.server} | ${d.serverFlags.join(', ')} | ${ok} | ${why} | \`${d.file}\` |` + ); + } else { + console.log( + `| \`${cmd}\` | ${d.ours} | ${d.server} | ${d.serverFlags.join(', ')} | \`${d.file}\` |` + ); + } + } + console.log(''); +} + +if (process.env.SHOW_UNKNOWN) { + console.log(`\n=== UNKNOWN (${unknown.length}) ===`); + for (const u of unknown) { + console.log(`${nameFor(u, u.tokens.length, '_')} ${u.file}`); + } +} From d288c0aba0817d63981c3e23477bed065a6deb7e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 14:41:59 +0300 Subject: [PATCH 12/54] extract request/response policy branches into named dispatch helpers Move each switch case body in `_execute` into a typed router/reducer function in `request-response-policies/dispatch.ts`. Switches still dispatch by policy enum; behavior unchanged. Prepares for replacing the switches with a strategy registry in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/cluster/index.ts | 95 +++++++------- .../request-response-policies/dispatch.ts | 122 ++++++++++++++++++ .../request-response-policies/index.ts | 1 + 3 files changed, 169 insertions(+), 49 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/dispatch.ts diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 4f74dd6c275..693d629c01d 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -17,7 +17,24 @@ import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; -import { aggregateLogicalAnd, aggregateLogicalOr, aggregateMax, aggregateMerge, aggregateMin, aggregateSum } from './request-response-policies/generic-aggregators'; +import { + routeAllNodes, + routeAllShards, + routeMultiShard, + routeDefaultKeyless, + routeDefaultKeyed, + routeSpecial, + reduceOneSucceeded, + reduceAllSucceeded, + reduceLogicalAnd, + reduceLogicalOr, + reduceMin, + reduceMax, + reduceSum, + reduceSpecial, + reduceDefaultKeyless, + reduceDefaultKeyed +} from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -519,29 +536,27 @@ export default class RedisCluster< switch (requestPolicy) { case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: - clients = this._slots.getAllClients() + clients = await routeAllNodes(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: - clients = this._slots.getAllMasterClients() + clients = await routeAllShards(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: - clients = await Promise.all( - parser.keys.map(async (key) => (await this._slots.getClientAndSlotNumber(key, isReadonly)).client) - ); + clients = await routeMultiShard(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: - throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + clients = await routeSpecial(this._slots, parser, isReadonly); + break; case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - //TODO handle undefined case? - clients = [this._slots.getRandomNode().client!] + clients = await routeDefaultKeyless(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - clients = [(await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client] + clients = await routeDefaultKeyed(this._slots, parser, isReadonly); break; default: @@ -619,53 +634,35 @@ export default class RedisCluster< }) switch (responsePolicy) { - case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: { - return Promise.any(responsePromises); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: + return reduceOneSucceeded(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: { - const responses = await Promise.all(responsePromises); - return responses[0] - } + case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: + return reduceAllSucceeded(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: { - const responses = await Promise.all(responsePromises) - return aggregateLogicalAnd(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: + return reduceLogicalAnd(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: { - const responses = await Promise.all(responsePromises) - return aggregateLogicalOr(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: + return reduceLogicalOr(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: { - const responses = await Promise.all(responsePromises); - return aggregateMin(responses); - } - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: { - const responses = await Promise.all(responsePromises); - return aggregateMax(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: + return reduceMin(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: { - const responses = await Promise.all(responsePromises); - return aggregateSum(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: + return reduceMax(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: { - throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: + return reduceSum(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: { - const responses = await Promise.all(responsePromises); - return aggregateMerge(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: + return reduceSpecial(responsePromises, parser); - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: { - const responses = await Promise.all(responsePromises); - return responses as T; - } + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: + return reduceDefaultKeyless(responsePromises); + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: + return reduceDefaultKeyed(responsePromises); default: throw new Error(`Unknown response policy ${responsePolicy}`); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts new file mode 100644 index 00000000000..e57126e2f38 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -0,0 +1,122 @@ +import type { CommandParser } from '../../client/parser'; +import type { RedisClientType } from '../../client'; +import type { + RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping +} from '../../RESP/types'; +import type RedisClusterSlots from '../cluster-slots'; +import { + aggregateLogicalAnd, + aggregateLogicalOr, + aggregateMax, + aggregateMerge, + aggregateMin, + aggregateSum +} from './generic-aggregators'; + +type Client< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = RedisClientType; + +type Slots< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = RedisClusterSlots; + +export type RequestRouter< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = ( + slots: Slots, + parser: CommandParser, + isReadonly: boolean | undefined +) => Promise>>; + +export type ResponseReducer = ( + responsePromises: Promise[], + parser: CommandParser +) => Promise; + +// --- request routers --- + +export const routeAllNodes: RequestRouter = + async (slots) => slots.getAllClients(); + +export const routeAllShards: RequestRouter = + async (slots) => slots.getAllMasterClients(); + +export const routeMultiShard: RequestRouter = + async (slots, parser, isReadonly) => + Promise.all( + parser.keys.map(async (key) => (await slots.getClientAndSlotNumber(key, isReadonly)).client) + ); + +export const routeDefaultKeyless: RequestRouter = + async (slots) => [slots.getRandomNode().client!]; + +export const routeDefaultKeyed: RequestRouter = + async (slots, parser, isReadonly) => + [(await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client]; + +export const routeSpecial: RequestRouter = + async (_slots, parser) => { + throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + }; + +// --- response reducers --- + +export const reduceOneSucceeded = (promises: Promise[]): Promise => + Promise.any(promises); + +export const reduceAllSucceeded = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses[0]; +}; + +export const reduceLogicalAnd = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateLogicalAnd(responses) as T; +}; + +export const reduceLogicalOr = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateLogicalOr(responses) as T; +}; + +export const reduceMin = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMin(responses) as T; +}; + +export const reduceMax = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMax(responses) as T; +}; + +export const reduceSum = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateSum(responses) as T; +}; + +export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { + throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); +}; + +export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMerge(responses) as T; +}; + +export const reduceDefaultKeyed = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses as T; +}; diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index e4b2410ba89..546637b4414 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -5,5 +5,6 @@ export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-pol export * from './policies-constants'; export { POLICIES } from './static-policies-data'; +export * from './dispatch'; // export { type CommandRouter } from './command-router'; \ No newline at end of file From f2d6306b412e58c46db2c0b321b3f22656b0b0f9 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 14:44:14 +0300 Subject: [PATCH 13/54] replace policy switches with strategy registries in `_execute` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define `REQUEST_ROUTERS` and `RESPONSE_REDUCERS` as policy-keyed records of the dispatch helpers introduced in the previous commit. The two switches in `_execute` collapse to a single registry lookup each, with `Unknown policy` errors preserved. `satisfies Record` keeps the lookup exhaustive at the type level. Adding a new policy now requires only a new entry in the registry — no `_execute` changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/cluster/index.ts | 92 +++---------------- .../request-response-policies/dispatch.ts | 30 ++++++ 2 files changed, 41 insertions(+), 81 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 693d629c01d..c259c193944 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,25 +16,8 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; -import { - routeAllNodes, - routeAllShards, - routeMultiShard, - routeDefaultKeyless, - routeDefaultKeyed, - routeSpecial, - reduceOneSucceeded, - reduceAllSucceeded, - reduceLogicalAnd, - reduceLogicalOr, - reduceMin, - reduceMax, - reduceSum, - reduceSpecial, - reduceDefaultKeyless, - reduceDefaultKeyed -} from './request-response-policies/dispatch'; +import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -531,37 +514,13 @@ export default class RedisCluster< const requestPolicy = policyResult.value.request const responsePolicy = policyResult.value.response - let clients: Array>; // https://redis.io/docs/latest/develop/reference/command-tips - switch (requestPolicy) { - - case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: - clients = await routeAllNodes(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: - clients = await routeAllShards(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: - clients = await routeMultiShard(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: - clients = await routeSpecial(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - clients = await routeDefaultKeyless(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - clients = await routeDefaultKeyed(this._slots, parser, isReadonly); - break; - - default: - throw new Error(`Unknown request policy ${requestPolicy}`); + const router = REQUEST_ROUTERS[requestPolicy]; + if (!router) { + throw new Error(`Unknown request policy ${requestPolicy}`); } + const clients: Array> = + await router(this._slots, parser, isReadonly); const responsePromises = clients.map(async client => { @@ -633,40 +592,11 @@ export default class RedisCluster< }) - switch (responsePolicy) { - case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: - return reduceOneSucceeded(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: - return reduceAllSucceeded(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: - return reduceLogicalAnd(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: - return reduceLogicalOr(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: - return reduceMin(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: - return reduceMax(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: - return reduceSum(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: - return reduceSpecial(responsePromises, parser); - - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - return reduceDefaultKeyless(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - return reduceDefaultKeyed(responsePromises); - - default: - throw new Error(`Unknown response policy ${responsePolicy}`); + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); } + return reducer(responsePromises, parser) as Promise; } diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index e57126e2f38..0876d1c25a9 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -12,6 +12,12 @@ import { aggregateMin, aggregateSum } from './generic-aggregators'; +import { + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS, + type RequestPolicyWithDefaults, + type ResponsePolicyWithDefaults +} from './policies-constants'; type Client< M extends RedisModules, @@ -120,3 +126,27 @@ export const reduceDefaultKeyed = async (promises: Promise[]): Promise const responses = await Promise.all(promises); return responses as T; }; + +// --- registries --- + +export const REQUEST_ROUTERS = { + [REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES]: routeAllNodes, + [REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS]: routeAllShards, + [REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD]: routeMultiShard, + [REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL]: routeSpecial, + [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: routeDefaultKeyless, + [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: routeDefaultKeyed +} as const satisfies Record>; + +export const RESPONSE_REDUCERS = { + [RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED]: reduceOneSucceeded, + [RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED]: reduceAllSucceeded, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND]: reduceLogicalAnd, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR]: reduceLogicalOr, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN]: reduceMin, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX]: reduceMax, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM]: reduceSum, + [RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL]: reduceSpecial, + [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: reduceDefaultKeyless, + [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: reduceDefaultKeyed +} as const satisfies Record>; From e8f26c90990f231df959b7bfcbb7eca0d2602ddd Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 15:20:31 +0300 Subject: [PATCH 14/54] stabilize policy resolver: consistent casing + safer subcommand handling - `CommandIdentifier.subcommand` is now `string | undefined`, mirroring the fact that single-word commands have no second arg. The parser emits `undefined` instead of an implicit empty value. - `StaticPolicyResolver` lowercases the entire policy table at construction time, so lookups are case-insensitive regardless of the casing used in the static data file (which today mixes upper- and lowercase module/command keys). - Subcommand lookups also lowercase the incoming identifier and guard against `undefined`, so `MEMORY USAGE` resolves to the `usage` subcommand policy regardless of caller casing. - Drop the stray `console.log` left in the resolver. - Error messages that referenced `parser.commandIdentifier` now print a `command [subcommand]` label instead of `[object Object]`. - Update `dynamic-policy-resolver.spec.ts` to pass `CommandIdentifier` objects to `resolvePolicy` (previously was passing raw strings). - Add `static-policy-resolver.spec.ts` covering FT.SEARCH, MEMORY USAGE, GET, COMMAND INFO, FT.SUGADD, casing, errors and fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/client/parser.ts | 10 +- packages/client/lib/cluster/index.ts | 4 +- .../request-response-policies/dispatch.ts | 8 +- .../dynamic-policy-resolver.spec.ts | 32 ++--- .../static-policy-resolver.spec.ts | 122 ++++++++++++++++++ .../static-policy-resolver.ts | 42 ++++-- 6 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 0da883db7cd..1a6db5be443 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -33,7 +33,7 @@ export function prefixKeys(keyPrefix: RedisArgument | undefined, keys: RedisVari : [prefixKey(keyPrefix, keys)]; } -export type CommandIdentifier = { command: string, subcommand: string }; +export type CommandIdentifier = { command: string, subcommand: string | undefined }; export interface CommandParser { redisArgs: ReadonlyArray; @@ -94,8 +94,12 @@ export class BasicCommandParser implements CommandParser { } get commandIdentifier(): CommandIdentifier { - const command = this.#redisArgs[0] instanceof Buffer ? this.#redisArgs[0].toString() : this.#redisArgs[0]; - const subcommand = this.#redisArgs[1] instanceof Buffer ? this.#redisArgs[1].toString() : this.#redisArgs[1]; + const rawCommand = this.#redisArgs[0]; + const rawSubcommand = this.#redisArgs[1]; + const command = rawCommand instanceof Buffer ? rawCommand.toString() : rawCommand; + const subcommand = rawSubcommand === undefined + ? undefined + : rawSubcommand instanceof Buffer ? rawSubcommand.toString() : rawSubcommand; return { command, subcommand }; } diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index c259c193944..83a542fbf91 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -508,7 +508,9 @@ export default class RedisCluster< const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); if(!policyResult.ok) { - throw new Error(`Policy resolution error for ${parser.commandIdentifier}: ${policyResult.error}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Policy resolution error for ${label}: ${policyResult.error}`); } const requestPolicy = policyResult.value.request diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 0876d1c25a9..39512e65b18 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -75,7 +75,9 @@ export const routeDefaultKeyed: RequestRouter = export const routeSpecial: RequestRouter = async (_slots, parser) => { - throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Special request policy not implemented for ${label}`); }; // --- response reducers --- @@ -114,7 +116,9 @@ export const reduceSum = async (promises: Promise[]): Promise => { }; export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { - throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Special response policy not implemented for ${label}`); }; export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index 6f05b62111b..fa79970147b 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -30,7 +30,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); assert.ok(resolver instanceof StaticPolicyResolver); - const result = resolver.resolvePolicy('ping'); + const result = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -59,7 +59,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('ping'); + const result = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -86,7 +86,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('get'); + const result = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); @@ -113,7 +113,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('dbsize'); + const result = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, 'all_shards'); @@ -140,7 +140,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('ft.search'); + const result = resolver.resolvePolicy({ command: 'ft.search', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, 'all_shards'); @@ -167,7 +167,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('json.get'); + const result = resolver.resolvePolicy({ command: 'json.get', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -196,7 +196,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('test'); + const result = resolver.resolvePolicy({ command: 'test', subcommand: undefined }); assert.equal(result.ok, true); }); @@ -204,7 +204,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('unknown'); + const result = resolver.resolvePolicy({ command: 'unknown', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-command'); }); @@ -213,7 +213,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('unknown.command'); + const result = resolver.resolvePolicy({ command: 'unknown.command', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-module'); }); @@ -222,7 +222,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('too.many.dots.here'); + const result = resolver.resolvePolicy({ command: 'too.many.dots.here', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'wrong-command-or-module-name'); }); @@ -261,7 +261,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); // Command with only request policy should fall back to defaults - let result = resolver.resolvePolicy('partial-request'); + let result = resolver.resolvePolicy({ command: 'partial-request', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES); @@ -269,7 +269,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Command with only response policy should fall back to defaults - result = resolver.resolvePolicy('partial-response'); + result = resolver.resolvePolicy({ command: 'partial-response', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -282,7 +282,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); assert.ok(resolver instanceof StaticPolicyResolver); - const result = resolver.resolvePolicy('any-command'); + const result = resolver.resolvePolicy({ command: 'any-command', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-command'); }); @@ -294,7 +294,7 @@ describe('DynamicPolicyResolverFactory', () => { assert.ok(resolver instanceof StaticPolicyResolver); // Test that ping command is classified as keyless - const pingResult = resolver.resolvePolicy('ping'); + const pingResult = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); if (pingResult.ok) { assert.equal(pingResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS); assert.equal(pingResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED); @@ -303,7 +303,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Test that get command is classified as keyed - const getResult = resolver.resolvePolicy('get'); + const getResult = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); if (getResult.ok) { assert.equal(getResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); assert.equal(getResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); @@ -312,7 +312,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Test that dbsize command uses explicit policies if available - const dbsizeResult = resolver.resolvePolicy('dbsize'); + const dbsizeResult = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); if (dbsizeResult.ok) { assert.ok( diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts new file mode 100644 index 00000000000..b75109b2b16 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts @@ -0,0 +1,122 @@ +import { strict as assert } from 'node:assert'; +import { + StaticPolicyResolver, + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS +} from '.'; + +describe('StaticPolicyResolver', () => { + const resolver = new StaticPolicyResolver(); + + describe('subcommand detection', () => { + it('FT.SEARCH: second arg is an index name, not a subcommand', () => { + const result = resolver.resolvePolicy({ command: 'FT.SEARCH', subcommand: 'my-index' }); + assert.equal(result.ok, true); + if (result.ok) { + // FT.SEARCH has no subcommands declared → falls back to parent policy + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('MEMORY USAGE: USAGE is a declared subcommand → uses subcommand policy', () => { + const result = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'USAGE' }); + assert.equal(result.ok, true); + if (result.ok) { + // MEMORY default is keyless, MEMORY USAGE is keyed + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.isKeyless, false); + } + }); + + it('GET key: second arg is a key, not a subcommand', () => { + const result = resolver.resolvePolicy({ command: 'GET', subcommand: 'foo' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + }); + + it('COMMAND INFO: INFO is a declared subcommand', () => { + const result = resolver.resolvePolicy({ command: 'COMMAND', subcommand: 'INFO' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.isKeyless, true); + } + }); + + it('FT.SUGADD: keyed module command, no subcommand declared', () => { + const result = resolver.resolvePolicy({ command: 'FT.SUGADD', subcommand: 'mydict' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.isKeyless, false); + } + }); + + it('undefined subcommand on a command with declared subcommands → parent policy', () => { + const result = resolver.resolvePolicy({ command: 'MEMORY', subcommand: undefined }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('casing', () => { + it('module name uppercase resolves same as lowercase', () => { + const a = resolver.resolvePolicy({ command: 'FT.SEARCH', subcommand: undefined }); + const b = resolver.resolvePolicy({ command: 'ft.search', subcommand: undefined }); + assert.deepEqual(a, b); + }); + + it('subcommand uppercase resolves same as lowercase', () => { + const a = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'USAGE' }); + const b = resolver.resolvePolicy({ command: 'memory', subcommand: 'usage' }); + assert.deepEqual(a, b); + }); + }); + + describe('errors', () => { + it('unknown command in std module', () => { + const r = resolver.resolvePolicy({ command: 'definitelynotacommand', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'unknown-command'); + }); + + it('unknown module', () => { + const r = resolver.resolvePolicy({ command: 'fakemodule.something', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'unknown-module'); + }); + + it('too many dots', () => { + const r = resolver.resolvePolicy({ command: 'a.b.c', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'wrong-command-or-module-name'); + }); + }); + + describe('fallback', () => { + it('falls back to provided resolver on unknown command', () => { + const fallback = new StaticPolicyResolver({ + std: { + customping: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true + } + } + }); + const chained = resolver.withFallback(fallback); + const r = chained.resolvePolicy({ command: 'customping', subcommand: undefined }); + assert.equal(r.ok, true); + }); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 6847ef735d6..4efc5c0254c 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -1,14 +1,38 @@ -import type { PolicyResult, PolicyResolver } from './types'; +import type { PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; import { POLICIES } from './static-policies-data'; import { CommandIdentifier } from '../../client/parser'; +import type { CommandPolicies } from './policies-constants'; + +const lowercaseCommandPolicies = (policies: CommandPolicies): CommandPolicies => { + if (!policies.subcommands) return policies; + const subcommands: Record = {}; + for (const [name, sub] of Object.entries(policies.subcommands)) { + subcommands[name.toLowerCase()] = lowercaseCommandPolicies(sub); + } + return { ...policies, subcommands }; +}; + +const lowercaseModulePolicies = (policies: ModulePolicyRecords): ModulePolicyRecords => { + const out: ModulePolicyRecords = {}; + for (const [moduleName, commands] of Object.entries(policies)) { + const normalized: CommandPolicyRecords = {}; + for (const [commandName, policy] of Object.entries(commands)) { + normalized[commandName.toLowerCase()] = lowercaseCommandPolicies(policy); + } + out[moduleName.toLowerCase()] = normalized; + } + return out; +}; export class StaticPolicyResolver implements PolicyResolver { private readonly fallbackResolver: PolicyResolver | null = null; + private readonly policies: ModulePolicyRecords; constructor( - private readonly policies = POLICIES, + policies: ModulePolicyRecords = POLICIES, fallbackResolver?: PolicyResolver ) { + this.policies = lowercaseModulePolicies(policies); this.fallbackResolver = fallbackResolver || null; } @@ -25,7 +49,6 @@ export class StaticPolicyResolver implements PolicyResolver { resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { const parts = commandIdentifier.command.toLowerCase().split('.'); - if (parts.length > 2) { return { ok: false, error: 'wrong-command-or-module-name' }; } @@ -34,8 +57,6 @@ export class StaticPolicyResolver implements PolicyResolver { ? ['std', parts[0]] : parts; - console.log(`module name `, moduleName, `command name `, commandName); - if (!this.policies[moduleName]) { if (this.fallbackResolver) { return this.fallbackResolver.resolvePolicy(commandIdentifier); @@ -50,7 +71,6 @@ export class StaticPolicyResolver implements PolicyResolver { } if (!this.policies[moduleName][commandName]) { - // Try fallback resolver if available if (this.fallbackResolver) { return this.fallbackResolver.resolvePolicy(commandIdentifier); } @@ -59,19 +79,19 @@ export class StaticPolicyResolver implements PolicyResolver { const policy = this.policies[moduleName][commandName]; - if(policy.subcommands) { - const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand]; - if(subcommandPolicy) { + if (policy.subcommands && commandIdentifier.subcommand !== undefined) { + const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand.toLowerCase()]; + if (subcommandPolicy) { return { ok: true, value: subcommandPolicy - } + }; } } return { ok: true, value: policy - } + }; } } From 2457a5ab6c3b2e171e932245de8d1c99324a78cf Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 16:02:20 +0300 Subject: [PATCH 15/54] align Search policy entries with the HLD command routing table The static policy table had three problems for the Search module: - The `FT` module entries were upper-cased while every other module uses lower-case keys. - A second `search` module held three RediSearch cluster-admin commands (`CLUSTERSET`, `CLUSTERINFO`, `CLUSTERREFRESH`) that are not part of the public FT.* surface. - A `_FT` module held private debug subcommands (`_FT.CONFIG`, `_FT.DEBUG`, etc.) and a long list of deprecated / private FT.* entries (`_LIST`, `_CREATEIFNX`, `_DROPIFX`, `_DROPINDEXIFX`, `_ALIASADDIFNX`, `_ALIASDELIFX`, `_ALTERIFNX`, `ADD`, `DEL`, `GET`, `MGET`, `SYNADD`) which are not user-facing and shouldn't participate in policy resolution. Replace those three modules with a single lower-case `ft` module containing exactly the 25 commands listed in the HLD Command Routing Policy Table, each with the policy the client should apply per the HLD client-interpretation column. `ft.cursor` is left at `default-keyless`/`default-keyless` for now; the HLD specifies `special` (sticky cursor) but that depends on the not-yet-built special-handler registry and cursor binding map. Add `ft-policies.spec.ts`, a parameterised test that asserts every HLD command resolves to the prescribed `request`/`response` pair and that the dropped debug / admin commands no longer resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ft-policies.spec.ts | 85 +++++ .../static-policies-data.ts | 359 ++---------------- 2 files changed, 114 insertions(+), 330 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts diff --git a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts new file mode 100644 index 00000000000..f95b33adf5b --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import { + StaticPolicyResolver, + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS +} from '.'; + +/** + * Snapshot of the HLD "Command Routing Policy Table" — client interpretation column. + * + * `default(keyless)` and `default(hashslot)` from the HLD denote "no policy + * declared"; the client routes by the default rules. They are stored here as + * `default-keyless` / `default-keyed` to match the resolver vocabulary. + * + * `ft.cursor` is mis-labeled `default-keyless` in this table on purpose. + * The HLD specifies `special` request_policy (sticky cursor), which requires + * the special-handler registry and cursor binding state. That work is tracked + * in its own story — once those land, the entry below flips to `special` and + * an extra cursor-routing test goes with it. + */ +const KEYLESS = { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true +} as const; + +const KEYED = { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + isKeyless: false +} as const; + +const HLD_FT_TABLE: Record = { + 'FT.CREATE': KEYLESS, + 'FT.SEARCH': KEYLESS, + 'FT.AGGREGATE': KEYLESS, + 'FT.DICTADD': KEYLESS, + 'FT.DICTDEL': KEYLESS, + 'FT.DICTDUMP': KEYLESS, + 'FT.SUGLEN': KEYED, + 'FT.CURSOR': KEYLESS, + 'FT.SUGADD': KEYED, + 'FT.SUGGET': KEYED, + 'FT.SUGDEL': KEYED, + 'FT.SPELLCHECK': KEYLESS, + 'FT.EXPLAIN': KEYLESS, + 'FT.EXPLAINCLI': KEYLESS, + 'FT.ALIASADD': KEYLESS, + 'FT.ALIASUPDATE': KEYLESS, + 'FT.ALIASDEL': KEYLESS, + 'FT.INFO': KEYLESS, + 'FT.TAGVALS': KEYLESS, + 'FT.SYNDUMP': KEYLESS, + 'FT.SYNUPDATE': KEYLESS, + 'FT.PROFILE': KEYLESS, + 'FT.ALTER': KEYLESS, + 'FT.DROPINDEX': KEYLESS, + 'FT.DROP': KEYLESS +}; + +describe('FT.* policy table matches the HLD', () => { + const resolver = new StaticPolicyResolver(); + + for (const [command, expected] of Object.entries(HLD_FT_TABLE)) { + it(`${command} resolves to the HLD policy`, () => { + const result = resolver.resolvePolicy({ command, subcommand: undefined }); + assert.equal(result.ok, true, `expected ${command} to resolve`); + if (result.ok) { + assert.equal(result.value.request, expected.request, `${command} request`); + assert.equal(result.value.response, expected.response, `${command} response`); + assert.equal(result.value.isKeyless, expected.isKeyless, `${command} isKeyless`); + } + }); + } + + it('does not expose dropped debug commands (e.g. FT._LIST)', () => { + const result = resolver.resolvePolicy({ command: 'FT._LIST', subcommand: undefined }); + assert.equal(result.ok, false); + }); + + it('does not expose stray cluster-admin commands (e.g. FT.CLUSTERSET)', () => { + const result = resolver.resolvePolicy({ command: 'FT.CLUSTERSET', subcommand: undefined }); + assert.equal(result.ok, false); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 327541fcd6c..ef2c354ef39 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1943,188 +1943,128 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false } }, - "FT": { - "ALIASADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALIASUPDATE": { + "ft": { + "create": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SPELLCHECK": { + "search": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTADD": { + "aggregate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_DROPIFX": { + "dictadd": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DROP": { + "dictdel": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "EXPLAINCLI": { + "dictdump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SUGGET": { + "suglen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "SYNADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TAGVALS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "EXPLAIN": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALTER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CURSOR": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_LIST": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_CREATEIFNX": { + "cursor": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTDEL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALIASDEL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SYNDUMP": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sugadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "SUGDEL": { + "sugget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "SUGADD": { + "sugdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "_DROPINDEXIFX": { + "spellcheck": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SYNUPDATE": { + "explain": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "MGET": { + "explaincli": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "GET": { + "aliasadd": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "AGGREGATE": { + "aliasupdate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SUGLEN": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "DEL": { + "aliasdel": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALIASDELIFX": { + "info": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALIASADDIFNX": { + "tagvals": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DROPINDEX": { + "syndump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALTERIFNX": { + "synupdate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "PROFILE": { + "profile": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "CREATE": { + "alter": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "INFO": { + "dropindex": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTDUMP": { + "drop": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -2614,247 +2554,6 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false } }, - "search": { - "CLUSTERSET": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLUSTERINFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLUSTERREFRESH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - }, - "_FT": { - "CONFIG": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SAFEADD": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "DEBUG": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "INFO_TAGIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "VECSIM_INFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SPEC_INVIDXES_INFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_HNSW": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL_PAUSE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_FORCEBGINVOKE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SHARD_CONNECTION_STATES": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_STOP_SCHEDULE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_TAGIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SET_MONITOR_EXPIRATION": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_TERMS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_CONTINUE_SCHEDULE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_NUMIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_CLEAN_NUMERIC": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "HELP": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "FT.AGGREGATE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_SUFFIX_TRIE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DOCINFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_FT.AGGREGATE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "WORKERS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "RESUME_TOPOLOGY_UPDATER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_NUMIDXTREE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_INVIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLEAR_PENDING_TOPOLOGY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_FT.SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "BG_SCAN_CONTROLLER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "PAUSE_TOPOLOGY_UPDATER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GIT_SHA": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "IDTODOCID": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "INVIDX_SUMMARY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_GEOMIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_FORCEINVOKE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL_EXPIRE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "FT.SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_PHONETIC_HASH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DOCIDTOID": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_PREFIX_TRIE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DELETE_LOCAL_CURSORS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_WAIT_FOR_JOBS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "NUMIDX_SUMMARY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - } - }, "timeseries": { "REFRESHCLUSTER": { "request": "default-keyless", From e040180160201a6a34314343dd4866c6d3434b14 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 13:17:55 +0300 Subject: [PATCH 16/54] feat(client): add static-policies-data generator - extract DynamicPolicyResolverFactory.buildModulePolicyRecords so the generator and the runtime resolver share one derivation path - scripts/generate-static-policies-data.ts (npm run generate:policies -- ) regenerates static-policies-data.ts from a live COMMAND reply; keys lowercased and sorted for stable diffs - HLD curation codified in scripts/static-policies-overrides.ts: internal/deprecated FT commands and _ft/search cluster-admin modules excluded, ft.cursor pinned to default-keyless until the special-handler registry lands - data regenerated against Redis 8.8.0: adds 42 new std commands (msetex was missing entirely), json.debug flips to keyless per server-reported key specs Co-Authored-By: Claude Fable 5 --- .../dynamic-policy-resolver-factory.ts | 15 +- .../static-policies-data.ts | 2556 +++++++++-------- packages/client/package.json | 1 + .../scripts/generate-static-policies-data.ts | 110 + .../scripts/static-policies-overrides.ts | 54 + 5 files changed, 1584 insertions(+), 1152 deletions(-) create mode 100644 packages/client/scripts/generate-static-policies-data.ts create mode 100644 packages/client/scripts/static-policies-overrides.ts diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 750ee491f8a..e6fb766a824 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -29,6 +29,19 @@ export class DynamicPolicyResolverFactory { fallbackResolver?: PolicyResolver ): Promise { const commands = await commandFetcher(); + const policies = DynamicPolicyResolverFactory.buildModulePolicyRecords(commands); + + return new StaticPolicyResolver(policies, fallbackResolver); + } + + /** + * Builds module->command policy records from COMMAND replies. + * + * Also used by `scripts/generate-static-policies-data.ts` to regenerate + * `static-policies-data.ts`, so the static data is guaranteed to match what + * this factory would derive at runtime. + */ + static buildModulePolicyRecords(commands: Array): ModulePolicyRecords { const policies: ModulePolicyRecords = {}; for (const command of commands) { @@ -51,7 +64,7 @@ export class DynamicPolicyResolverFactory { policies[moduleName][commandName] = commandPolicies; } - return new StaticPolicyResolver(policies, fallbackResolver); + return policies; } /** diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index ef2c354ef39..525d5c25a96 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1,611 +1,515 @@ +// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually. +// Source: Redis 8.8.0, 415 commands. import { ModulePolicyRecords } from "./types"; export const POLICIES: ModulePolicyRecords = { - "std": { - "getrange": { + "ft": { + "aggregate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "alter": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "create": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "cursor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictdump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "drop": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dropindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "explain": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "explaincli": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "profile": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "search": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "spellcheck": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "sugadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incr": { + "sugdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zlexcount": { + "sugget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hincrbyfloat": { + "suglen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zinterstore": { + "syndump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "synupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "tagvals": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "bf": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zpopmax": { + "card": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zdiff": { + "debug": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "waitaof": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true - }, - "psubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "geodist": { + "exists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hdel": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "type": { + "insert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "flushdb": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "lpos": { + "loadchunk": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xreadgroup": { + "madd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pttl": { + "mexists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sdiff": { + "reserve": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hkeys": { + "scandump": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "eval": { + } + }, + "cf": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "substr": { + "addnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebyrank": { + "compact": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zcount": { + "count": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "memory": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "purge": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "doctor": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "stats": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "malloc-stats": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "usage": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hgetdel": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpersist": { + "exists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "persist": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "llen": { + "insert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "insertnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "failover": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hello": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "exec": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hpexpiretime": { + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cms": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "acl": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "deluser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "genpass": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dryrun": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "save": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "cat": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "users": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "whoami": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "list": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "load": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "log": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setuser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "getuser": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "sort": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "latency": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "history": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - }, - "doctor": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "histogram": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "latest": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "graph": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "zincrby": { + "initbydim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sync": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "rpushx": { + "initbyprob": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xtrim": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "auth": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "echo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "json": { + "arrappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "georadiusbymember": { + "arrindex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zcard": { + "arrinsert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setnx": { + "arrlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hsetex": { + "arrpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "restore": { + "arrtrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geoadd": { + "clear": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "subscribe": { + "debug": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getex": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebyscore": { + "forget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hmset": { + "get": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebylex": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "watch": { + "mget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "fcall": { + "mset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lset": { + "numincrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpttl": { + "nummultby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zintercard": { + "numpowby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sort_ro": { + "objkeys": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrandmember": { + "objlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "discard": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zpopmin": { + "resp": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scard": { + "set": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hrandfield": { + "strappend": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hstrlen": { + "strlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xinfo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "groups": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "consumers": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "stream": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "flushall": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "linsert": { + "toggle": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geopos": { + "type": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "pexpiretime": { + } + }, + "std": { + "vadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sdiffstore": { + "vcard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ping": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "zscan": { + "vdim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hget": { + "vemb": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zunionstore": { + "vgetattr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ssubscribe": { + "vinfo": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrevrange": { + "vismember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "slaveof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bitcount": { + "vlinks": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "evalsha_ro": { + "vrandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpushx": { + "vrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sinterstore": { + "vrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "touch": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false - }, - "bgsave": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pfcount": { + "vsetattr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zdiffstore": { + "vsim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pubsub": { + "acl": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "shardnumsub": { + "cat": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "numpat": { + "deluser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "dryrun": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "numsub": { + "genpass": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "channels": { + "getuser": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -615,163 +519,219 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "shardchannels": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "log": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "save": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "setuser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "users": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "whoami": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "lindex": { + "append": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadiusbymember_ro": { + "arcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geohash": { + "ardel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xgroup": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "setid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "create": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "destroy": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "createconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } + "ardelrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xadd": { + "arget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xrange": { + "argetrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrange": { + "argrep": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sscan": { + "arinfo": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "randomkey": { - "request": "all_shards", - "response": "special", - "isKeyless": true + "arinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "bzpopmax": { + "arlastitems": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitfield_ro": { + "arlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ttl": { + "armget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hsetnx": { + "armset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rename": { + "arnext": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "shutdown": { + "arop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arring": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arseek": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "asking": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "strlen": { + "auth": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bgrewriteaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bgsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bitcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpexpireat": { + "bitfield": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "slowlog": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "get": { - "request": "all_nodes", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "len": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } + "bitfield_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "setex": { + "bitop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xack": { + "bitpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzpopmin": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -786,246 +746,149 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "setinfo": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "setname": { - "request": "all_nodes", - "response": "all_succeeded", + "getname": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "list": { + "getredir": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "kill": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "no-evict": { + "id": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "reply": { + "info": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "tracking": { + "kill": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unblock": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "trackinginfo": { + "no-evict": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unpause": { + "no-touch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { + "pause": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "id": { + "reply": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getredir": { - "request": "default-keyless", - "response": "default-keyless", + "setinfo": { + "request": "all_nodes", + "response": "all_succeeded", "isKeyless": true }, - "help": { + "setname": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "tracking": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "pause": { + "trackinginfo": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getname": { + "unblock": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "no-touch": { + "unpause": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "unsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pexpireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hgetall": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "multi": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrevrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "psetex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xsetid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "decr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xautoclaim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrangestore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "blpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "replconf": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "keys": { - "request": "all_shards", - "response": "default-keyless", - "isKeyless": true - }, - "command": { + "cluster": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "list": { + "addslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getkeysandflags": { + "addslotsrange": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { + "bumpepoch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "count": { + "count-failure-reports": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getkeys": { + "countkeysinslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "help": { + "delslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "docs": { + "delslotsrange": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true - } - } - }, - "exists": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false - }, - "sismember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "function": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "dump": { + }, + "failover": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "delete": { - "request": "all_shards", - "response": "all_succeeded", + "flushslots": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "stats": { + "forget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysinslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1035,224 +898,520 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "restore": { - "request": "all_shards", - "response": "all_succeeded", + "info": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "list": { + "keyslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", + "links": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "load": { - "request": "all_shards", - "response": "all_succeeded", + "meet": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "flush": { - "request": "all_shards", - "response": "all_succeeded", + "migration": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myshardid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "nodes": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicas": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "saveconfig": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set-config-epoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shards": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slaves": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slot-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "syncslots": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true } } }, - "xread": { + "command": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "count": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "docs": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysandflags": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "config": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "resetstat": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "rewrite": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "set": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "copy": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rpush": { + "dbsize": { + "request": "all_shards", + "response": "agg_sum", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "decr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "append": { + "decrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpop": { - "request": "default-keyed", - "response": "default-keyed", + "del": { + "request": "multi_shard", + "response": "agg_sum", "isKeyless": false }, - "set": { + "delex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "move": { + "digest": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expireat": { + "discard": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dump": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pexpire": { + "echo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "eval": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "brpoplpush": { + "eval_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { - "request": "multi_shard", - "response": "agg_sum", + "evalsha": { + "request": "default-keyed", + "response": "default-keyed", "isKeyless": false }, - "lmpop": { + "evalsha_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setrange": { + "exec": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exists": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "expire": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunsubscribe": { + "expireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "migrate": { + "expiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scan": { + "failover": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "lcs": { + "fcall": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "quit": { - "request": "default-keyless", - "response": "default-keyless", + "fcall_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "flushall": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "cluster": { + "flushdb": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "function": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "addslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "slots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "links": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "addslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "keyslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "meet": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "countkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "count-failure-reports": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "shards": { - "request": "default-keyless", - "response": "default-keyless", + "delete": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "myshardid": { + "dump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "myid": { - "request": "default-keyless", - "response": "default-keyless", + "flush": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "reset": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "flushslots": { - "request": "default-keyless", - "response": "default-keyless", + "kill": { + "request": "all_shards", + "response": "one_succeeded", "isKeyless": true }, - "slaves": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { - "request": "default-keyless", - "response": "default-keyless", + "load": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "replicate": { - "request": "default-keyless", - "response": "default-keyless", + "restore": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "nodes": { + "stats": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true - }, - "failover": { + } + } + }, + "geoadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geodist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geohash": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geopos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearchstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hello": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hotkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1262,161 +1421,191 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "saveconfig": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "set-config-epoch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bumpepoch": { - "request": "default-keyless", + "reset": { + "request": "special", "response": "default-keyless", "isKeyless": true }, - "replicas": { - "request": "default-keyless", + "start": { + "request": "special", "response": "default-keyless", "isKeyless": true }, - "forget": { - "request": "default-keyless", + "stop": { + "request": "special", "response": "default-keyless", "isKeyless": true } } }, - "spop": { + "hpersist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lrange": { + "hpexpiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xpending": { + "hpttl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunionstore": { + "hrandfield": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "select": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "hscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "sintercard": { + "hset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "srandmember": { + "hsetex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bzmpop": { + "hsetnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pfadd": { + "hstrlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "msetnx": { + "httl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expiretime": { + "hvals": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "script": { + "incr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "increx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keys": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true + }, + "lastsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "latency": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "load": { - "request": "all_nodes", - "response": "all_succeeded", + "doctor": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", + "graph": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "exists": { - "request": "all_shards", - "response": "agg_logical_and", + "help": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "flush": { - "request": "all_nodes", - "response": "all_succeeded", + "histogram": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "debug": { + "history": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "help": { + "latest": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true } } }, - "zrem": { + "lcs": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "save": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "smove": { + "lindex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "spublish": { + "linsert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "fcall_ro": { + "llen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lrem": { + "lmove": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "blmove": { + "lmpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -1426,82 +1615,89 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "bzpopmin": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ltrim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "asking": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrevrangebylex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "restore-asking": { + "lpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setbit": { + "lpos": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "smembers": { + "lpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xlen": { + "lpushx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expire": { + "lrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hexpireat": { + "lrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "srem": { + "lset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "httl": { + "ltrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lastsave": { + "memory": { "request": "default-keyless", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "malloc-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "purge": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "usage": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "hmget": { - "request": "default-keyed", + "mget": { + "request": "multi_shard", "response": "default-keyed", "isKeyless": false }, - "hexists": { + "migrate": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -1511,12 +1707,12 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true, "subcommands": { - "list": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unload": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1526,870 +1722,913 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "help": { + "loadex": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "loadex": { + "unload": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "sadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, "monitor": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "geosearch": { + "move": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "copy": { - "request": "default-keyed", - "response": "default-keyed", + "mset": { + "request": "multi_shard", + "response": "all_succeeded", "isKeyless": false }, - "lmove": { - "request": "default-keyed", - "response": "default-keyed", + "msetex": { + "request": "multi_shard", + "response": "all_succeeded", "isKeyless": false }, - "publish": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zscore": { + "msetnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bgrewriteaof": { + "multi": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zunion": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "config": { + "object": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "set": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "resetstat": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true + "encoding": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "get": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "freq": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "rewrite": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true + "idletime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "refcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false } } }, - "punsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrangebylex": { + "persist": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reset": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "pexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xclaim": { + "pexpireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geosearchstore": { + "pexpiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sinter": { + "pfadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pfdebug": { + "pfcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hscan": { + "pfdebug": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadius_ro": { + "pfmerge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "unwatch": { + "pfselftest": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unlink": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false + "ping": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true }, - "renamenx": { + "psetex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "brpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "psubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "zrevrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "psync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "incrby": { + "pttl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "publish": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "object": { + "pubsub": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "encoding": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "channels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "refcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "idletime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "numpat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "freq": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "numsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "help": { + "shardchannels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shardnumsub": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "time": { + "punsubscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "quit": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "rpoplpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "randomkey": { + "request": "all_shards", + "response": "special", + "isKeyless": true }, - "hincrby": { + "readonly": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "readwrite": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rename": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zinter": { + "renamenx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "role": { + "replconf": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "replicaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "pfselftest": { + "reset": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "hexpiretime": { + "restore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrbyfloat": { + "restore-asking": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zmscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "role": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "zmpop": { + "rpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "smismember": { + "rpoplpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xrevrange": { + "rpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitpos": { + "rpushx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hgetex": { + "sadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "readonly": { + "save": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "readwrite": { + "scan": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "pfmerge": { + "scard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "dbsize": { - "request": "all_shards", - "response": "agg_sum", - "isKeyless": true + "script": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exists": { + "request": "all_shards", + "response": "agg_logical_and", + "isKeyless": true + }, + "flush": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "load": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } }, - "dump": { + "sdiff": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mget": { - "request": "multi_shard", + "sdiffstore": { + "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mset": { - "request": "multi_shard", - "response": "all_succeeded", + "select": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", "isKeyless": false }, - "wait": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true + "setbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xdel": { + "setex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "evalsha": { + "setnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitop": { + "setrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "psync": { + "shutdown": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getbit": { + "sinter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadius": { + "sintercard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "getdel": { + "sinterstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "swapdb": { + "sismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slaveof": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "debug": { + "slowlog": { "request": "default-keyless", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "get": { + "request": "all_nodes", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "len": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } }, - "hvals": { + "smembers": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpush": { + "smismember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "replicaof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "eval_ro": { + "smove": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "getset": { + "sort": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "decrby": { + "sort_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitfield": { + "spop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "blmpop": { + "spublish": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunion": { + "srandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "ft": { - "create": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "search": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "aggregate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true }, - "dictadd": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "srem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "dictdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "dictdump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "ssubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "suglen": { + "strlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "cursor": { + "subscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "sugadd": { + "substr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sugget": { + "sunion": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sugdel": { + "sunionstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "spellcheck": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "explain": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sunsubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "explaincli": { + "swapdb": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasadd": { + "sync": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasupdate": { + "time": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "touch": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false }, - "info": { + "trimslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "tagvals": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "ttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "syndump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "synupdate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "unlink": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false }, - "profile": { + "unsubscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "alter": { + "unwatch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "dropindex": { - "request": "default-keyless", - "response": "default-keyless", + "wait": { + "request": "all_shards", + "response": "agg_min", "isKeyless": true }, - "drop": { - "request": "default-keyless", - "response": "default-keyless", + "waitaof": { + "request": "all_shards", + "response": "agg_min", "isKeyless": true - } - }, - "json": { - "strlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false }, - "mget": { + "watch": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "set": { + "xack": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "clear": { + "xackdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrpop": { + "xadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrinsert": { + "xautoclaim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "objkeys": { + "xcfgset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "type": { + "xclaim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "xdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "strappend": { + "xdelex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "xgroup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "delconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "destroy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "arrtrim": { + "xidmprecord": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "xinfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "consumers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "groups": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "stream": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "mset": { + "xlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "numincrby": { + "xnack": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "forget": { + "xpending": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrlen": { + "xrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrindex": { + "xread": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "nummultby": { + "xreadgroup": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "objlen": { + "xrevrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "numpowby": { + "xsetid": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrappend": { + "xtrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "zadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "toggle": { + "zcard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "resp": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "cms": { - "initbyprob": { + "zcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "query": { + "zdiff": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "zdiffstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "zincrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrby": { + "zinter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "initbydim": { + "zintercard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "bf": { - "loadchunk": { + }, + "zinterstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "zlexcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "zmpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "madd": { + "zmscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mexists": { + "zpopmax": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "insert": { + "zpopmin": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "exists": { + "zrandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "card": { + "zrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "zrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "zrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scandump": { + "zrangestore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "ts": { - "mrevrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true }, - "info": { + "zrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "alter": { + "zrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "revrange": { + "zremrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "madd": { + "zremrangebyrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "createrule": { + "zremrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { + "zrevrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mget": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "get": { + "zrevrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "create": { + "zrevrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "zrevrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "range": { + "zscan": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "queryindex": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "deleterule": { + "zscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "decrby": { + "zunion": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrby": { + "zunionstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } }, "tdigest": { - "max": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "byrevrank": { + "byrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "byrevrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "byrank": { + "cdf": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -2399,131 +2638,138 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyed", "isKeyless": false }, - "reset": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "max": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "trimmed_mean": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "min": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "revrank": { + "quantile": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "min": { + "rank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "cdf": { + "reset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rank": { + "revrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "quantile": { + "trimmed_mean": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } }, - "cf": { - "count": { + "timeseries": { + "refreshcluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "topk": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "count": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "exists": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "compact": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "loadchunk": { + "list": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "insertnx": { + "query": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "addnx": { + "reserve": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "insert": { + } + }, + "ts": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "alter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scandump": { + "create": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "createrule": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mexists": { + "decrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { + "deleterule": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "topk": { - "list": { + }, + "get": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -2533,32 +2779,40 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyed", "isKeyless": false }, - "incrby": { + "madd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "query": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "mget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "count": { + "mrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "mrevrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "queryindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "range": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "revrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } - }, - "timeseries": { - "REFRESHCLUSTER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } } } as const; diff --git a/packages/client/package.json b/packages/client/package.json index fb39cf4f481..b3a5de301bf 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,6 +11,7 @@ "scripts": { "test": "npm run test:types && nyc -r text-summary -r lcov mocha -r tsx --reporter mocha-multi-reporters --reporter-options configFile=mocha-multi-reporter-config.json --exit './lib/**/*.spec.ts'", "test:types": "tsc -p tsconfig.types-test.json", + "generate:policies": "tsx ./scripts/generate-static-policies-data.ts", "release": "release-it" }, "dependencies": { diff --git a/packages/client/scripts/generate-static-policies-data.ts b/packages/client/scripts/generate-static-policies-data.ts new file mode 100644 index 00000000000..f1cea473905 --- /dev/null +++ b/packages/client/scripts/generate-static-policies-data.ts @@ -0,0 +1,110 @@ +/** + * Regenerates `lib/cluster/request-response-policies/static-policies-data.ts` + * from a live Redis server's COMMAND reply. + * + * The policy derivation is shared with `DynamicPolicyResolverFactory`, so the + * generated static data is exactly what the dynamic resolver would build at + * runtime against the same server, minus the HLD curation defined in + * `static-policies-overrides.ts` (internal/deprecated/cluster-admin commands). + * + * Usage: + * npm run generate:policies --workspace=packages/client -- redis://localhost:6379 + * + * The Redis URL is taken from the first CLI argument, then the REDIS_URL + * environment variable, and defaults to redis://localhost:6379. + * + * Run against a server with all bundled modules loaded (e.g. Redis 8.8) so + * module commands (ft, json, bf, ...) are included. + */ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createClient } from '../index'; +import { transformCommandReply, type CommandRawReply } from '../lib/commands/generic-transformers'; +import { DynamicPolicyResolverFactory } from '../lib/cluster/request-response-policies/dynamic-policy-resolver-factory'; +import type { CommandPolicyRecords, ModulePolicyRecords } from '../lib/cluster/request-response-policies/types'; +import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; +import { EXCLUDED_MODULES, EXCLUDED_COMMANDS, COMMAND_OVERRIDES } from './static-policies-overrides'; + +const OUTPUT_PATH = resolve(__dirname, '../lib/cluster/request-response-policies/static-policies-data.ts'); + +function sortedByKey(record: Record, mapValue: (value: T) => T): Record { + const sorted: Record = {}; + // Lowercased to match StaticPolicyResolver's lookup normalization. + for (const key of Object.keys(record).sort()) { + sorted[key.toLowerCase()] = mapValue(record[key]); + } + return sorted; +} + +function sortCommandPolicies(policies: CommandPolicies): CommandPolicies { + return { + ...policies, + subcommands: policies.subcommands + ? sortedByKey(policies.subcommands, sortCommandPolicies) + : undefined + }; +} + +// Sort modules, commands and subcommands alphabetically so regeneration +// produces stable diffs regardless of the order the server lists commands in. +function sortModulePolicyRecords(records: ModulePolicyRecords): ModulePolicyRecords { + return sortedByKey(records, (commands: CommandPolicyRecords) => + sortedByKey(commands, sortCommandPolicies) + ); +} + +// Applies the HLD curation from static-policies-overrides.ts. Expects +// lowercased records (i.e. run after sortModulePolicyRecords). +function curate(records: ModulePolicyRecords): ModulePolicyRecords { + const curated: ModulePolicyRecords = {}; + + for (const [moduleName, commands] of Object.entries(records)) { + if (EXCLUDED_MODULES.has(moduleName)) continue; + + curated[moduleName] = {}; + for (const [commandName, policies] of Object.entries(commands)) { + const fullName = `${moduleName}.${commandName}`; + if (EXCLUDED_COMMANDS.has(fullName)) continue; + + curated[moduleName][commandName] = COMMAND_OVERRIDES[fullName] ?? policies; + } + } + + return curated; +} + +async function main() { + const url = process.argv[2] ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; + const client = createClient({ url }); + await client.connect(); + + try { + const rawCommands = await client.sendCommand>(['COMMAND']); + const commands = rawCommands.map(transformCommandReply); + const policies = curate(sortModulePolicyRecords( + DynamicPolicyResolverFactory.buildModulePolicyRecords(commands) + )); + + const info = await client.sendCommand(['INFO', 'server']); + const version = /redis_version:(\S+)/.exec(info)?.[1] ?? 'unknown'; + + const content = [ + '// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually.', + `// Source: Redis ${version}, ${Object.values(policies).reduce((sum, commands) => sum + Object.keys(commands).length, 0)} commands.`, + 'import { ModulePolicyRecords } from "./types";', + '', + `export const POLICIES: ModulePolicyRecords = ${JSON.stringify(policies, null, 2)} as const;`, + '' + ].join('\n'); + + writeFileSync(OUTPUT_PATH, content); + console.log(`Wrote ${OUTPUT_PATH} (Redis ${version})`); + } finally { + client.destroy(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/client/scripts/static-policies-overrides.ts b/packages/client/scripts/static-policies-overrides.ts new file mode 100644 index 00000000000..9c760870c1f --- /dev/null +++ b/packages/client/scripts/static-policies-overrides.ts @@ -0,0 +1,54 @@ +/** + * Curation applied on top of the raw COMMAND dump when regenerating + * `static-policies-data.ts`, keeping the static data aligned with the HLD + * "Command Routing Policy Table" (see ft-policies.spec.ts). + * + * Rationale: the server reports internal, debug, deprecated and cluster-admin + * commands that the HLD deliberately omits from client routing. They are + * excluded here so the static phase refuses to resolve them (they fall through + * to the fallback resolver instead). + */ +import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; + +/** Entire modules to drop (internal / cluster-admin command namespaces). */ +export const EXCLUDED_MODULES: ReadonlySet = new Set(['_ft', 'search']); + +/** Individual `module.command` entries to drop. */ +export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ + // FT internal conditional variants + 'ft._aliasaddifnx', + 'ft._aliasdelifx', + 'ft._alterifnx', + 'ft._createifnx', + 'ft._dropifx', + 'ft._dropindexifx', + // FT debug/introspection not in the HLD table + 'ft._list', + 'ft.config', + // FT deprecated legacy (pre-2.0) commands + 'ft.add', + 'ft.del', + 'ft.get', + 'ft.mget', + 'ft.safeadd', + 'ft.synadd', + // Not yet in the HLD routing table + 'ft.hybrid', + // Cluster-admin + 'timeseries.clusterset' +]); + +/** + * Full-entry replacements, keyed by `module.command`. + * + * `ft.cursor` is pinned to plain default-keyless (its `special` request-policy + * subcommands stripped) until the special-handler registry and cursor binding + * state land — see the note in ft-policies.spec.ts. + */ +export const COMMAND_OVERRIDES: Readonly> = { + 'ft.cursor': { + request: 'default-keyless', + response: 'default-keyless', + isKeyless: true + } +}; From 87469322e31e802c7679ee0ea24a989b1518a35e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 13:36:33 +0300 Subject: [PATCH 17/54] fix(client): scan all tips when parsing command policies COMMAND tips carry no ordering guarantee; commands like INFO declare nondeterministic_output before their policy tips, so positional parsing silently dropped request/response policies for 13 commands (scan, info, memory/latency subcommands, function stats, cluster slot-stats, hotkeys get) and they fell back to default routing. Scan the whole tips array by prefix instead and regenerate static-policies-data from Redis 8.8.0. Surfaced 'special' policies now reach the throwing routeSpecial / reduceSpecial handlers on cluster dispatch; safe fallback deferred. --- .../static-policies-data.ts | 50 ++++++------ packages/client/lib/commands/COMMAND.spec.ts | 81 ++++++++++++++++++- .../lib/commands/generic-transformers.ts | 28 ++++--- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 525d5c25a96..3f752cd198a 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -979,7 +979,7 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "slot-stats": { - "request": "default-keyless", + "request": "all_shards", "response": "default-keyless", "isKeyless": true }, @@ -1240,8 +1240,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true } } @@ -1412,8 +1412,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "get": { - "request": "default-keyless", - "response": "default-keyless", + "request": "special", + "response": "special", "isKeyless": true }, "help": { @@ -1524,8 +1524,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false }, "info": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "keys": { @@ -1544,13 +1544,13 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "doctor": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "graph": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "help": { @@ -1559,18 +1559,18 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "histogram": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "history": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "latest": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "reset": { @@ -1661,8 +1661,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "doctor": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "help": { @@ -1671,8 +1671,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "malloc-stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "purge": { @@ -1681,8 +1681,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "usage": { @@ -2004,8 +2004,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "scan": { - "request": "default-keyless", - "response": "default-keyless", + "request": "special", + "response": "special", "isKeyless": true }, "scard": { diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 7ace4de3c70..565dc79a2b1 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -108,6 +108,75 @@ describe('COMMAND', () => { isKeyless: true, subcommands: [] } + }, + { + // INFO declares tips in this order on a live server; the parser must not + // depend on policy tips being first + name: 'with policies after a non-policy tip', + input: ['info', -1, [], 0, 0, 0, [], ['nondeterministic_output', 'request_policy:all_shards', 'response_policy:special'], [], []] satisfies CommandRawReply, + expected: { + name: 'info', + arity: -1, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: 'special' }, + isKeyless: true, + subcommands: [] + } + }, + { + // CLUSTER SLOT-STATS shape: non-policy tip first, request policy only + name: 'with request policy after a non-policy tip', + input: ['test', 0, [], 0, 0, 0, [], ['nondeterministic_output', 'request_policy:all_shards'], [], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: undefined }, + isKeyless: true, + subcommands: [] + } + }, + { + // KEYS shape: policy first, trailing non-policy tip must not be + // misread as a response policy + name: 'with non-policy tip after request policy', + input: ['keys', 2, [], 0, 0, 0, [], ['request_policy:all_shards', 'nondeterministic_output'], [], []] satisfies CommandRawReply, + expected: { + name: 'keys', + arity: 2, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: undefined }, + isKeyless: true, + subcommands: [] + } + }, + { + name: 'with only non-policy tips', + input: ['test', 0, [], 0, 0, 0, [], ['nondeterministic_output', 'nondeterministic_output_order'], [], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: undefined }, + isKeyless: true, + subcommands: [] + } } ]; @@ -122,10 +191,18 @@ describe('COMMAND', () => { }); testUtils.testWithClient('client.command', async client => { - const result = ((await client.command()).find(command => command.name === 'dbsize')); + const commands = await client.command(); + + const result = commands.find(command => command.name === 'dbsize'); assert.equal(result?.name, 'dbsize'); assert.equal(result?.arity, 1); assert.equal(result?.policies?.request, 'all_shards'); assert.equal(result?.policies?.response, 'agg_sum'); + + // INFO declares 'nondeterministic_output' before its policy tips — + // regression guard for positional tips parsing + const info = commands.find(command => command.name === 'info'); + assert.equal(info?.policies?.request, 'all_shards'); + assert.equal(info?.policies?.response, 'special'); }, GLOBAL.SERVERS.OPEN); -}); \ No newline at end of file +}); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 65a452bdb4f..a5aa31467c8 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -369,15 +369,25 @@ export function transformCommandReply( ): CommandReply { - const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); - const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) - ? requestPolicyRaw as RequestPolicyWithDefaults - : undefined; - - const responsePolicyRaw = tips[1]?.replace('response_policy:', ''); - const responsePolicy = responsePolicyRaw && Object.values(RESPONSE_POLICIES_WITH_DEFAULTS).includes(responsePolicyRaw as ResponsePolicyWithDefaults) - ? responsePolicyRaw as ResponsePolicyWithDefaults - : undefined; + // Tips are free-form hints with no ordering guarantee — commands like INFO + // declare 'nondeterministic_output' before their policy tips, so scan the + // whole array instead of relying on positions. + let requestPolicy: RequestPolicyWithDefaults | undefined; + let responsePolicy: ResponsePolicyWithDefaults | undefined; + + for (const tip of tips) { + if (tip.startsWith('request_policy:')) { + const raw = tip.slice('request_policy:'.length); + if ((Object.values(REQUEST_POLICIES_WITH_DEFAULTS) as string[]).includes(raw)) { + requestPolicy = raw as RequestPolicyWithDefaults; + } + } else if (tip.startsWith('response_policy:')) { + const raw = tip.slice('response_policy:'.length); + if ((Object.values(RESPONSE_POLICIES_WITH_DEFAULTS) as string[]).includes(raw)) { + responsePolicy = raw as ResponsePolicyWithDefaults; + } + } + } const subcommands = subcommandsReply.map(transformCommandReply); From 61c6012925ea39515b6782abb6d055bb4c3583c8 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 15:06:31 +0300 Subject: [PATCH 18/54] feat(client): parse COMMAND key specs into CommandReply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key specs were reduced to an isKeyless boolean; the begin_search / find_keys structures were discarded. The multi_shard splitter needs them as the reconstruction recipe (key strides, keynum count position, options suffix), so retain them as CommandReply.keySpecs. RESP2 delivers each map level as flat field-value pair arrays; normalize to the RESP3 object shape so both protocols parse identically. Unrecognized or malformed entries parse to { type: 'unknown' } instead of throwing — consumers decide whether unknown is acceptable. --- packages/client/lib/commands/COMMAND.spec.ts | 113 +++++++++++++++++- .../lib/commands/generic-transformers.ts | 105 +++++++++++++++- 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 565dc79a2b1..6646dffd76a 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; -import { parseArgs, transformCommandReply, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; +import { parseArgs, transformCommandReply, transformKeySpec, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; import COMMAND from './COMMAND'; describe('COMMAND', () => { @@ -26,6 +26,7 @@ describe('COMMAND', () => { categories: new Set([CommandCategories.FAST]), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -42,6 +43,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -58,6 +60,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } }, @@ -74,6 +77,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } }, @@ -90,6 +94,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -106,6 +111,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -124,6 +130,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'special' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -141,6 +148,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -159,6 +167,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -175,6 +184,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } } @@ -190,6 +200,101 @@ describe('COMMAND', () => { }); }); + describe('transformKeySpec', () => { + // Shapes captured from a live Redis 8.8.0 COMMAND INFO reply: RESP3 + // delivers nested objects, RESP2 the same data as flat field-value pair + // arrays. Both must parse to the same (RESP3-like) result. + const testCases = [ + { + name: 'range (MSET)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'index', spec: { index: 1 } }, + find_keys: { type: 'range', spec: { lastkey: -1, keystep: 2, limit: 0 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'index', 'spec', ['index', 1]], + 'find_keys', ['type', 'range', 'spec', ['lastkey', -1, 'keystep', 2, 'limit', 0]] + ], + expected: { + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + } + }, + { + name: 'keynum (MSETEX)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'index', spec: { index: 1 } }, + find_keys: { type: 'keynum', spec: { keynumidx: 0, firstkey: 1, keystep: 2 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'index', 'spec', ['index', 1]], + 'find_keys', ['type', 'keynum', 'spec', ['keynumidx', 0, 'firstkey', 1, 'keystep', 2]] + ], + expected: { + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'keynum', keyNumIdx: 0, firstKey: 1, keyStep: 2 } + } + }, + { + name: 'keyword (GEORADIUS STORE)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'keyword', spec: { keyword: 'STORE', startfrom: 6 } }, + find_keys: { type: 'range', spec: { lastkey: 0, keystep: 1, limit: 0 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'keyword', 'spec', ['keyword', 'STORE', 'startfrom', 6]], + 'find_keys', ['type', 'range', 'spec', ['lastkey', 0, 'keystep', 1, 'limit', 0]] + ], + expected: { + beginSearch: { type: 'keyword', keyword: 'STORE', startFrom: 6 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + } + }, + { + name: 'unrecognized types', + resp3: { + begin_search: { type: 'future-type', spec: { whatever: 1 } }, + find_keys: { type: 'future-type', spec: { whatever: 1 } } + }, + resp2: [ + 'begin_search', ['type', 'future-type', 'spec', ['whatever', 1]], + 'find_keys', ['type', 'future-type', 'spec', ['whatever', 1]] + ], + expected: { + beginSearch: { type: 'unknown' }, + findKeys: { type: 'unknown' } + } + } + ]; + + testCases.forEach(testCase => { + it(`${testCase.name} - RESP3 shape`, () => { + assert.deepEqual(transformKeySpec(testCase.resp3), testCase.expected); + }); + + it(`${testCase.name} - RESP2 shape`, () => { + assert.deepEqual(transformKeySpec(testCase.resp2), testCase.expected); + }); + }); + + it('malformed entries parse to unknown instead of throwing', () => { + const unknown = { beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }; + assert.deepEqual(transformKeySpec('some key specification'), unknown); + assert.deepEqual(transformKeySpec(null), unknown); + assert.deepEqual(transformKeySpec(['odd', 'pair', 'array']), unknown); + assert.deepEqual(transformKeySpec({ + begin_search: { type: 'index', spec: { index: 'not-a-number' } }, + find_keys: { type: 'range', spec: { lastkey: -1 } } + }), unknown); + }); + }); + testUtils.testWithClient('client.command', async client => { const commands = await client.command(); @@ -204,5 +309,11 @@ describe('COMMAND', () => { const info = commands.find(command => command.name === 'info'); assert.equal(info?.policies?.request, 'all_shards'); assert.equal(info?.policies?.response, 'special'); + + const mset = commands.find(command => command.name === 'mset'); + assert.deepEqual(mset?.keySpecs, [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + }]); }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index a5aa31467c8..b4dfe9e9ed5 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -345,10 +345,20 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: Array, + keySpecifications: Array, subcommands: Array ]; +export type KeySpec = { + beginSearch: + | { type: 'index'; index: number } + | { type: 'keyword'; keyword: string; startFrom: number } + | { type: 'unknown' }; + findKeys: + | { type: 'range'; lastKey: number; keyStep: number; limit: number } + | { type: 'keynum'; keyNumIdx: number; firstKey: number; keyStep: number } + | { type: 'unknown' }; +}; export type CommandReply = { name: string, @@ -360,9 +370,101 @@ export type CommandReply = { categories: Set, policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } isKeyless: boolean, + keySpecs: Array, subcommands: Array }; +/** + * Normalizes one map-shaped level of a key specification to the RESP3 object + * shape. RESP3 already delivers objects; RESP2 delivers the same data as flat + * `[field, value, ...]` pair arrays. + */ +function normalizeKeySpecMap(raw: unknown): Record | undefined { + if (raw === null || typeof raw !== 'object') return undefined; + + if (!Array.isArray(raw)) return raw as Record; + + if (raw.length % 2 !== 0) return undefined; + + const normalized: Record = {}; + for (let i = 0; i < raw.length; i += 2) { + const field = raw[i]; + if (typeof field !== 'string') return undefined; + normalized[field] = raw[i + 1]; + } + return normalized; +} + +function transformNumber(raw: unknown): number | undefined { + const value = Number(raw); + return Number.isInteger(value) ? value : undefined; +} + +const UNKNOWN_KEY_SPEC_PART = { type: 'unknown' } as const; + +function transformBeginSearch(raw: unknown): KeySpec['beginSearch'] { + const beginSearch = normalizeKeySpecMap(raw); + const spec = normalizeKeySpecMap(beginSearch?.spec); + if (!beginSearch || !spec) return UNKNOWN_KEY_SPEC_PART; + + switch (beginSearch.type) { + case 'index': { + const index = transformNumber(spec.index); + if (index !== undefined) return { type: 'index', index }; + break; + } + case 'keyword': { + const startFrom = transformNumber(spec.startfrom); + if (typeof spec.keyword === 'string' && startFrom !== undefined) { + return { type: 'keyword', keyword: spec.keyword, startFrom }; + } + break; + } + } + return UNKNOWN_KEY_SPEC_PART; +} + +function transformFindKeys(raw: unknown): KeySpec['findKeys'] { + const findKeys = normalizeKeySpecMap(raw); + const spec = normalizeKeySpecMap(findKeys?.spec); + if (!findKeys || !spec) return UNKNOWN_KEY_SPEC_PART; + + switch (findKeys.type) { + case 'range': { + const lastKey = transformNumber(spec.lastkey), + keyStep = transformNumber(spec.keystep), + limit = transformNumber(spec.limit); + if (lastKey !== undefined && keyStep !== undefined && limit !== undefined) { + return { type: 'range', lastKey, keyStep, limit }; + } + break; + } + case 'keynum': { + const keyNumIdx = transformNumber(spec.keynumidx), + firstKey = transformNumber(spec.firstkey), + keyStep = transformNumber(spec.keystep); + if (keyNumIdx !== undefined && firstKey !== undefined && keyStep !== undefined) { + return { type: 'keynum', keyNumIdx, firstKey, keyStep }; + } + break; + } + } + return UNKNOWN_KEY_SPEC_PART; +} + +/** + * Parses one COMMAND key-specification entry. Unrecognized or malformed + * shapes parse to `{ type: 'unknown' }` parts instead of throwing — consumers + * (e.g. the multi_shard splitter) decide whether unknown is acceptable. + */ +export function transformKeySpec(raw: unknown): KeySpec { + const entry = normalizeKeySpecMap(raw); + return { + beginSearch: transformBeginSearch(entry?.begin_search), + findKeys: transformFindKeys(entry?.find_keys) + }; +} + export function transformCommandReply( this: void, [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply @@ -404,6 +506,7 @@ export function transformCommandReply( response: responsePolicy }, isKeyless: keySpecifications.length === 0, + keySpecs: keySpecifications.map(transformKeySpec), subcommands }; } From 63bab9187c8f57cf930de62e15472771e7cd50c1 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 16:27:10 +0300 Subject: [PATCH 19/54] feat(client): carry key specs into multi_shard command policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi_shard splitter needs the key-spec reconstruction recipe at resolution time on both resolver paths. Copy CommandReply.keySpecs into CommandPolicies only for multi_shard commands — the same builder produces static-policies-data.ts, and unconditional copying would roughly triple the generated file with specs nothing reads. Regenerated static data (Redis 8.8.0): del, exists, mget, mset, msetex, touch, unlink gained keySpecs; msetex is the only keynum spec. --- .../dynamic-policy-resolver-factory.ts | 11 +- .../dynamic-policy-resolver.spec.ts | 62 ++++++++++ .../policies-constants.ts | 8 ++ .../static-policies-data.ts | 112 ++++++++++++++++-- 4 files changed, 185 insertions(+), 8 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index e6fb766a824..c23f1cefa61 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -127,10 +127,19 @@ export class DynamicPolicyResolverFactory { } } + const request = command.policies.request ?? defaultRequest; + return { - request: command.policies.request ?? defaultRequest, + request, response: command.policies.response ?? defaultResponse, isKeyless, + // Only the multi_shard splitter consumes key specs. This builder also + // produces static-policies-data.ts, so copying them unconditionally + // would pollute the generated data with specs nothing reads + // (~tripling the file). + keySpecs: request === REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD + ? command.keySpecs + : undefined, subcommands }; } diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index fa79970147b..f38843dae25 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -52,6 +52,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -79,6 +80,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: false, + keySpecs: [], subcommands: [] } ]; @@ -106,6 +108,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -121,6 +124,60 @@ describe('DynamicPolicyResolverFactory', () => { } }); + it('should carry keySpecs through for multi_shard commands only', async () => { + const msetKeySpecs = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + }] as const; + const mockCommands: Array = [ + { + name: 'mset', + arity: -3, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: -1, + step: 2, + categories: new Set(), + policies: { request: 'multi_shard', response: 'all_succeeded' }, + isKeyless: false, + keySpecs: [...msetKeySpecs], + subcommands: [] + }, + { + name: 'get', + arity: 2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: false, + keySpecs: [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + }], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const msetResult = resolver.resolvePolicy({ command: 'mset', subcommand: undefined }); + assert.equal(msetResult.ok, true); + if (msetResult.ok) { + assert.deepEqual(msetResult.value.keySpecs, msetKeySpecs); + } + + // non-multi_shard commands never split — no keySpecs on their entries + const getResult = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); + assert.equal(getResult.ok, true); + if (getResult.ok) { + assert.equal(getResult.value.keySpecs, undefined); + } + }); + it('should handle module commands correctly', async () => { const mockCommands: Array = [ { @@ -133,6 +190,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'special' }, isKeyless: false, + keySpecs: [], subcommands: [] } ]; @@ -160,6 +218,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -189,6 +248,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -241,6 +301,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + keySpecs: [], subcommands: [] }, { @@ -253,6 +314,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index fff861c56af..2abe0ef69f4 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -1,3 +1,5 @@ +import type { KeySpec } from '../../commands/generic-transformers'; + export const REQUEST_POLICIES_WITH_DEFAULTS = { /** * The client should execute the command on all nodes - masters and replicas alike. @@ -113,4 +115,10 @@ export interface CommandPolicies { readonly response: ResponsePolicyWithDefaults; readonly subcommands?: Record; readonly isKeyless: boolean; + /** + * COMMAND key specifications — the reconstruction recipe for splitting the + * command per slot. Only populated for `multi_shard` commands; other + * commands never split, so their entries stay lean. + */ + readonly keySpecs?: ReadonlyArray; } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 3f752cd198a..d043c81449e 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1097,7 +1097,21 @@ export const POLICIES: ModulePolicyRecords = { "del": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "delex": { "request": "default-keyed", @@ -1152,7 +1166,21 @@ export const POLICIES: ModulePolicyRecords = { "exists": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "expire": { "request": "default-keyed", @@ -1695,7 +1723,21 @@ export const POLICIES: ModulePolicyRecords = { "mget": { "request": "multi_shard", "response": "default-keyed", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "migrate": { "request": "default-keyed", @@ -1747,12 +1789,40 @@ export const POLICIES: ModulePolicyRecords = { "mset": { "request": "multi_shard", "response": "all_succeeded", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 2, + "limit": 0 + } + } + ] }, "msetex": { "request": "multi_shard", "response": "all_succeeded", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "keynum", + "keyNumIdx": 0, + "firstKey": 1, + "keyStep": 2 + } + } + ] }, "msetnx": { "request": "default-keyed", @@ -2250,7 +2320,21 @@ export const POLICIES: ModulePolicyRecords = { "touch": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "trimslots": { "request": "default-keyless", @@ -2270,7 +2354,21 @@ export const POLICIES: ModulePolicyRecords = { "unlink": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "unsubscribe": { "request": "default-keyless", From 30d864d39928c2a97fd15b763e612d2921a817f3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 12:46:52 +0300 Subject: [PATCH 20/54] feat(client): add multi_shard command splitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure per-slot splitter driven by COMMAND key specs: range specs cover DEL, UNLINK, EXISTS, TOUCH, MGET, MSET; keynum covers MSETEX (numkeys rewritten per sub-command, options suffix copied verbatim). Single-slot commands pass through unsplit, preserving single-slot atomicity. Each sub-command records its key groups' original ordinals so reply aggregation can restore input key order (MGET). Anything that cannot be split deterministically — missing/multiple/keyword/unknown specs, malformed numkeys, misaligned key region — throws naming the command: a wrong split of a write command means corrupted data, so refusal beats guessing. Routing/aggregation wiring lands separately. --- .../request-response-policies/index.ts | 1 + .../multi-shard-splitter.spec.ts | 191 ++++++++++++++++++ .../multi-shard-splitter.ts | 148 ++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index 546637b4414..a06604a8961 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -6,5 +6,6 @@ export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-pol export * from './policies-constants'; export { POLICIES } from './static-policies-data'; export * from './dispatch'; +export { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; // export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts new file mode 100644 index 00000000000..15907f4fb74 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts @@ -0,0 +1,191 @@ +import { strict as assert } from 'node:assert'; +import calculateSlot from 'cluster-key-slot'; +import type { KeySpec } from '../../commands/generic-transformers'; +import { splitMultiShardCommand } from './multi-shard-splitter'; + +// Real specs of the 7 multi_shard commands (Redis 8.8.0). +const RANGE_STEP_1: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 1, limit: 0 } +}]; +const RANGE_STEP_2: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } +}]; +const KEYNUM_STEP_2: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'keynum', keyNumIdx: 0, firstKey: 1, keyStep: 2 } +}]; + +// Hash tags pin keys to slots: {a}* keys share a slot, {b}* keys share +// a different one. +const SLOT_A = calculateSlot('{a}1'); +const SLOT_B = calculateSlot('{b}1'); + +describe('splitMultiShardCommand', () => { + before(() => assert.notEqual(SLOT_A, SLOT_B)); + + it('single-slot fast path returns the original args', () => { + const args = ['DEL', '{a}1', '{a}2', '{a}3']; + const result = splitMultiShardCommand(args, RANGE_STEP_1); + + assert.equal(result.size, 1); + assert.deepEqual(result.get(SLOT_A), { + args: ['DEL', '{a}1', '{a}2', '{a}3'], + groupIndices: [0, 1, 2] + }); + }); + + it('splits DEL per slot (range, keystep 1)', () => { + const result = splitMultiShardCommand(['DEL', '{a}1', '{b}1', '{a}2'], RANGE_STEP_1); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['DEL', '{a}1', '{a}2'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['DEL', '{b}1'], + groupIndices: [1] + }); + }); + + it('splits MSET keeping values with their keys (range, keystep 2)', () => { + const result = splitMultiShardCommand( + ['MSET', '{a}1', 'v1', '{b}1', 'v2', '{a}2', 'v3'], + RANGE_STEP_2 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['MSET', '{a}1', 'v1', '{a}2', 'v3'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSET', '{b}1', 'v2'], + groupIndices: [1] + }); + }); + + it('splits MSETEX rewriting numkeys and copying the options suffix', () => { + const result = splitMultiShardCommand( + ['MSETEX', '3', '{a}1', 'v1', '{b}1', 'v2', '{a}2', 'v3', 'NX', 'EX', '10'], + KEYNUM_STEP_2 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v3', 'NX', 'EX', '10'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSETEX', '1', '{b}1', 'v2', 'NX', 'EX', '10'], + groupIndices: [1] + }); + }); + + it('keynum single-slot fast path keeps original numkeys', () => { + const args = ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v2', 'KEEPTTL']; + const result = splitMultiShardCommand(args, KEYNUM_STEP_2); + + assert.equal(result.size, 1); + assert.deepEqual(result.get(SLOT_A)?.args, args); + }); + + it('records interleaved group indices for order-preserving reassembly (MGET)', () => { + const result = splitMultiShardCommand( + ['MGET', '{a}1', '{b}1', '{a}2', '{b}2'], + RANGE_STEP_1 + ); + + assert.deepEqual(result.get(SLOT_A)?.groupIndices, [0, 2]); + assert.deepEqual(result.get(SLOT_B)?.groupIndices, [1, 3]); + }); + + it('handles Buffer keys', () => { + const result = splitMultiShardCommand( + ['MGET', Buffer.from('{a}1'), '{b}1'], + RANGE_STEP_1 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A)?.args, ['MGET', Buffer.from('{a}1')]); + }); + + describe('guardrails', () => { + const args = ['DEL', '{a}1', '{b}1']; + + it('rejects missing key specs', () => { + assert.throws(() => splitMultiShardCommand(args, undefined), /Cannot split DEL: command has no key specification/); + assert.throws(() => splitMultiShardCommand(args, []), /no key specification/); + }); + + it('rejects multiple key specs', () => { + assert.throws( + () => splitMultiShardCommand(args, [...RANGE_STEP_1, ...RANGE_STEP_1]), + /multiple key specifications/ + ); + }); + + it('rejects keyword begin_search', () => { + assert.throws( + () => splitMultiShardCommand(args, [{ + beginSearch: { type: 'keyword', keyword: 'STORE', startFrom: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 1, limit: 0 } + }]), + /unsupported begin_search type 'keyword'/ + ); + }); + + it('rejects unknown spec parts', () => { + assert.throws( + () => splitMultiShardCommand(args, [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }]), + /unsupported begin_search type 'unknown'/ + ); + assert.throws( + () => splitMultiShardCommand(args, [{ beginSearch: { type: 'index', index: 1 }, findKeys: { type: 'unknown' } }]), + /unsupported find_keys type 'unknown'/ + ); + }); + + it('rejects bounded ranges', () => { + assert.throws( + () => splitMultiShardCommand(['GET', '{a}1'], [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + }]), + /unsupported find_keys range/ + ); + }); + + it('rejects malformed numkeys', () => { + for (const numkeys of ['abc', '-1', '0', '2.5', '']) { + assert.throws( + () => splitMultiShardCommand(['MSETEX', numkeys, '{a}1', 'v1'], KEYNUM_STEP_2), + /malformed numkeys/ + ); + } + }); + + it('rejects a key region overrunning the args (numkeys too large)', () => { + assert.throws( + () => splitMultiShardCommand(['MSETEX', '3', '{a}1', 'v1'], KEYNUM_STEP_2), + /key region overruns/ + ); + }); + + it('rejects a key region misaligned with keystep (MSET missing value)', () => { + assert.throws( + () => splitMultiShardCommand(['MSET', '{a}1', 'v1', '{b}1'], RANGE_STEP_2), + /does not align with keystep/ + ); + }); + + it('rejects an empty key region', () => { + assert.throws( + () => splitMultiShardCommand(['DEL'], RANGE_STEP_1), + /key region/ + ); + }); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts new file mode 100644 index 00000000000..659cd085ad9 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -0,0 +1,148 @@ +import calculateSlot from 'cluster-key-slot'; +import type { RedisArgument } from '../../RESP/types'; +import type { KeySpec } from '../../commands/generic-transformers'; + +export type SubCommand = { + args: Array; + /** + * 0-based ordinals of this sub-command's key groups in the original + * command, for order-preserving reply reassembly (e.g. MGET). + */ + groupIndices: Array; +}; + +/** + * Splits a multi_shard command's arguments into one sub-command per hash + * slot, using the command's COMMAND key specification as the reconstruction + * recipe ("the command must be split even if all the slots are managed by + * the same shard" — but same-*slot* commands pass through unsplit). + * + * Each key group is the key plus its `keyStep - 1` trailing siblings (e.g. + * MSET's value). A sub-command is prefix + that slot's groups (in original + * relative order) + suffix; for `keynum` specs the numkeys argument in the + * prefix is rewritten to the sub-command's group count. + * + * Throws on anything it cannot split deterministically — a wrong split of a + * write command means corrupted data, so refusal beats guessing. All current + * multi_shard commands (DEL, UNLINK, EXISTS, TOUCH, MGET, MSET, MSETEX) + * declare exactly one supported spec. + */ +export function splitMultiShardCommand( + args: ReadonlyArray, + keySpecs: ReadonlyArray | undefined +): Map { + const label = args.length > 0 ? args[0].toString() : ''; + + if (!keySpecs || keySpecs.length === 0) { + throw new Error(`Cannot split ${label}: command has no key specification`); + } + // TODO(multi-spec): a command whose keys are interchangeable but + // syntactically scattered (e.g. a fixed-position key plus a keyword-tail + // list) could legitimately be multi_shard with several specs, and + // multi-region reconstruction would be deterministic. No such command + // exists, and specs alone cannot distinguish that shape from linked-operand + // specs (GEORADIUS-like) where splitting is meaningless — so refuse until a + // real command motivates multi-region support. + if (keySpecs.length > 1) { + throw new Error(`Cannot split ${label}: multiple key specifications are not supported`); + } + + const { beginSearch, findKeys } = keySpecs[0]; + if (beginSearch.type !== 'index') { + throw new Error(`Cannot split ${label}: unsupported begin_search type '${beginSearch.type}'`); + } + + const start = beginSearch.index; + let keyRegionStart: number; + let keyRegionEnd: number; + let keyStep: number; + // Absolute position of the numkeys argument to rewrite per sub-command. + let keyNumIdx: number | undefined; + + switch (findKeys.type) { + case 'range': { + // All current multi_shard range specs are "until end of args"; bounded + // ranges (lastKey >= 0) and limit can be added when a command needs them. + if (findKeys.lastKey !== -1 || findKeys.limit !== 0) { + throw new Error(`Cannot split ${label}: unsupported find_keys range (lastkey ${findKeys.lastKey}, limit ${findKeys.limit})`); + } + keyStep = findKeys.keyStep; + keyRegionStart = start; + keyRegionEnd = args.length; + break; + } + case 'keynum': { + keyStep = findKeys.keyStep; + keyNumIdx = start + findKeys.keyNumIdx; + keyRegionStart = start + findKeys.firstKey; + if (keyNumIdx >= keyRegionStart) { + throw new Error(`Cannot split ${label}: numkeys argument inside the key region`); + } + const numKeys = parsePositiveInteger(args[keyNumIdx]); + if (numKeys === undefined) { + throw new Error(`Cannot split ${label}: malformed numkeys argument '${args[keyNumIdx]}'`); + } + keyRegionEnd = keyRegionStart + numKeys * keyStep; + break; + } + default: + throw new Error(`Cannot split ${label}: unsupported find_keys type '${findKeys.type}'`); + } + + if (keyStep < 1) { + throw new Error(`Cannot split ${label}: invalid keystep ${keyStep}`); + } + if (keyRegionStart < 1 || keyRegionEnd > args.length) { + throw new Error(`Cannot split ${label}: key region overruns the arguments`); + } + const regionLength = keyRegionEnd - keyRegionStart; + if (regionLength <= 0 || regionLength % keyStep !== 0) { + throw new Error(`Cannot split ${label}: key region does not align with keystep ${keyStep}`); + } + + const groupCount = regionLength / keyStep; + const slotGroups = new Map>(); + for (let group = 0; group < groupCount; group++) { + const slot = calculateSlot(args[keyRegionStart + group * keyStep]); + const groups = slotGroups.get(slot); + if (groups) { + groups.push(group); + } else { + slotGroups.set(slot, [group]); + } + } + + const subCommands = new Map(); + + // Single-slot fast path: nothing to split — pass the original command + // through untouched (also preserves single-slot atomicity). + if (slotGroups.size === 1) { + const [[slot, groupIndices]] = slotGroups; + subCommands.set(slot, { args: [...args], groupIndices }); + return subCommands; + } + + const suffix = args.slice(keyRegionEnd); + for (const [slot, groupIndices] of slotGroups) { + const subArgs = args.slice(0, keyRegionStart); + if (keyNumIdx !== undefined) { + subArgs[keyNumIdx] = groupIndices.length.toString(); + } + for (const group of groupIndices) { + const groupStart = keyRegionStart + group * keyStep; + for (let i = 0; i < keyStep; i++) { + subArgs.push(args[groupStart + i]); + } + } + subArgs.push(...suffix); + subCommands.set(slot, { args: subArgs, groupIndices }); + } + + return subCommands; +} + +function parsePositiveInteger(arg: RedisArgument | undefined): number | undefined { + if (arg === undefined) return undefined; + const value = Number(arg.toString()); + return Number.isInteger(value) && value > 0 ? value : undefined; +} From 7f6f19a24c82aff8444512bb55d74983c673e5f0 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 16:59:45 +0300 Subject: [PATCH 21/54] refactor(client): extract policy dispatch into _executeWithPolicies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore _execute to a policy-free transport primitive — one command to one client (key-routed unless a pinned client is given) with MOVED/ASK redirect handling. Policy resolution, request-policy fan-out and response-policy aggregation move to _executeWithPolicies, which call sites hand data (parser, command, transformReply) instead of fn closures; fn creation now happens in one place, which the multi_shard splitter wiring will rely on to build per-sub-command closures. No behavior change: cluster test results are identical before and after. Note: _executePolicyPlan is a _-prefixed method rather than a native #private — cluster instances are derived via Object.create, which does not carry private fields. --- packages/client/lib/cluster/index.ts | 109 ++++++++++++++++++++------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 83a542fbf91..8926e22edf3 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -1,6 +1,6 @@ import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client'; import { CommandOptions } from '../client/commands-queue'; -import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types'; +import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, TransformReply } from '../RESP/types'; import { NON_STICKY_COMMANDS } from '../commands'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; @@ -188,11 +188,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - command.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + command, + transformReply ); }; } @@ -204,11 +204,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - command.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + command, + transformReply ); }; } @@ -222,11 +222,11 @@ export default class RedisCluster< parser.push(...prefix); fn.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - fn.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(fn, parser, opts, transformReply) + fn, + transformReply ); }; } @@ -240,11 +240,11 @@ export default class RedisCluster< parser.push(...prefix); script.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeScriptWithPolicies( parser, - script.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeScript(script, parser, opts, transformReply) + script, + transformReply ); }; } @@ -496,15 +496,46 @@ export default class RedisCluster< }; } - async _execute( + /** + * Resolves the command's policies and executes it accordingly: the request + * policy picks the target clients, each target runs through the core + * `_execute` transport primitive, and the response policy aggregates the + * replies. Call sites pass data — fn closures are created here. + */ + async _executeWithPolicies( parser: CommandParser, - isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, - fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise + command: Command | RedisFunction, + transformReply: TransformReply | undefined ): Promise { + return this._executePolicyPlan( + parser, + command.IS_READ_ONLY, + options, + p => (client, opts) => client._executeCommand(command, p, opts as CommandOptions | undefined, transformReply) + ); + } - const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + async _executeScriptWithPolicies( + parser: CommandParser, + options: ClusterCommandOptions | undefined, + script: RedisScript, + transformReply: TransformReply | undefined + ): Promise { + return this._executePolicyPlan( + parser, + script.IS_READ_ONLY, + options, + p => (client, opts) => client._executeScript(script, p, opts as CommandOptions | undefined, transformReply) + ); + } + async _executePolicyPlan( + parser: CommandParser, + isReadonly: boolean | undefined, + options: ClusterCommandOptions | undefined, + makeFn: (parser: CommandParser) => (client: RedisClientType, opts?: ClusterCommandOptions) => Promise + ): Promise { const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); if(!policyResult.ok) { @@ -524,7 +555,34 @@ export default class RedisCluster< const clients: Array> = await router(this._slots, parser, isReadonly); - const responsePromises = clients.map(async client => { + const responsePromises = clients.map( + client => this._execute(parser, isReadonly, options, makeFn(parser), client) + ); + + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); + } + return reducer(responsePromises, parser) as Promise; + } + + /** + * Core transport primitive: sends one command to one client — resolved by + * the parser's first key unless `pinnedClient` is given — with MOVED/ASK + * redirect handling. Policy-free; fan-out and aggregation live in + * `_executePolicyPlan`. + */ + async _execute( + parser: CommandParser, + isReadonly: boolean | undefined, + options: ClusterCommandOptions | undefined, + fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise, + pinnedClient?: RedisClientType + ): Promise { + const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + + let client = pinnedClient + ?? (await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client; let i = 0; @@ -591,15 +649,6 @@ export default class RedisCluster< throw err; } } - - }) - - const reducer = RESPONSE_REDUCERS[responsePolicy]; - if (!reducer) { - throw new Error(`Unknown response policy ${responsePolicy}`); - } - return reducer(responsePromises, parser) as Promise; - } async sendCommand( @@ -620,11 +669,13 @@ export default class RedisCluster< firstKey && parser.push(firstKey) args.forEach(arg => parser.push(arg)); - return this._self._execute( + // Raw path: no command object, so readonly-ness stays an explicit caller + // argument and the reply is returned untransformed. + return this._self._executePolicyPlan( parser, isReadonly, opts, - (client, opts) => client.sendCommand(args, opts) + () => (client, opts) => client.sendCommand(args, opts) ); } From 482e6fa0389839c21c63acec2891272c1d731165 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 17:00:50 +0300 Subject: [PATCH 22/54] fix(client): correct default policy reducers reduceDefaultKeyed returned the responses array instead of the single reply, wrapping every keyed cluster command (GET returned ['value']). reduceDefaultKeyless fed single replies through merge aggregation, which throws on scalars (PUBLISH replies are numbers); merging is only meaningful for fan-out replies, so single-target responses now pass through. Also drop a stray debug console.log from aggregateMerge. --- .../client/lib/cluster/request-response-policies/dispatch.ts | 5 ++++- .../cluster/request-response-policies/generic-aggregators.ts | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 39512e65b18..31b61a73114 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -123,12 +123,15 @@ export const reduceSpecial = async (_promises: Promise[], parser: CommandP export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { const responses = await Promise.all(promises); + // Merging is only meaningful for fan-out replies (e.g. KEYS under + // all_shards); the single-target case must pass scalar replies through. + if (responses.length === 1) return responses[0]; return aggregateMerge(responses) as T; }; export const reduceDefaultKeyed = async (promises: Promise[]): Promise => { const responses = await Promise.all(promises); - return responses as T; + return responses[0]; }; // --- registries --- diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts index de4202d2d44..16de711f2c4 100644 --- a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -119,8 +119,6 @@ export const aggregateMerge = (replies: Array): T => { return map as T; } - //TODO remove - console.log('firstReply', firstReply, typeof firstReply); throw new Error('Unsupported reply type for merge aggregation'); }; From 332e6016047ba9da0e25e4a19c007c0838f4485e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 17:02:58 +0300 Subject: [PATCH 23/54] fix(client): connect nodes lazily in all-shards/all-nodes fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAllMasterClients/getAllClients silently skipped nodes without a connected client. With minimizeConnections (or a partially connected cluster) all_shards/all_nodes commands fanned out to a subset of nodes — or none, resolving undefined: PING via sendCommand returned undefined, DBSIZE could sum a subset without any error. Route through nodeClient(), the same lazy connect-or-reuse accessor keyed routing uses. getAllClients no longer yields dedicated PubSub connections; they cannot run regular commands. Also guard the policy dispatch against an empty fan-out — failing loud beats resolving undefined. --- packages/client/lib/cluster/cluster-slots.ts | 23 +++++++++++--------- packages/client/lib/cluster/index.ts | 4 ++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 51a79b50d90..1854b562022 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -818,18 +818,21 @@ export default class RedisClusterSlots< } } - getAllClients() { - return Array.from(this.#clients()); + /** + * All node clients (masters and replicas), connecting lazily — with + * `minimizeConnections` nodes have no client until first use, and skipping + * them would silently fan commands out to a subset of the cluster. + * Excludes dedicated PubSub connections: they cannot run regular commands. + */ + getAllClients(): Promise>> { + return Promise.all([ + ...this.masters.map(master => this.nodeClient(master)), + ...this.replicas.map(replica => this.nodeClient(replica)) + ]); } - getAllMasterClients() { - const result = []; - for (const master of this.masters) { - if (master.client) { - result.push(master.client); - } - } - return result; + getAllMasterClients(): Promise>> { + return Promise.all(this.masters.map(master => this.nodeClient(master))); } async getClientAndSlotNumber( diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 8926e22edf3..92798db94b8 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -555,6 +555,10 @@ export default class RedisCluster< const clients: Array> = await router(this._slots, parser, isReadonly); + if (clients.length === 0) { + throw new Error(`Request policy ${requestPolicy} produced no target nodes`); + } + const responsePromises = clients.map( client => this._execute(parser, isReadonly, options, makeFn(parser), client) ); From 8185f97af0cbe00b7dcf75a4c88f74bc4d40042c Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 12:00:58 +0300 Subject: [PATCH 24/54] feat(client): wire multi_shard splitter into cluster dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the multi_shard request policy actually split commands per hash slot instead of sending the full command to one client per key. - Routers now return a plan (RoutedCommand[]) rather than bare clients: pass-through policies set `client`; routeMultiShard calls the splitter and builds a sub-parser per slot (buildSubParser marks keys at the splitter's new keyPositions so `firstKey` routes each sub-command). - The engine runs each plan entry through core `_execute` with its own sub-parser, so every shard gets its own sub-args; MOVED/ASK use the sub-command's key. - Response reducers gain optional positionHints; reduceDefaultKeyed scatters split replies by groupIndices so MGET preserves caller key order across slots. Aggregating reducers (agg_sum, all_succeeded) ignore the hint. - Splitter SubCommand now carries keyPositions for sub-parser key marking. Also merge _executePolicyPlan into _executeWithPolicies — the old name implied a plan object that never existed; it is now the single engine (resolve policy -> plan -> execute -> reduce). Router/reducer types are intentionally non-generic (routing sits below the typed command surface; clients are opaque pass-through), erased to the base cluster types instead of `any`; the engine re-narrows the client at the `_execute` boundary. Working end-to-end: DEL/UNLINK/EXISTS/TOUCH, MSET/MSETEX, MGET. Raw sendCommand multi_shard and cluster-docker integration tests remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/cluster/index.ts | 81 ++++----- .../dispatch.spec.ts | 76 +++++++++ .../request-response-policies/dispatch.ts | 161 ++++++++++++------ .../multi-shard-splitter.spec.ts | 21 ++- .../multi-shard-splitter.ts | 22 ++- 5 files changed, 250 insertions(+), 111 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/dispatch.spec.ts diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 92798db94b8..5cd7a44d8c3 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -1,6 +1,6 @@ import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client'; import { CommandOptions } from '../client/commands-queue'; -import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, TransformReply } from '../RESP/types'; +import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types'; import { NON_STICKY_COMMANDS } from '../commands'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; @@ -190,9 +190,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + command.IS_READ_ONLY, this._commandOptions, - command, - transformReply + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -206,9 +206,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + command.IS_READ_ONLY, this._self._commandOptions, - command, - transformReply + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -224,9 +224,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + fn.IS_READ_ONLY, this._self._commandOptions, - fn, - transformReply + p => (client, opts) => client._executeCommand(fn, p, opts, transformReply) ); }; } @@ -240,11 +240,11 @@ export default class RedisCluster< parser.push(...prefix); script.parseCommand(parser, ...args); - return this._self._executeScriptWithPolicies( + return this._self._executeWithPolicies( parser, + script.IS_READ_ONLY, this._commandOptions, - script, - transformReply + p => (client, opts) => client._executeScript(script, p, opts, transformReply) ); }; } @@ -500,37 +500,10 @@ export default class RedisCluster< * Resolves the command's policies and executes it accordingly: the request * policy picks the target clients, each target runs through the core * `_execute` transport primitive, and the response policy aggregates the - * replies. Call sites pass data — fn closures are created here. + * replies. Call sites pass a `makeFn` factory that builds the per-client + * execution closure (command, script, or raw `sendCommand`). */ async _executeWithPolicies( - parser: CommandParser, - options: ClusterCommandOptions | undefined, - command: Command | RedisFunction, - transformReply: TransformReply | undefined - ): Promise { - return this._executePolicyPlan( - parser, - command.IS_READ_ONLY, - options, - p => (client, opts) => client._executeCommand(command, p, opts as CommandOptions | undefined, transformReply) - ); - } - - async _executeScriptWithPolicies( - parser: CommandParser, - options: ClusterCommandOptions | undefined, - script: RedisScript, - transformReply: TransformReply | undefined - ): Promise { - return this._executePolicyPlan( - parser, - script.IS_READ_ONLY, - options, - p => (client, opts) => client._executeScript(script, p, opts as CommandOptions | undefined, transformReply) - ); - } - - async _executePolicyPlan( parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, @@ -552,29 +525,39 @@ export default class RedisCluster< if (!router) { throw new Error(`Unknown request policy ${requestPolicy}`); } - const clients: Array> = - await router(this._slots, parser, isReadonly); + // Routers are typed against the erased base cluster types (routing is + // below the typed command surface); bridge this instantiation's slots in. + const plan = await router( + this._slots as unknown as Parameters[0], + parser, + isReadonly, + policyResult.value.keySpecs + ); - if (clients.length === 0) { + if (plan.length === 0) { throw new Error(`Request policy ${requestPolicy} produced no target nodes`); } - const responsePromises = clients.map( - client => this._execute(parser, isReadonly, options, makeFn(parser), client) - ); + const responsePromises = plan.map(entry => { + const entryParser = entry.parser ?? parser; + // Re-narrow the opaque routed client to this cluster's instantiation. + const client = entry.client as RedisClientType | undefined; + return this._execute(entryParser, isReadonly, options, makeFn(entryParser), client); + }); const reducer = RESPONSE_REDUCERS[responsePolicy]; if (!reducer) { throw new Error(`Unknown response policy ${responsePolicy}`); } - return reducer(responsePromises, parser) as Promise; + const positionHints = plan.map(entry => entry.groupIndices); + return reducer(responsePromises, parser, positionHints) as Promise; } /** * Core transport primitive: sends one command to one client — resolved by * the parser's first key unless `pinnedClient` is given — with MOVED/ASK * redirect handling. Policy-free; fan-out and aggregation live in - * `_executePolicyPlan`. + * `_executeWithPolicies`. */ async _execute( parser: CommandParser, @@ -670,12 +653,12 @@ export default class RedisCluster< } const parser = new BasicCommandParser(); - firstKey && parser.push(firstKey) + if (firstKey) parser.push(firstKey); args.forEach(arg => parser.push(arg)); // Raw path: no command object, so readonly-ness stays an explicit caller // argument and the reply is returned untransformed. - return this._self._executePolicyPlan( + return this._self._executeWithPolicies( parser, isReadonly, opts, diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts new file mode 100644 index 00000000000..70b8e1fb2f3 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts @@ -0,0 +1,76 @@ +import { strict as assert } from 'node:assert'; +import type { CommandParser } from '../../client/parser'; +import { reduceDefaultKeyed } from './dispatch'; + +// The reducer ignores the parser; a stub keeps the calls readable. +const PARSER = {} as CommandParser; + +describe('reduceDefaultKeyed', () => { + it('passes the sole reply through when not split (no hints)', async () => { + const reply = await reduceDefaultKeyed([Promise.resolve(['v1', 'v2'])], PARSER); + assert.deepEqual(reply, ['v1', 'v2']); + }); + + it('passes through when hints are all undefined (single-key command)', async () => { + const reply = await reduceDefaultKeyed([Promise.resolve('v1')], PARSER, [undefined]); + assert.equal(reply, 'v1'); + }); + + it('passes through a single-slot multi_shard reply unchanged', async () => { + const reply = await reduceDefaultKeyed( + [Promise.resolve(['v0', 'v1', 'v2'])], + PARSER, + [[0, 1, 2]] + ); + assert.deepEqual(reply, ['v0', 'v1', 'v2']); + }); + + it('scatters interleaved sub-replies back into original key order (MGET A,B,A,B)', async () => { + // keys hash to A,B,A,B -> slot A holds groups [0,2], slot B holds [1,3]. + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['a0', 'a2']), + Promise.resolve(['b1', 'b3']) + ], + PARSER, + [[0, 2], [1, 3]] + ); + assert.deepEqual(reply, ['a0', 'b1', 'a2', 'b3']); + }); + + it('places each sub-reply by its hint regardless of plan order', async () => { + // slot B (groups [1]) listed before slot A (groups [0,2]). + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['b1']), + Promise.resolve(['a0', 'a2']) + ], + PARSER, + [[1], [0, 2]] + ); + assert.deepEqual(reply, ['a0', 'b1', 'a2']); + }); + + it('preserves null replies (missing keys) at their positions', async () => { + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['a0', null]), + Promise.resolve([null]) + ], + PARSER, + [[0, 2], [1]] + ); + assert.deepEqual(reply, ['a0', null, null]); + }); + + it('throws when a split reply is missing its position hint', async () => { + await assert.rejects( + reduceDefaultKeyed( + [Promise.resolve(['a0']), Promise.resolve(['b1'])], + PARSER, + [[0], undefined] + ), + /missing position hints/ + ); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 31b61a73114..b9053692c56 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -1,9 +1,11 @@ -import type { CommandParser } from '../../client/parser'; +import { BasicCommandParser, type CommandParser } from '../../client/parser'; import type { RedisClientType } from '../../client'; import type { - RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping + RedisArgument, RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../../RESP/types'; +import type { KeySpec } from '../../commands/generic-transformers'; import type RedisClusterSlots from '../cluster-slots'; +import { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; import { aggregateLogicalAnd, aggregateLogicalOr, @@ -19,61 +21,95 @@ import { type ResponsePolicyWithDefaults } from './policies-constants'; -type Client< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = RedisClientType; - -type Slots< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = RedisClusterSlots; - -export type RequestRouter< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = ( - slots: Slots, +// Routing runs *below* the typed command surface: routers never inspect the +// command's M/F/S/RESP/TM parameters, they just shuffle opaque clients from +// `slots` into the plan and on to `_execute`. So these types are deliberately +// not generic — they use the base constraint types. The engine re-narrows the +// client to its own instantiation at the `_execute` boundary. +type ClusterClient = RedisClientType; +type ClusterSlots = RedisClusterSlots; + +/** + * One unit of work a request policy schedules. Pass-through policies set only + * `client` (run the original command on that node). `multi_shard` sets `parser` + * (a per-slot sub-command, routed by its own `firstKey`) and `groupIndices` + * (where its replies belong in the reassembled result). + */ +export type RoutedCommand = { + client?: ClusterClient; + parser?: CommandParser; + groupIndices?: Array; +}; + +export type RequestRouter = ( + slots: ClusterSlots, parser: CommandParser, - isReadonly: boolean | undefined -) => Promise>>; + isReadonly: boolean | undefined, + keySpecs: ReadonlyArray | undefined +) => Promise>; export type ResponseReducer = ( responsePromises: Promise[], - parser: CommandParser + parser: CommandParser, + /** + * For `multi_shard` commands, `positionHints[p]` is the original 0-based + * group ordinals carried by the p-th sub-command (plan order == promise + * order). Reducers that preserve order (e.g. default-keyed MGET) use it to + * scatter each sub-reply back into the caller's key order. `undefined` + * entries mean "not split"; the whole array is absent for non-split commands. + */ + positionHints?: Array | undefined> ) => Promise; // --- request routers --- -export const routeAllNodes: RequestRouter = - async (slots) => slots.getAllClients(); - -export const routeAllShards: RequestRouter = - async (slots) => slots.getAllMasterClients(); - -export const routeMultiShard: RequestRouter = - async (slots, parser, isReadonly) => - Promise.all( - parser.keys.map(async (key) => (await slots.getClientAndSlotNumber(key, isReadonly)).client) - ); - -export const routeDefaultKeyless: RequestRouter = - async (slots) => [slots.getRandomNode().client!]; +export const routeAllNodes: RequestRouter = + async (slots) => (await slots.getAllClients()).map(client => ({ client })); + +export const routeAllShards: RequestRouter = + async (slots) => (await slots.getAllMasterClients()).map(client => ({ client })); + +/** + * Splits the command into one sub-command per hash slot (using the COMMAND key + * specs as the reconstruction recipe) and returns a plan entry per slot. Each + * entry carries its own sub-parser, so core `_execute` routes it by that + * slot's `firstKey` and handles MOVED/ASK with the sub-command's own key. + */ +export const routeMultiShard: RequestRouter = + async (_slots, parser, _isReadonly, keySpecs) => { + const subCommands = splitMultiShardCommand(parser.redisArgs, keySpecs); + return Array.from(subCommands.values(), sub => ({ + parser: buildSubParser(sub), + groupIndices: sub.groupIndices + })); + }; -export const routeDefaultKeyed: RequestRouter = +/** + * Rebuilds a `CommandParser` from a split sub-command, marking the keys at + * their known positions so `firstKey` resolves to this slot's first key. + */ +function buildSubParser(sub: SubCommand): CommandParser { + const parser = new BasicCommandParser(); + const keyPositions = new Set(sub.keyPositions); + for (let i = 0; i < sub.args.length; i++) { + const arg = sub.args[i] as RedisArgument; + if (keyPositions.has(i)) { + parser.pushKey(arg); + } else { + parser.push(arg); + } + } + return parser; +} + +export const routeDefaultKeyless: RequestRouter = + async (slots) => [{ client: slots.getRandomNode().client! }]; + +export const routeDefaultKeyed: RequestRouter = async (slots, parser, isReadonly) => - [(await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client]; + [{ client: (await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client }]; -export const routeSpecial: RequestRouter = +export const routeSpecial: RequestRouter = async (_slots, parser) => { const { command, subcommand } = parser.commandIdentifier; const label = subcommand ? `${command} ${subcommand}` : command; @@ -129,9 +165,34 @@ export const reduceDefaultKeyless = async (promises: Promise[]): Promise(promises: Promise[]): Promise => { +export const reduceDefaultKeyed = async ( + promises: Promise[], + _parser: CommandParser, + positionHints?: Array | undefined> +): Promise => { const responses = await Promise.all(promises); - return responses[0]; + + // Unsplit (single-target read, or a multi_shard command that landed on one + // slot): pass the sole reply through unchanged. + if (!positionHints?.some(hint => hint !== undefined)) { + return responses[0]; + } + + // Split multi_shard (e.g. MGET across slots): each sub-reply is an array in + // its own group order; scatter element i back to its original group ordinal + // so the result matches the caller's key order regardless of slot/arrival. + const result: Array = []; + responses.forEach((reply, p) => { + const indices = positionHints[p]; + if (!indices) { + throw new Error('default-keyed reducer: split reply missing position hints'); + } + const elements = reply as Array; + indices.forEach((groupIndex, i) => { + result[groupIndex] = elements[i]; + }); + }); + return result as T; }; // --- registries --- @@ -143,7 +204,7 @@ export const REQUEST_ROUTERS = { [REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL]: routeSpecial, [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: routeDefaultKeyless, [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: routeDefaultKeyed -} as const satisfies Record>; +} as const satisfies Record; export const RESPONSE_REDUCERS = { [RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED]: reduceOneSucceeded, @@ -156,4 +217,4 @@ export const RESPONSE_REDUCERS = { [RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL]: reduceSpecial, [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: reduceDefaultKeyless, [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: reduceDefaultKeyed -} as const satisfies Record>; +} as const satisfies Record>; diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts index 15907f4fb74..83edc56a577 100644 --- a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts @@ -32,7 +32,8 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 1); assert.deepEqual(result.get(SLOT_A), { args: ['DEL', '{a}1', '{a}2', '{a}3'], - groupIndices: [0, 1, 2] + groupIndices: [0, 1, 2], + keyPositions: [1, 2, 3] }); }); @@ -42,11 +43,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['DEL', '{a}1', '{a}2'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [1, 2] }); assert.deepEqual(result.get(SLOT_B), { args: ['DEL', '{b}1'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [1] }); }); @@ -59,11 +62,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['MSET', '{a}1', 'v1', '{a}2', 'v3'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [1, 3] }); assert.deepEqual(result.get(SLOT_B), { args: ['MSET', '{b}1', 'v2'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [1] }); }); @@ -76,11 +81,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v3', 'NX', 'EX', '10'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [2, 4] }); assert.deepEqual(result.get(SLOT_B), { args: ['MSETEX', '1', '{b}1', 'v2', 'NX', 'EX', '10'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [2] }); }); diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts index 659cd085ad9..0e27d06a564 100644 --- a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -9,6 +9,12 @@ export type SubCommand = { * command, for order-preserving reply reassembly (e.g. MGET). */ groupIndices: Array; + /** + * Absolute indices into `args` of this sub-command's keys (the first arg of + * each group). Lets the caller build a sub-parser that marks keys, so core + * `_execute` routes by the sub-command's own `firstKey`. + */ + keyPositions: Array; }; /** @@ -115,10 +121,12 @@ export function splitMultiShardCommand( const subCommands = new Map(); // Single-slot fast path: nothing to split — pass the original command - // through untouched (also preserves single-slot atomicity). + // through untouched (also preserves single-slot atomicity). Keys keep their + // original absolute positions. if (slotGroups.size === 1) { const [[slot, groupIndices]] = slotGroups; - subCommands.set(slot, { args: [...args], groupIndices }); + const keyPositions = groupIndices.map(group => keyRegionStart + group * keyStep); + subCommands.set(slot, { args: [...args], groupIndices, keyPositions }); return subCommands; } @@ -128,14 +136,18 @@ export function splitMultiShardCommand( if (keyNumIdx !== undefined) { subArgs[keyNumIdx] = groupIndices.length.toString(); } - for (const group of groupIndices) { + // Groups are appended in order, each keyStep wide with the key first, so + // the j-th group's key lands at keyRegionStart + j * keyStep in subArgs. + const keyPositions: Array = []; + groupIndices.forEach((group, j) => { + keyPositions.push(keyRegionStart + j * keyStep); const groupStart = keyRegionStart + group * keyStep; for (let i = 0; i < keyStep; i++) { subArgs.push(args[groupStart + i]); } - } + }); subArgs.push(...suffix); - subCommands.set(slot, { args: subArgs, groupIndices }); + subCommands.set(slot, { args: subArgs, groupIndices, keyPositions }); } return subCommands; From 7a1e0dda64879df9af0b4206d601a774a683ef96 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 14:12:19 +0300 Subject: [PATCH 25/54] fix(client): fall back to default policy for unknown cluster commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _executeWithPolicies routed every command through policy resolution, but the cluster's resolver (StaticPolicyResolver(POLICIES)) has no fallback, so any command absent from the policy table resolved to { ok: false } and the engine threw "Policy resolution error". This broke user-defined custom commands, scripts/functions whose dispatched name is not in the table, and module commands the resolver was not built with. Such commands have no request/response policy and nothing to split or aggregate. Instead of throwing, fall back to a default policy (default_keyed when the parser carries keys, else default_keyless) and route through the existing pass-through default router/reducer — the same single-client, key-routed behaviour as a direct _execute. Known module commands the dynamic resolver does recognise still get their real policy, so a module declaring multi_shard keeps working. Known commands that cannot be split still throw from the splitter. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/cluster/index.ts | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 5cd7a44d8c3..5700e645796 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,7 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { POLICIES, PolicyResolver, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandPolicies } from './request-response-policies'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = @@ -511,14 +511,28 @@ export default class RedisCluster< ): Promise { const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); - if(!policyResult.ok) { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Policy resolution error for ${label}: ${policyResult.error}`); - } - - const requestPolicy = policyResult.value.request - const responsePolicy = policyResult.value.response + // Commands the resolver doesn't know — user-defined custom commands, + // scripts/functions, modules absent from the policy table — have no + // request/response policy and nothing to split or aggregate. Fall back to + // the default key-routed path (single client by `firstKey`, sole reply + // passed through) rather than failing. Scripts/functions are single-slot + // by contract, so default-keyed is always correct for them. Known + // multi_shard commands that can't be split still throw from the splitter. + const hasKeys = parser.keys.length > 0; + const policy: CommandPolicies = policyResult.ok + ? policyResult.value + : { + request: hasKeys + ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: hasKeys + ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: !hasKeys + }; + + const requestPolicy = policy.request + const responsePolicy = policy.response // https://redis.io/docs/latest/develop/reference/command-tips const router = REQUEST_ROUTERS[requestPolicy]; @@ -531,7 +545,7 @@ export default class RedisCluster< this._slots as unknown as Parameters[0], parser, isReadonly, - policyResult.value.keySpecs + policy.keySpecs ); if (plan.length === 0) { From 29a7f465c155c0f83fa3283d117bd7337e6b8e86 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 17:18:03 +0300 Subject: [PATCH 26/54] fix(client): route and split the raw cluster sendCommand path correctly The policy refactor folded the raw cluster sendCommand into _executeWithPolicies but built its parser wrong, regressing behaviour master got right by routing on the explicit firstKey: - firstKey was prepended to args, so redisArgs[0] became a key instead of the command name (breaking policy resolution) and no key was ever marked, leaving parser.firstKey unset so routing fell back to undefined. - makeFn ignored the per-entry sub-parser and always sent the original args, so a split multi_shard command sent the full command to every shard. Build redisArgs as an exact copy of args (command name at index 0) and register the caller's firstKey via a new BasicCommandParser.markRoutingKey, which records the key for routing without re-appending it to redisArgs. Send p.redisArgs from the closure so split sub-commands each carry their own slot's arguments. A known multi_shard command sent raw now splits correctly, since the resolver supplies its key specs and the splitter operates on the flat redisArgs. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/client/parser.spec.ts | 31 +++++++++++++++++++++++ packages/client/lib/client/parser.ts | 11 ++++++++ packages/client/lib/cluster/index.ts | 12 ++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/client/parser.spec.ts b/packages/client/lib/client/parser.spec.ts index 4677456250e..ed6280553e5 100644 --- a/packages/client/lib/client/parser.spec.ts +++ b/packages/client/lib/client/parser.spec.ts @@ -160,4 +160,35 @@ describe('BasicCommandParser', () => { } }); }); + + describe('markRoutingKey', () => { + it('sets firstKey without appending to redisArgs', () => { + const parser = new BasicCommandParser(); + parser.push('MGET', 'k1', 'k2'); + parser.markRoutingKey('k1'); + + // redisArgs stays an exact copy of what was pushed (the wire command). + assert.deepEqual(parser.redisArgs, ['MGET', 'k1', 'k2']); + // ...but the key is registered for routing. + assert.deepEqual(parser.keys, ['k1']); + assert.equal(parser.firstKey, 'k1'); + }); + + it('leaves keys empty when never called (keyless raw command)', () => { + const parser = new BasicCommandParser(); + parser.push('PING'); + + assert.deepEqual(parser.redisArgs, ['PING']); + assert.deepEqual(parser.keys, []); + assert.equal(parser.firstKey, undefined); + }); + + it('keeps commandIdentifier pointing at the command name', () => { + const parser = new BasicCommandParser(); + parser.push('GET', 'k1'); + parser.markRoutingKey('k1'); + + assert.deepEqual(parser.commandIdentifier, { command: 'GET', subcommand: 'k1' }); + }); + }); }); diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 1a6db5be443..c1fabd795b6 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -150,6 +150,17 @@ export class BasicCommandParser implements CommandParser { this.#addKey(key, applyPrefix); } + /** + * Records a routing key whose value is already present in the pushed args, + * without appending it again. Used by the raw cluster `sendCommand` path, + * where the caller supplies the routing key separately from the full, + * already-assembled argument list — so `firstKey` resolves for routing while + * `redisArgs` stays an exact copy of the command sent on the wire. + */ + markRoutingKey(key: RedisArgument) { + this.#keys.push(key); + } + pushKeysLength(keys: RedisVariadicArgument) { if (Array.isArray(keys)) { this.#redisArgs.push(keys.length.toString()); diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 5700e645796..6b13b381d8f 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -666,17 +666,23 @@ export default class RedisCluster< ...options } + // `args` is the full command as sent on the wire (name at index 0), so it + // becomes `redisArgs` verbatim — policy resolution reads the command name + // and the multi_shard splitter's key-spec offsets line up. The caller's + // `firstKey` is marked for routing without re-appending it. const parser = new BasicCommandParser(); - if (firstKey) parser.push(firstKey); args.forEach(arg => parser.push(arg)); + if (firstKey !== undefined) parser.markRoutingKey(firstKey); // Raw path: no command object, so readonly-ness stays an explicit caller - // argument and the reply is returned untransformed. + // argument and the reply is returned untransformed. The closure sends the + // per-entry parser's args, so split multi_shard sub-commands each carry + // their own slot's arguments (and the unsplit case sends `args` unchanged). return this._self._executeWithPolicies( parser, isReadonly, opts, - () => (client, opts) => client.sendCommand(args, opts) + p => (client, opts) => client.sendCommand(p.redisArgs as CommandArguments, opts) ); } From 44e7cd2bb58b0e637274607fcbd4f51ce21cba27 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 1 Jul 2026 19:03:58 +0300 Subject: [PATCH 27/54] feat(client): implement special-policy reducers + safe fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the throw-on-`special` behavior in the cluster policy dispatch with a per-command reducer registry plus a safe generic fallback: - routeSpecial: route to a single node + console.warn instead of throwing, so an unhandled special-request command still works (reply may be partial). - reduceSpecial: dispatch by uppercased command identifier to a dedicated reducer; unhandled commands fall back to the default-keyless reduction (sole reply / merge) with a warn. - reduceRandomKey (RANDOMKEY): fan out per all_shards, return one non-nil reply at random; all-empty -> nil. Avoids false-nil on empty shards. - reduceFirstReply: for fan-out diagnostics with a single-node reply type (INFO, MEMORY DOCTOR/MALLOC-STATS/STATS, FUNCTION STATS, LATENCY DOCTOR/GRAPH/HISTOGRAM/HISTORY/LATEST) — fan out per the request tip, return one node's reply so the reply type stays honest. FT.CURSOR sticky routing and SCAN/HOTKEYS remain unhandled (fall through to the safe generic fallback) — tracked as separate work. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../request-response-policies/dispatch.ts | 98 +++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index b9053692c56..6a04c3ed13c 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -109,11 +109,32 @@ export const routeDefaultKeyed: RequestRouter = async (slots, parser, isReadonly) => [{ client: (await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client }]; +/** + * Uppercased command key ("RANDOMKEY", "MEMORY STATS") used both to look up a + * per-command special handler and to label warnings. `commandIdentifier` + * preserves the caller's casing, so normalize before matching. + */ +function specialKey(parser: CommandParser): string { + const { command, subcommand } = parser.commandIdentifier; + const c = command.toUpperCase(); + return subcommand ? `${c} ${subcommand.toUpperCase()}` : c; +} + +/** + * Fallback router for `special` request commands without a dedicated handler. + * A `special` request policy means non-trivial routing that no generic rule + * captures; we don't know the correct target, so route to a single (random) + * node like a keyless command. This keeps the command working instead of + * throwing, but the reply reflects only that one node — warn so the gap is + * visible. Commands with a real handler never reach this. + */ export const routeSpecial: RequestRouter = - async (_slots, parser) => { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Special request policy not implemented for ${label}`); + async (slots, parser) => { + console.warn( + `node-redis: no cluster routing implemented for the "special" request policy of ` + + `"${specialKey(parser)}"; routing to a single node. The reply may be incomplete.` + ); + return [{ client: slots.getRandomNode().client! }]; }; // --- response reducers --- @@ -151,10 +172,71 @@ export const reduceSum = async (promises: Promise[]): Promise => { return aggregateSum(responses) as T; }; -export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Special response policy not implemented for ${label}`); +/** + * RANDOMKEY under `all_shards`: each master returns a random key from its own + * keyspace (or nil when empty). Return one of the non-nil replies at random so + * the result is a valid random key across the whole cluster and never a + * false-nil when some shard is empty but others hold keys. All shards empty → + * nil. + */ +export const reduceRandomKey = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + const keys = responses.filter(reply => reply != null); + if (keys.length === 0) return responses[0]; + return keys[Math.floor(Math.random() * keys.length)]; +}; + +/** + * Reducer for fan-out diagnostic commands (INFO, ...) whose per-node replies + * can't be merged into one meaningful value and whose reply type is a single + * node's shape. We still fan out per the `all_shards`/`all_nodes` request tip, + * wait for every node to succeed, then return one node's reply. This keeps the + * reply type honest (it matches the single-node command type) at the cost of + * discarding the other nodes' replies. + */ +export const reduceFirstReply = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses[0]; +}; + +/** + * Per-command reducers for the `special` response policy, keyed by uppercased + * command identifier. A `special` response needs command-specific merging that + * no generic rule captures. Commands absent here hit `reduceSpecial`'s generic + * fallback. + */ +export const SPECIAL_RESPONSE_REDUCERS: Record> = { + RANDOMKEY: reduceRandomKey, + INFO: reduceFirstReply, + 'MEMORY DOCTOR': reduceFirstReply, + 'MEMORY MALLOC-STATS': reduceFirstReply, + 'MEMORY STATS': reduceFirstReply, + 'FUNCTION STATS': reduceFirstReply, + 'LATENCY DOCTOR': reduceFirstReply, + 'LATENCY GRAPH': reduceFirstReply, + 'LATENCY HISTOGRAM': reduceFirstReply, + 'LATENCY HISTORY': reduceFirstReply, + 'LATENCY LATEST': reduceFirstReply +}; + +/** + * Entry point for the `special` response policy: dispatch to a per-command + * reducer if one exists, else fall back to the default-keyless reduction (sole + * reply as is, or a merge of a fan-out) so the command works instead of + * throwing. Warn on the fallback because the merged shape is unlikely to be + * what the command really wants. + */ +export const reduceSpecial = async (promises: Promise[], parser: CommandParser): Promise => { + const reducer = SPECIAL_RESPONSE_REDUCERS[specialKey(parser)]; + if (reducer) return reducer(promises, parser) as Promise; + + if (promises.length > 1) { + console.warn( + `node-redis: no cluster aggregation implemented for the "special" response policy of ` + + `"${specialKey(parser)}"; merging replies from ${promises.length} nodes. The result shape may be wrong.` + ); + } + return reduceDefaultKeyless(promises); }; export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { From b790a82b9c2630a162356fc6e3d720b4848fa661 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 2 Jul 2026 13:53:29 +0300 Subject: [PATCH 28/54] feat(client): sticky FT.CURSOR routing via special request policy FT.CURSOR READ/DEL carry no key, so hash-slot routing can't reach the coordinator that minted the cursor via FT.AGGREGATE ...WITHCURSOR and the server rejects the unknown cursor. Flip ft.cursor to the HLD `special` request policy and add client-side sticky machinery: - cluster-slots: per-instance cursor binding registry ((index,cursorId) -> address) with bind/lookup/evict + opportunistic idle sweep, plus nodeAddressByClient reverse-lookup. - ft-cursor: routeFtCursor (pin bound node / throw MISS before any network call), SPECIAL_REQUEST_ROUTERS, extractCursorId, and the captureCursorBinding post-reply hook (AGGREGATE bind / READ rebind-refresh-evict / DEL evict). - dispatch: routeSpecial short-circuits into SPECIAL_REQUEST_ROUTERS, keeps the warn-and-random fallback for unregistered special commands. - _executeWithPolicies: best-effort capture hook after the reply resolves; _execute untouched. - static policies: override ft.cursor -> special (+ READ/DEL subcommands, response stays default-keyless), regenerated data table. Response stays single-node pass-through (no special reducer). Bindings are per client instance; cross-instance/cross-process cursors MISS by design. Unit tests cover router/capture/lifecycle/collision; cluster integration tests cover pagination-to-completion, DEL->READ MISS, and cross-instance MISS. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/cluster/cluster-slots.ts | 66 +++++++ packages/client/lib/cluster/index.ts | 18 +- .../request-response-policies/dispatch.ts | 17 +- .../ft-cursor.spec.ts | 163 ++++++++++++++++++ .../request-response-policies/ft-cursor.ts | 148 ++++++++++++++++ .../ft-policies.spec.ts | 30 +++- .../static-policies-data.ts | 16 +- .../scripts/static-policies-overrides.ts | 19 +- .../lib/commands/CURSOR_READ.cluster.spec.ts | 74 ++++++++ packages/search/lib/test-utils.ts | 12 ++ 10 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/ft-cursor.ts create mode 100644 packages/search/lib/commands/CURSOR_READ.cluster.spec.ts diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 1854b562022..26c015c5250 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -22,6 +22,26 @@ export type NodeAddressMap = { export const RESUBSCRIBE_LISTENERS_EVENT = '__resubscribeListeners' +/** + * Sticky-cursor binding: which node served a RediSearch cursor. FT.CURSOR + * READ/DEL carry no key, so hash-slot routing can't reach the coordinator that + * minted the cursor — we pin by `address` ("host:port"), the durable handle + * (clients are recreated on reconnect/topology refresh, addresses aren't). + */ +export interface CursorBinding { + address: string; + createdAt: number; + maxIdleMs?: number; +} + +/** + * Fallback idle TTL for the opportunistic cursor-binding sweep when the + * FT.AGGREGATE didn't declare MAXIDLE. Mirrors the RediSearch default (300s) + * so abandoned cursors don't leak the binding map (timer-free, like + * `smigratedSeqIdsSeen`). + */ +const DEFAULT_CURSOR_MAX_IDLE_MS = 300_000; + export interface Node< M extends RedisModules, F extends RedisFunctions, @@ -121,6 +141,8 @@ export default class RedisClusterSlots< pubSubNode?: PubSubNode; clientSideCache?: PooledClientSideCacheProvider; smigratedSeqIdsSeen = new Set; + /** Per-instance sticky-cursor bindings, keyed `${index}:${cursorId}`. */ + readonly cursorBindings = new Map(); #topologyRefreshPromise?: Promise; #isOpen = false; @@ -948,6 +970,50 @@ export default class RedisClusterSlots< return this.nodeClient(master); } + /** + * Reverse-resolve a routed client to its node address. FT.AGGREGATE is + * keyless, so the plan carries only the client; we need its address to bind + * the cursor. Clients are few per cluster, so the linear scan is negligible. + */ + nodeAddressByClient(client: RedisClientType): string | undefined { + for (const [address, node] of this.nodeByAddress) { + if (node.client === client) return address; + } + return undefined; + } + + #cursorKey(index: string, cursorId: number) { + return `${index}:${cursorId}`; + } + + /** + * Drop bindings idle past their MAXIDLE (or the default TTL). Opportunistic — + * runs on each `bindCursor` so there's no timer to manage (see + * `smigratedSeqIdsSeen`). Cheap: the map holds only live cursors. + */ + #sweepStaleCursors(now: number) { + for (const [key, binding] of this.cursorBindings) { + const ttl = binding.maxIdleMs ?? DEFAULT_CURSOR_MAX_IDLE_MS; + if (now - binding.createdAt > ttl) { + this.cursorBindings.delete(key); + } + } + } + + bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) { + const now = Date.now(); + this.#sweepStaleCursors(now); + this.cursorBindings.set(this.#cursorKey(index, cursorId), { address, createdAt: now, maxIdleMs }); + } + + lookupCursor(index: string, cursorId: number): CursorBinding | undefined { + return this.cursorBindings.get(this.#cursorKey(index, cursorId)); + } + + evictCursor(index: string, cursorId: number) { + this.cursorBindings.delete(this.#cursorKey(index, cursorId)); + } + getPubSubClient(): Promise> { this.#assertReady(); diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 6b13b381d8f..33b748649b5 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -18,6 +18,7 @@ import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/i import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { POLICIES, PolicyResolver, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandPolicies } from './request-response-policies'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; +import { captureCursorBinding } from './request-response-policies/ft-cursor'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -564,7 +565,22 @@ export default class RedisCluster< throw new Error(`Unknown response policy ${responsePolicy}`); } const positionHints = plan.map(entry => entry.groupIndices); - return reducer(responsePromises, parser, positionHints) as Promise; + const reply = await (reducer(responsePromises, parser, positionHints) as Promise); + + // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the + // serving node from the resolved reply. Command-name gated and best-effort + // (a bad binding only downgrades to a MISS throw on the next READ/DEL), so + // never let it mask the caller's reply. + try { + captureCursorBinding( + this._slots as unknown as Parameters[0], + parser, + plan, + reply + ); + } catch { /* binding capture is best-effort */ } + + return reply; } /** diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 6a04c3ed13c..a94c2e872fb 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -20,6 +20,7 @@ import { type RequestPolicyWithDefaults, type ResponsePolicyWithDefaults } from './policies-constants'; +import { SPECIAL_REQUEST_ROUTERS } from './ft-cursor'; // Routing runs *below* the typed command surface: routers never inspect the // command's M/F/S/RESP/TM parameters, they just shuffle opaque clients from @@ -121,15 +122,17 @@ function specialKey(parser: CommandParser): string { } /** - * Fallback router for `special` request commands without a dedicated handler. - * A `special` request policy means non-trivial routing that no generic rule - * captures; we don't know the correct target, so route to a single (random) - * node like a keyless command. This keeps the command working instead of - * throwing, but the reply reflects only that one node — warn so the gap is - * visible. Commands with a real handler never reach this. + * Router for the `special` request policy. Commands with a dedicated handler + * (e.g. FT.CURSOR sticky routing) short-circuit into `SPECIAL_REQUEST_ROUTERS` + * first. Everything else has non-trivial routing no generic rule captures and + * no handler yet: route to a single (random) node like a keyless command so it + * still works, but warn — the reply reflects only that one node. */ export const routeSpecial: RequestRouter = - async (slots, parser) => { + async (slots, parser, isReadonly, keySpecs) => { + const handler = SPECIAL_REQUEST_ROUTERS[specialKey(parser)]; + if (handler) return handler(slots, parser, isReadonly, keySpecs); + console.warn( `node-redis: no cluster routing implemented for the "special" request policy of ` + `"${specialKey(parser)}"; routing to a single node. The reply may be incomplete.` diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts new file mode 100644 index 00000000000..ce37e5879c6 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts @@ -0,0 +1,163 @@ +import { strict as assert } from 'node:assert'; +import type { CommandParser } from '../../client/parser'; +import { routeFtCursor, captureCursorBinding, extractCursorId } from './ft-cursor'; + +/** + * Minimal stand-in for the cursor-relevant surface of `RedisClusterSlots`, + * mirroring the real `${index}:${cursorId}` keying and address→client map so + * the router/capture logic is exercised without spinning a cluster. + */ +class FakeSlots { + cursorBindings = new Map(); + clientsByAddress = new Map(); + + #key(index: string, cursorId: number) { return `${index}:${cursorId}`; } + bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) { + this.cursorBindings.set(this.#key(index, cursorId), { address, createdAt: 0, maxIdleMs }); + } + lookupCursor(index: string, cursorId: number) { return this.cursorBindings.get(this.#key(index, cursorId)); } + evictCursor(index: string, cursorId: number) { this.cursorBindings.delete(this.#key(index, cursorId)); } + async getMasterByAddress(address: string) { return this.clientsByAddress.get(address); } + nodeAddressByClient(client: object) { + for (const [address, c] of this.clientsByAddress) if (c === client) return address; + return undefined; + } +} + +const parserOf = (...args: Array) => + ({ redisArgs: args, commandIdentifier: { command: args[0], subcommand: args[1] } }) as unknown as CommandParser; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- routers/capture run below the typed surface +const asSlots = (s: FakeSlots) => s as any; + +describe('extractCursorId', () => { + it('reads the transformed-path `{ cursor }` object (RESP2 + RESP3)', () => { + assert.equal(extractCursorId({ total: 1, results: [], cursor: 42 }), 42); + }); + + it('reads raw RESP2 `[result, cursor]` at index 1', () => { + assert.equal(extractCursorId([['result'], 7]), 7); + }); + + it('reads raw RESP3 map key `cursor`', () => { + assert.equal(extractCursorId(new Map([['results', []], ['cursor', 9]])), 9); + }); + + it('returns undefined when there is no cursor (e.g. FT.CURSOR DEL "OK")', () => { + assert.equal(extractCursorId('OK'), undefined); + assert.equal(extractCursorId(null), undefined); + }); +}); + +describe('routeFtCursor', () => { + it('pins the bound client on HIT', async () => { + const slots = new FakeSlots(); + const client = { id: 'node-a' }; + slots.clientsByAddress.set('127.0.0.1:7000', client); + slots.bindCursor('idx', 123, '127.0.0.1:7000'); + + const plan = await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '123'), undefined, undefined); + assert.deepEqual(plan, [{ client }]); + }); + + it('throws on MISS (cursor never bound)', async () => { + const slots = new FakeSlots(); + await assert.rejects( + routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '404'), undefined, undefined), + /no known node for cursor 404 on index "idx"/ + ); + }); + + it('throws when the bound node is gone (getMasterByAddress → undefined)', async () => { + const slots = new FakeSlots(); + slots.bindCursor('idx', 5, '127.0.0.1:9999'); // address not in clientsByAddress + await assert.rejects( + routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '5'), undefined, undefined), + /left the cluster/ + ); + }); +}); + +describe('captureCursorBinding — FT.AGGREGATE', () => { + it('binds (index, cursor) → serving node address (RESP2 array reply)', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], [[], 55]); + assert.deepEqual(slots.lookupCursor('idx', 55), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('binds from the transformed `{ cursor }` reply and captures MAXIDLE', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding( + asSlots(slots), + parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'MAXIDLE', '5000'), + [{ client } as any], + { total: 0, results: [], cursor: 88 } + ); + assert.deepEqual(slots.lookupCursor('idx', 88), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: 5000 }); + }); + + it('does not bind when the aggregate exhausts in one batch (cursor 0)', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], { cursor: 0 }); + assert.equal(slots.cursorBindings.size, 0); + }); +}); + +describe('captureCursorBinding — FT.CURSOR lifecycle', () => { + const seed = () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + slots.bindCursor('idx', 100, '10.0.0.1:6379'); + return { slots, client }; + }; + + it('rebinds a continuation cursor (evict old, bind new, same address)', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 200 }); + assert.equal(slots.lookupCursor('idx', 100), undefined); + assert.deepEqual(slots.lookupCursor('idx', 200), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('evicts on READ → cursor 0 (exhausted)', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 0 }); + assert.equal(slots.lookupCursor('idx', 100), undefined); + }); + + it('keeps the binding when the continuation id is unchanged', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 100 }); + assert.deepEqual(slots.lookupCursor('idx', 100), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('evicts on DEL regardless of reply, then a follow-up READ MISSes', async () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '100'), [{ client } as any], 'OK'); + assert.equal(slots.lookupCursor('idx', 100), undefined); + await assert.rejects(routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), undefined, undefined)); + }); +}); + +describe('cursor-id collision across indexes', () => { + it('keys on (index, cursorId) so same id under two indexes routes independently', async () => { + const slots = new FakeSlots(); + const clientA = { id: 'a' }, clientB = { id: 'b' }; + slots.clientsByAddress.set('a:1', clientA); + slots.clientsByAddress.set('b:1', clientB); + slots.bindCursor('idxA', 1, 'a:1'); + slots.bindCursor('idxB', 1, 'b:1'); + + assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxA', '1'), undefined, undefined), [{ client: clientA }]); + assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxB', '1'), undefined, undefined), [{ client: clientB }]); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts new file mode 100644 index 00000000000..4a46aa668a3 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -0,0 +1,148 @@ +import type { CommandParser } from '../../client/parser'; +import type { RedisArgument } from '../../RESP/types'; +import type { RequestRouter, RoutedCommand } from './dispatch'; + +// Routing/capture runs below the typed command surface (see dispatch.ts), so +// the slots handle is the erased base instantiation. `_executeWithPolicies` +// bridges its own typed slots in at the call boundary. +type ClusterSlots = Parameters[0]; + +/** RediSearch index names are case-sensitive raw wire strings; mirror them. */ +export function argToString(arg: RedisArgument): string { + return typeof arg === 'string' ? arg : arg.toString(); +} + +/** + * Pull the continuation cursor id out of an FT.AGGREGATE …WITHCURSOR / + * FT.CURSOR READ reply, across every reply path: + * - transformed command path → `{ total, results, cursor }` (RESP2 + RESP3), + * - raw RESP2 `sendCommand` → `[result, cursor]` (cursor at index 1), + * - raw RESP3 `sendCommand` → a map with a `cursor` key (Map or object). + * Returns `undefined` when no cursor field is present (e.g. FT.CURSOR DEL). + */ +export function extractCursorId(reply: unknown): number | undefined { + if (reply == null) return undefined; + + if (reply instanceof Map) { + return reply.has('cursor') ? toCursorNumber(reply.get('cursor')) : undefined; + } + + if (Array.isArray(reply)) { + return toCursorNumber(reply[1]); + } + + if (typeof reply === 'object' && 'cursor' in (reply as Record)) { + return toCursorNumber((reply as Record).cursor); + } + + return undefined; +} + +function toCursorNumber(value: unknown): number | undefined { + if (value == null) return undefined; + const n = Number(value); + return Number.isNaN(n) ? undefined : n; +} + +/** Read the numeric MAXIDLE (ms) an FT.AGGREGATE …WITHCURSOR declared, if any. */ +function maxIdleFromAggregateArgs(redisArgs: ReadonlyArray): number | undefined { + for (let i = 0; i < redisArgs.length - 1; i++) { + if (argToString(redisArgs[i]).toUpperCase() === 'MAXIDLE') { + const n = Number(argToString(redisArgs[i + 1])); + return Number.isNaN(n) ? undefined : n; + } + } + return undefined; +} + +/** + * Sticky router for FT.CURSOR READ/DEL (HLD `request_policy: special`). These + * are keyless — there's no slot to route by — so we pin the exact node that + * minted the cursor via its recorded binding. A MISS (never created here, + * already exhausted, or the bound node left the cluster) is unusable by this + * client, so throw before any network call rather than fan out or guess. + */ +export const routeFtCursor: RequestRouter = async (slots, parser) => { + const { redisArgs } = parser; + const index = argToString(redisArgs[2]); + const cursorId = Number(argToString(redisArgs[3])); + + const binding = slots.lookupCursor(index, cursorId); + if (binding) { + const client = await slots.getMasterByAddress(binding.address); + if (client) return [{ client }]; + } + + throw new Error( + `FT.CURSOR: no known node for cursor ${cursorId} on index "${index}". ` + + `The cursor was not created by this client instance, has already been ` + + `exhausted, or the node that served it has left the cluster.` + ); +}; + +/** Special-request routers, keyed like `SPECIAL_RESPONSE_REDUCERS` (see dispatch.ts). */ +export const SPECIAL_REQUEST_ROUTERS: Record = { + 'FT.CURSOR READ': routeFtCursor, + 'FT.CURSOR DEL': routeFtCursor +}; + +/** + * Command-name-gated hook run after an FT.AGGREGATE / FT.CURSOR reply resolves + * (HLD "hardcoded by command name"). Captures, rebinds, or evicts the sticky + * cursor binding using the single-target plan's serving node. No-op for any + * other command, and for multi-target plans (cursor commands are single-node). + */ +export function captureCursorBinding( + slots: ClusterSlots, + parser: CommandParser, + plan: ReadonlyArray, + reply: unknown +): void { + const { command, subcommand } = parser.commandIdentifier; + const cmd = command.toUpperCase(); + const sub = subcommand?.toUpperCase(); + + if (cmd !== 'FT.AGGREGATE' && cmd !== 'FT.CURSOR') return; + if (plan.length !== 1) return; + + const { redisArgs } = parser; + const client = plan[0].client; + + if (cmd === 'FT.AGGREGATE') { + const cursor = extractCursorId(reply); + // cursor 0 → exhausted in one batch, nothing to pin. + if (!cursor || !client) return; + const address = slots.nodeAddressByClient(client); + if (address) { + slots.bindCursor(argToString(redisArgs[1]), cursor, address, maxIdleFromAggregateArgs(redisArgs)); + } + return; + } + + // FT.CURSOR READ / DEL — index at arg 2, cursor id at arg 3. + const index = argToString(redisArgs[2]); + const cursorId = Number(argToString(redisArgs[3])); + + if (sub === 'DEL') { + // Self-cleaning: evict locally regardless of the server reply. + slots.evictCursor(index, cursorId); + return; + } + + if (sub === 'READ') { + // Reuse the node that served this READ (the binding we routed by, or a + // reverse-lookup of the pinned client) for any continuation cursor. + const address = slots.lookupCursor(index, cursorId)?.address + ?? (client ? slots.nodeAddressByClient(client) : undefined); + const next = extractCursorId(reply); + + if (next === 0 || next === undefined) { + slots.evictCursor(index, cursorId); // exhausted + } else if (next !== cursorId) { + slots.evictCursor(index, cursorId); // rebind continuation → same node + if (address) slots.bindCursor(index, next, address); + } else if (address) { + slots.bindCursor(index, cursorId, address); // unchanged → refresh createdAt + } + } +} diff --git a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts index f95b33adf5b..161f73f6f72 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -12,11 +12,10 @@ import { * declared"; the client routes by the default rules. They are stored here as * `default-keyless` / `default-keyed` to match the resolver vocabulary. * - * `ft.cursor` is mis-labeled `default-keyless` in this table on purpose. - * The HLD specifies `special` request_policy (sticky cursor), which requires - * the special-handler registry and cursor binding state. That work is tracked - * in its own story — once those land, the entry below flips to `special` and - * an extra cursor-routing test goes with it. + * `ft.cursor` carries the HLD `special` request_policy (sticky cursor): READ/DEL + * are routed to the node that served the FT.AGGREGATE that minted the cursor + * (see `ft-cursor.ts`). Its response stays `default(keyless)` — single-node + * pass-through. */ const KEYLESS = { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, @@ -30,7 +29,13 @@ const KEYED = { isKeyless: false } as const; -const HLD_FT_TABLE: Record = { +const SPECIAL_CURSOR = { + request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true +} as const; + +const HLD_FT_TABLE: Record = { 'FT.CREATE': KEYLESS, 'FT.SEARCH': KEYLESS, 'FT.AGGREGATE': KEYLESS, @@ -38,7 +43,7 @@ const HLD_FT_TABLE: Record = { 'FT.DICTDEL': KEYLESS, 'FT.DICTDUMP': KEYLESS, 'FT.SUGLEN': KEYED, - 'FT.CURSOR': KEYLESS, + 'FT.CURSOR': SPECIAL_CURSOR, 'FT.SUGADD': KEYED, 'FT.SUGGET': KEYED, 'FT.SUGDEL': KEYED, @@ -73,6 +78,17 @@ describe('FT.* policy table matches the HLD', () => { }); } + it('resolves FT.CURSOR READ/DEL subcommands to the special request policy', () => { + for (const subcommand of ['READ', 'DEL']) { + const result = resolver.resolvePolicy({ command: 'FT.CURSOR', subcommand }); + assert.equal(result.ok, true, `expected FT.CURSOR ${subcommand} to resolve`); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, `FT.CURSOR ${subcommand} request`); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, `FT.CURSOR ${subcommand} response`); + } + } + }); + it('does not expose dropped debug commands (e.g. FT._LIST)', () => { const result = resolver.resolvePolicy({ command: 'FT._LIST', subcommand: undefined }); assert.equal(result.ok, false); diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index d043c81449e..e3ecd8abf29 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -35,9 +35,21 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "cursor": { - "request": "default-keyless", + "request": "special", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "read": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + }, + "del": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + } + } }, "dictadd": { "request": "default-keyless", diff --git a/packages/client/scripts/static-policies-overrides.ts b/packages/client/scripts/static-policies-overrides.ts index 9c760870c1f..c0b3fd5252c 100644 --- a/packages/client/scripts/static-policies-overrides.ts +++ b/packages/client/scripts/static-policies-overrides.ts @@ -41,14 +41,23 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ /** * Full-entry replacements, keyed by `module.command`. * - * `ft.cursor` is pinned to plain default-keyless (its `special` request-policy - * subcommands stripped) until the special-handler registry and cursor binding - * state land — see the note in ft-policies.spec.ts. + * `ft.cursor` is pinned to the HLD `special` request policy (sticky cursor): + * FT.CURSOR READ/DEL must reach the node that served the FT.AGGREGATE that + * minted the cursor. The client-side sticky machinery lives in + * `lib/cluster/request-response-policies/ft-cursor.ts` (router + capture) and + * the cursor binding map on `cluster-slots.ts`. The response stays + * `default-keyless` (single-node pass-through). Pinned as an override so the + * static table is deterministic regardless of what a given server reports for + * the container command's subcommands. */ export const COMMAND_OVERRIDES: Readonly> = { 'ft.cursor': { - request: 'default-keyless', + request: 'special', response: 'default-keyless', - isKeyless: true + isKeyless: true, + subcommands: { + read: { request: 'special', response: 'default-keyless', isKeyless: true }, + del: { request: 'special', response: 'default-keyless', isKeyless: true } + } } }; diff --git a/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts new file mode 100644 index 00000000000..5c150931fcb --- /dev/null +++ b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; + +/** + * Cluster-mode sticky-cursor routing. FT.CURSOR READ/DEL carry no key, so + * without the client-side binding they'd hash-route to an arbitrary node and + * the server would reject the unknown cursor. These tests prove the binding: + * pagination completes against the coordinator that minted the cursor, DEL + * evicts it, and bindings are per client instance. + */ +describe('FT.CURSOR sticky routing (cluster)', () => { + const DOC_COUNT = 40; + + async function seedIndex(cluster: any) { + await cluster.ft.create('idx', { n: 'NUMERIC' }); + const writes = []; + for (let i = 0; i < DOC_COUNT; i++) { + writes.push(cluster.hSet(`doc:${i}`, { n: i })); + } + await Promise.all(writes); + } + + testUtils.testWithCluster('paginates a WITHCURSOR aggregate to completion (every page hits the same coordinator)', async cluster => { + await seedIndex(cluster); + + const first = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(first.cursor, 0, 'COUNT 5 over 40 docs should leave a live cursor'); + + let rows = first.results.length; + let cursor = first.cursor; + // Each READ is keyless: if routing weren't sticky it would land on a random + // node and throw "Cursor not found". Completing the loop proves stickiness. + while (cursor !== 0) { + const page = await cluster.ft.cursorRead('idx', cursor); + rows += page.results.length; + cursor = page.cursor; + } + + assert.equal(rows, DOC_COUNT, 'all pages assembled without an unknown-cursor error'); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('FT.CURSOR DEL evicts the binding, so a later READ throws a client-side MISS', async cluster => { + await seedIndex(cluster); + + const { cursor } = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(cursor, 0); + + await cluster.ft.cursorDel('idx', cursor); + await assert.rejects( + cluster.ft.cursorRead('idx', cursor), + /no known node for cursor/, + 'READ after DEL should MISS before any network call' + ); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('a cursor created on one client instance is unusable on another (per-instance bindings)', async cluster => { + await seedIndex(cluster); + + const { cursor } = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(cursor, 0); + + const other = cluster.duplicate(); + await other.connect(); + try { + await assert.rejects( + other.ft.cursorRead('idx', cursor), + /no known node for cursor/, + 'a second client has no binding for the first client\'s cursor' + ); + } finally { + other.destroy(); + } + }, GLOBAL.CLUSTERS.OPEN); +}); diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index bb64e1d20ff..b6da213fd61 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -33,5 +33,17 @@ export const GLOBAL = { } } } + }, + CLUSTERS: { + OPEN: { + numberOfMasters: 3, + serverArguments: [], + clusterConfiguration: { + RESP: 3 as const, + modules: { + ft: RediSearch + } + } + } } }; From 1782bf57092a899ef17a7a2d38f28e0f05cc3efc Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 15 Jul 2026 12:43:33 +0300 Subject: [PATCH 29/54] refactor(client): derive command routing/cache fields from server COMMAND metadata Several Command fields hardcode information the Redis server already reports via COMMAND, and the hand-maintained values had drifted across the hundreds of command definitions (writes marked read-only, read-only keyed commands never marked cacheable, zrange* cacheable but zrevrange* not, ...). Derive them instead from a generated metadata table sourced from the live COMMAND reply, keeping the hardcoded fields only as a fallback for user scripts/functions and for the handful of commands intentionally excluded from the table (no breaking change to the Command type). - Move the policy table out of cluster/ into a shared lib/command-metadata/ module (COMMAND_METADATA, StaticMetadataResolver, generator + overrides), now that it is consumed by CSC and routing alike. - Store the raw server signals (flags, tips) in the table instead of precomputed booleans, so the static table and a future dynamic live-COMMAND resolver feed the identical derivation. - Add isReplicaSafe / isCacheable predicates that own the derivation plus the resolve-then-fallback to Command.IS_READ_ONLY / Command.CACHEABLE: - replica-safety is the negation of the server `write` flag, matching the server's own read-only-replica gate in processCommand, NOT the broader `readonly` flag whose definition is not 1:1 with replica routing. - CSC eligibility follows the cross-client Command Eligibility algorithm: readonly flag, takes a key argument, no nondeterministic_output tip, no script/script_runner flag, no dont_cache tip. - Rewire the cluster, sentinel and CSC readers to the predicates; sentinel resolves once per command via a closure memo. - Add a narrow dont_cache override for TOUCH (the server still doesn't tag it); EVAL_RO/EVALSHA_RO/FCALL_RO are excluded by the server `script_runner` flag and TS.READ by its native `dont_cache` tip, so no overrides are needed there. Overrides shallow-merge onto the generated entry. - Deprecate the now-unused NOT_KEYED_COMMAND / IS_FORWARD_COMMAND type fields and remove the hardcoded IS_READ_ONLY / CACHEABLE / NOT_KEYED_COMMAND values from the built-in command definitions in client, bloom, json, search and time-series (259 files). Commands excluded from the table (FT._LIST, FT.CONFIG, FT.HYBRID) keep their hardcoded values as the fallback. The full hardcoded-vs-derived behavior delta was audited against Redis 8.10 (55 replica-safety changes, 90 cacheability changes, incl. the EVAL/EVALSHA/FCALL replica-routing change accepted by keeping the write-flag rule pure). Metadata regenerated against Redis 8.10 (dev build) with all bundled modules loaded. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/bloom/lib/commands/bloom/ADD.ts | 1 - packages/bloom/lib/commands/bloom/CARD.ts | 1 - packages/bloom/lib/commands/bloom/EXISTS.ts | 1 - packages/bloom/lib/commands/bloom/INFO.ts | 1 - packages/bloom/lib/commands/bloom/INSERT.ts | 1 - .../bloom/lib/commands/bloom/LOADCHUNK.ts | 1 - packages/bloom/lib/commands/bloom/MADD.ts | 1 - packages/bloom/lib/commands/bloom/MEXISTS.ts | 1 - packages/bloom/lib/commands/bloom/RESERVE.ts | 1 - packages/bloom/lib/commands/bloom/SCANDUMP.ts | 1 - .../lib/commands/count-min-sketch/INCRBY.ts | 1 - .../lib/commands/count-min-sketch/INFO.ts | 1 - .../commands/count-min-sketch/INITBYDIM.ts | 1 - .../commands/count-min-sketch/INITBYPROB.ts | 1 - .../lib/commands/count-min-sketch/MERGE.ts | 1 - .../lib/commands/count-min-sketch/QUERY.ts | 1 - packages/bloom/lib/commands/cuckoo/ADD.ts | 1 - packages/bloom/lib/commands/cuckoo/ADDNX.ts | 1 - packages/bloom/lib/commands/cuckoo/COUNT.ts | 1 - packages/bloom/lib/commands/cuckoo/DEL.ts | 1 - packages/bloom/lib/commands/cuckoo/EXISTS.ts | 1 - packages/bloom/lib/commands/cuckoo/INFO.ts | 1 - packages/bloom/lib/commands/cuckoo/INSERT.ts | 1 - .../bloom/lib/commands/cuckoo/INSERTNX.ts | 3 +- .../bloom/lib/commands/cuckoo/LOADCHUNK.ts | 1 - packages/bloom/lib/commands/cuckoo/RESERVE.ts | 1 - .../bloom/lib/commands/cuckoo/SCANDUMP.ts | 1 - packages/bloom/lib/commands/t-digest/ADD.ts | 1 - .../bloom/lib/commands/t-digest/BYRANK.ts | 1 - .../bloom/lib/commands/t-digest/BYREVRANK.ts | 1 - packages/bloom/lib/commands/t-digest/CDF.ts | 1 - .../bloom/lib/commands/t-digest/CREATE.ts | 1 - packages/bloom/lib/commands/t-digest/INFO.ts | 1 - packages/bloom/lib/commands/t-digest/MAX.ts | 1 - packages/bloom/lib/commands/t-digest/MERGE.ts | 1 - packages/bloom/lib/commands/t-digest/MIN.ts | 1 - .../bloom/lib/commands/t-digest/QUANTILE.ts | 1 - packages/bloom/lib/commands/t-digest/RANK.ts | 1 - packages/bloom/lib/commands/t-digest/RESET.ts | 1 - .../bloom/lib/commands/t-digest/REVRANK.ts | 1 - .../lib/commands/t-digest/TRIMMED_MEAN.ts | 1 - packages/bloom/lib/commands/top-k/ADD.ts | 1 - packages/bloom/lib/commands/top-k/COUNT.ts | 1 - packages/bloom/lib/commands/top-k/INCRBY.ts | 1 - packages/bloom/lib/commands/top-k/INFO.ts | 1 - packages/bloom/lib/commands/top-k/LIST.ts | 1 - .../lib/commands/top-k/LIST_WITHCOUNT.ts | 1 - packages/bloom/lib/commands/top-k/QUERY.ts | 1 - packages/bloom/lib/commands/top-k/RESERVE.ts | 1 - packages/client/lib/RESP/types.ts | 12 +- packages/client/lib/client/index.ts | 8 +- packages/client/lib/cluster/index.ts | 18 +- .../request-response-policies/dispatch.ts | 2 +- .../ft-policies.spec.ts | 6 +- .../request-response-policies/index.ts | 11 +- .../static-policies-data.ts | 2928 --------- .../command-metadata/command-metadata-data.ts | 5691 +++++++++++++++++ .../dynamic-policy-resolver-factory.ts | 42 +- .../dynamic-policy-resolver.spec.ts | 30 +- packages/client/lib/command-metadata/index.ts | 20 + .../policies-constants.ts | 23 +- .../client/lib/command-metadata/predicates.ts | 66 + .../resolve-then-fallback.spec.ts | 59 + .../static-metadata-resolver.spec.ts} | 8 +- .../static-metadata-resolver.ts} | 55 +- .../types.ts | 17 +- packages/client/lib/commands/ACL_CAT.ts | 2 - packages/client/lib/commands/ACL_DELUSER.ts | 2 - packages/client/lib/commands/ACL_DRYRUN.ts | 2 - packages/client/lib/commands/ACL_GENPASS.ts | 2 - packages/client/lib/commands/ACL_GETUSER.ts | 2 - packages/client/lib/commands/ACL_LIST.ts | 2 - packages/client/lib/commands/ACL_LOAD.ts | 2 - packages/client/lib/commands/ACL_LOG.ts | 2 - packages/client/lib/commands/ACL_LOG_RESET.ts | 3 - packages/client/lib/commands/ACL_SAVE.ts | 2 - packages/client/lib/commands/ACL_SETUSER.ts | 2 - packages/client/lib/commands/ACL_USERS.ts | 2 - packages/client/lib/commands/ACL_WHOAMI.ts | 2 - packages/client/lib/commands/APPEND.ts | 1 - packages/client/lib/commands/ARCOUNT.ts | 1 - packages/client/lib/commands/ARGET.ts | 1 - packages/client/lib/commands/ARGETRANGE.ts | 1 - packages/client/lib/commands/ARGREP.ts | 1 - .../client/lib/commands/ARGREP_WITHVALUES.ts | 1 - packages/client/lib/commands/ARINFO.ts | 1 - packages/client/lib/commands/ARLASTITEMS.ts | 1 - packages/client/lib/commands/ARLEN.ts | 1 - packages/client/lib/commands/ARMGET.ts | 1 - packages/client/lib/commands/ARNEXT.ts | 1 - packages/client/lib/commands/AROP.ts | 1 - packages/client/lib/commands/ARSCAN.ts | 1 - packages/client/lib/commands/ASKING.ts | 2 - packages/client/lib/commands/AUTH.ts | 2 - packages/client/lib/commands/BGREWRITEAOF.ts | 2 - packages/client/lib/commands/BGSAVE.ts | 2 - packages/client/lib/commands/BITCOUNT.ts | 2 - packages/client/lib/commands/BITFIELD.ts | 1 - packages/client/lib/commands/BITFIELD_RO.ts | 2 - packages/client/lib/commands/BITOP.ts | 1 - packages/client/lib/commands/BITPOS.ts | 2 - packages/client/lib/commands/BLMOVE.ts | 1 - packages/client/lib/commands/BLMPOP.ts | 1 - packages/client/lib/commands/BLPOP.ts | 1 - packages/client/lib/commands/BRPOP.ts | 1 - packages/client/lib/commands/BRPOPLPUSH.ts | 1 - packages/client/lib/commands/BZMPOP.ts | 1 - packages/client/lib/commands/BZPOPMAX.ts | 1 - packages/client/lib/commands/BZPOPMIN.ts | 1 - .../client/lib/commands/CLIENT_CACHING.ts | 2 - .../client/lib/commands/CLIENT_GETNAME.ts | 2 - .../client/lib/commands/CLIENT_GETREDIR.ts | 2 - packages/client/lib/commands/CLIENT_ID.ts | 2 - packages/client/lib/commands/CLIENT_INFO.ts | 2 - packages/client/lib/commands/CLIENT_KILL.ts | 2 - packages/client/lib/commands/CLIENT_LIST.ts | 2 - .../client/lib/commands/CLIENT_NO-EVICT.ts | 2 - .../client/lib/commands/CLIENT_NO-TOUCH.ts | 2 - packages/client/lib/commands/CLIENT_PAUSE.ts | 2 - .../client/lib/commands/CLIENT_SETNAME.ts | 2 - .../client/lib/commands/CLIENT_TRACKING.ts | 2 - .../lib/commands/CLIENT_TRACKINGINFO.ts | 2 - .../client/lib/commands/CLIENT_UNBLOCK.ts | 2 - .../client/lib/commands/CLIENT_UNPAUSE.ts | 2 - .../client/lib/commands/CLUSTER_ADDSLOTS.ts | 2 - .../lib/commands/CLUSTER_ADDSLOTSRANGE.ts | 2 - .../client/lib/commands/CLUSTER_BUMPEPOCH.ts | 2 - .../commands/CLUSTER_COUNT-FAILURE-REPORTS.ts | 2 - .../lib/commands/CLUSTER_COUNTKEYSINSLOT.ts | 2 - .../client/lib/commands/CLUSTER_DELSLOTS.ts | 2 - .../lib/commands/CLUSTER_DELSLOTSRANGE.ts | 2 - .../client/lib/commands/CLUSTER_FAILOVER.ts | 2 - .../client/lib/commands/CLUSTER_FLUSHSLOTS.ts | 2 - .../client/lib/commands/CLUSTER_FORGET.ts | 2 - .../lib/commands/CLUSTER_GETKEYSINSLOT.ts | 2 - packages/client/lib/commands/CLUSTER_INFO.ts | 2 - .../client/lib/commands/CLUSTER_KEYSLOT.ts | 6 +- packages/client/lib/commands/CLUSTER_LINKS.ts | 2 - packages/client/lib/commands/CLUSTER_MEET.ts | 2 - packages/client/lib/commands/CLUSTER_MYID.ts | 2 - .../client/lib/commands/CLUSTER_MYSHARDID.ts | 2 - packages/client/lib/commands/CLUSTER_NODES.ts | 2 - .../client/lib/commands/CLUSTER_REPLICAS.ts | 2 - .../client/lib/commands/CLUSTER_REPLICATE.ts | 2 - packages/client/lib/commands/CLUSTER_RESET.ts | 2 - .../client/lib/commands/CLUSTER_SAVECONFIG.ts | 2 - .../lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts | 2 - .../client/lib/commands/CLUSTER_SETSLOT.ts | 2 - packages/client/lib/commands/CLUSTER_SLOTS.ts | 2 - packages/client/lib/commands/COMMAND.spec.ts | 37 +- packages/client/lib/commands/COMMAND.ts | 2 - packages/client/lib/commands/COMMAND_COUNT.ts | 2 - .../client/lib/commands/COMMAND_GETKEYS.ts | 2 - .../lib/commands/COMMAND_GETKEYSANDFLAGS.ts | 2 - packages/client/lib/commands/COMMAND_INFO.ts | 2 - packages/client/lib/commands/COMMAND_LIST.ts | 2 - packages/client/lib/commands/CONFIG_GET.ts | 2 - .../client/lib/commands/CONFIG_RESETSTAT.ts | 2 - .../client/lib/commands/CONFIG_REWRITE.ts | 2 - packages/client/lib/commands/CONFIG_SET.ts | 2 - packages/client/lib/commands/COPY.ts | 1 - packages/client/lib/commands/DBSIZE.ts | 2 - packages/client/lib/commands/DEL.ts | 1 - packages/client/lib/commands/DELEX.ts | 1 - packages/client/lib/commands/DIGEST.ts | 1 - packages/client/lib/commands/DUMP.ts | 1 - packages/client/lib/commands/ECHO.ts | 2 - packages/client/lib/commands/EVAL.ts | 1 - packages/client/lib/commands/EVALSHA.ts | 1 - packages/client/lib/commands/EVALSHA_RO.ts | 1 - packages/client/lib/commands/EVAL_RO.ts | 1 - packages/client/lib/commands/EXISTS.ts | 2 - packages/client/lib/commands/EXPIRETIME.ts | 1 - packages/client/lib/commands/FCALL.ts | 1 - packages/client/lib/commands/FCALL_RO.ts | 1 - packages/client/lib/commands/FLUSHALL.ts | 2 - packages/client/lib/commands/FLUSHDB.ts | 2 - .../client/lib/commands/FUNCTION_DELETE.ts | 2 - packages/client/lib/commands/FUNCTION_DUMP.ts | 2 - .../client/lib/commands/FUNCTION_FLUSH.ts | 2 - packages/client/lib/commands/FUNCTION_KILL.ts | 2 - packages/client/lib/commands/FUNCTION_LIST.ts | 2 - .../lib/commands/FUNCTION_LIST_WITHCODE.ts | 2 - packages/client/lib/commands/FUNCTION_LOAD.ts | 2 - .../client/lib/commands/FUNCTION_RESTORE.ts | 2 - .../client/lib/commands/FUNCTION_STATS.ts | 2 - packages/client/lib/commands/GEOADD.ts | 1 - packages/client/lib/commands/GEODIST.ts | 2 - packages/client/lib/commands/GEOHASH.ts | 2 - packages/client/lib/commands/GEOPOS.ts | 2 - packages/client/lib/commands/GEORADIUS.ts | 1 - .../client/lib/commands/GEORADIUSBYMEMBER.ts | 1 - .../lib/commands/GEORADIUSBYMEMBER_RO.ts | 2 - .../lib/commands/GEORADIUSBYMEMBER_RO_WITH.ts | 2 - .../lib/commands/GEORADIUSBYMEMBER_STORE.ts | 3 +- .../lib/commands/GEORADIUSBYMEMBER_WITH.ts | 2 - packages/client/lib/commands/GEORADIUS_RO.ts | 2 - .../client/lib/commands/GEORADIUS_RO_WITH.ts | 2 - .../client/lib/commands/GEORADIUS_STORE.ts | 3 +- .../client/lib/commands/GEORADIUS_WITH.ts | 3 +- packages/client/lib/commands/GEOSEARCH.ts | 1 - .../client/lib/commands/GEOSEARCHSTORE.ts | 1 - .../client/lib/commands/GEOSEARCH_WITH.ts | 1 - packages/client/lib/commands/GET.ts | 2 - packages/client/lib/commands/GETBIT.ts | 2 - packages/client/lib/commands/GETDEL.ts | 1 - packages/client/lib/commands/GETEX.ts | 1 - packages/client/lib/commands/GETRANGE.ts | 2 - packages/client/lib/commands/GETSET.ts | 1 - packages/client/lib/commands/HEXISTS.ts | 2 - packages/client/lib/commands/HEXPIRETIME.ts | 1 - packages/client/lib/commands/HGET.ts | 2 - packages/client/lib/commands/HGETALL.ts | 2 - packages/client/lib/commands/HKEYS.ts | 2 - packages/client/lib/commands/HLEN.ts | 2 - packages/client/lib/commands/HMGET.ts | 2 - packages/client/lib/commands/HOTKEYS_GET.ts | 2 - packages/client/lib/commands/HOTKEYS_RESET.ts | 2 - packages/client/lib/commands/HOTKEYS_START.ts | 2 - packages/client/lib/commands/HOTKEYS_STOP.ts | 2 - packages/client/lib/commands/HPEXPIREAT.ts | 1 - packages/client/lib/commands/HPEXPIRETIME.ts | 1 - packages/client/lib/commands/HPTTL.ts | 1 - packages/client/lib/commands/HRANDFIELD.ts | 1 - .../client/lib/commands/HRANDFIELD_COUNT.ts | 1 - .../commands/HRANDFIELD_COUNT_WITHVALUES.ts | 1 - packages/client/lib/commands/HSCAN.ts | 1 - .../client/lib/commands/HSCAN_NOVALUES.ts | 1 - packages/client/lib/commands/HSETNX.ts | 1 - packages/client/lib/commands/HSTRLEN.ts | 2 - packages/client/lib/commands/HTTL.ts | 1 - packages/client/lib/commands/HVALS.ts | 2 - packages/client/lib/commands/INFO.ts | 2 - packages/client/lib/commands/KEYS.ts | 2 - packages/client/lib/commands/LASTSAVE.ts | 2 - .../client/lib/commands/LATENCY_DOCTOR.ts | 2 - packages/client/lib/commands/LATENCY_GRAPH.ts | 2 - .../client/lib/commands/LATENCY_HISTOGRAM.ts | 2 - .../client/lib/commands/LATENCY_HISTORY.ts | 2 - .../client/lib/commands/LATENCY_LATEST.ts | 2 - packages/client/lib/commands/LATENCY_RESET.ts | 2 - packages/client/lib/commands/LCS.ts | 1 - packages/client/lib/commands/LCS_IDX.ts | 1 - .../lib/commands/LCS_IDX_WITHMATCHLEN.ts | 1 - packages/client/lib/commands/LCS_LEN.ts | 1 - packages/client/lib/commands/LINDEX.ts | 2 - packages/client/lib/commands/LINSERT.ts | 1 - packages/client/lib/commands/LLEN.ts | 2 - packages/client/lib/commands/LMOVE.ts | 1 - packages/client/lib/commands/LMPOP.ts | 1 - packages/client/lib/commands/LOLWUT.ts | 2 - packages/client/lib/commands/LPOP_COUNT.ts | 1 - packages/client/lib/commands/LPOS.ts | 2 - packages/client/lib/commands/LPOS_COUNT.ts | 2 - packages/client/lib/commands/LRANGE.ts | 2 - packages/client/lib/commands/LREM.ts | 1 - packages/client/lib/commands/LSET.ts | 1 - packages/client/lib/commands/MEMORY_DOCTOR.ts | 2 - .../lib/commands/MEMORY_MALLOC-STATS.ts | 2 - packages/client/lib/commands/MEMORY_PURGE.ts | 2 - packages/client/lib/commands/MEMORY_STATS.ts | 2 - packages/client/lib/commands/MEMORY_USAGE.ts | 1 - packages/client/lib/commands/MGET.ts | 2 - packages/client/lib/commands/MIGRATE.ts | 1 - packages/client/lib/commands/MODULE_LIST.ts | 2 - packages/client/lib/commands/MODULE_LOAD.ts | 2 - packages/client/lib/commands/MODULE_UNLOAD.ts | 2 - packages/client/lib/commands/MSET.ts | 1 - packages/client/lib/commands/MSETNX.ts | 1 - .../client/lib/commands/OBJECT_ENCODING.ts | 1 - packages/client/lib/commands/OBJECT_FREQ.ts | 1 - .../client/lib/commands/OBJECT_IDLETIME.ts | 1 - .../client/lib/commands/OBJECT_REFCOUNT.ts | 1 - packages/client/lib/commands/PEXPIRE.ts | 1 - packages/client/lib/commands/PEXPIREAT.ts | 1 - packages/client/lib/commands/PEXPIRETIME.ts | 1 - packages/client/lib/commands/PFADD.ts | 1 - packages/client/lib/commands/PFCOUNT.ts | 1 - packages/client/lib/commands/PING.ts | 2 - packages/client/lib/commands/PTTL.ts | 1 - packages/client/lib/commands/PUBLISH.ts | 3 - .../client/lib/commands/PUBSUB_CHANNELS.ts | 2 - packages/client/lib/commands/PUBSUB_NUMPAT.ts | 2 - packages/client/lib/commands/PUBSUB_NUMSUB.ts | 2 - .../lib/commands/PUBSUB_SHARDCHANNELS.ts | 2 - .../client/lib/commands/PUBSUB_SHARDNUMSUB.ts | 1 - packages/client/lib/commands/RANDOMKEY.ts | 2 - packages/client/lib/commands/READONLY.ts | 2 - packages/client/lib/commands/READWRITE.ts | 2 - packages/client/lib/commands/RENAME.ts | 1 - packages/client/lib/commands/RENAMENX.ts | 1 - packages/client/lib/commands/REPLICAOF.ts | 2 - .../client/lib/commands/RESTORE-ASKING.ts | 2 - packages/client/lib/commands/RESTORE.ts | 1 - packages/client/lib/commands/ROLE.ts | 2 - packages/client/lib/commands/SAVE.ts | 2 - packages/client/lib/commands/SCAN.ts | 2 - packages/client/lib/commands/SCARD.ts | 2 - packages/client/lib/commands/SCRIPT_DEBUG.ts | 2 - packages/client/lib/commands/SCRIPT_EXISTS.ts | 2 - packages/client/lib/commands/SCRIPT_FLUSH.ts | 2 - packages/client/lib/commands/SCRIPT_KILL.ts | 2 - packages/client/lib/commands/SCRIPT_LOAD.ts | 2 - packages/client/lib/commands/SDIFF.ts | 2 - packages/client/lib/commands/SETBIT.ts | 1 - packages/client/lib/commands/SHUTDOWN.ts | 2 - packages/client/lib/commands/SINTER.ts | 2 - packages/client/lib/commands/SINTERCARD.ts | 1 - packages/client/lib/commands/SINTERSTORE.ts | 1 - packages/client/lib/commands/SISMEMBER.ts | 2 - packages/client/lib/commands/SMEMBERS.ts | 2 - packages/client/lib/commands/SMISMEMBER.ts | 2 - packages/client/lib/commands/SMOVE.ts | 1 - packages/client/lib/commands/SORT.ts | 1 - packages/client/lib/commands/SORT_RO.ts | 1 - packages/client/lib/commands/SORT_STORE.ts | 1 - packages/client/lib/commands/SPOP.ts | 1 - packages/client/lib/commands/SPOP_COUNT.ts | 1 - packages/client/lib/commands/SPUBLISH.ts | 1 - packages/client/lib/commands/SRANDMEMBER.ts | 1 - .../client/lib/commands/SRANDMEMBER_COUNT.ts | 1 - packages/client/lib/commands/SREM.ts | 1 - packages/client/lib/commands/SSCAN.ts | 1 - packages/client/lib/commands/STRLEN.ts | 2 - packages/client/lib/commands/SUNION.ts | 2 - packages/client/lib/commands/SUNIONSTORE.ts | 1 - packages/client/lib/commands/SWAPDB.ts | 2 - packages/client/lib/commands/TIME.ts | 2 - packages/client/lib/commands/TOUCH.ts | 1 - packages/client/lib/commands/TTL.ts | 1 - packages/client/lib/commands/TYPE.ts | 2 - packages/client/lib/commands/UNLINK.ts | 1 - packages/client/lib/commands/VCARD.ts | 1 - packages/client/lib/commands/VDIM.ts | 1 - packages/client/lib/commands/VEMB.ts | 1 - packages/client/lib/commands/VEMB_RAW.ts | 1 - packages/client/lib/commands/VGETATTR.ts | 1 - packages/client/lib/commands/VINFO.ts | 1 - packages/client/lib/commands/VLINKS.ts | 1 - .../client/lib/commands/VLINKS_WITHSCORES.ts | 1 - packages/client/lib/commands/VRANDMEMBER.ts | 1 - packages/client/lib/commands/VRANGE.ts | 1 - packages/client/lib/commands/VSIM.ts | 1 - .../client/lib/commands/VSIM_WITHSCORES.ts | 1 - packages/client/lib/commands/WAIT.ts | 2 - packages/client/lib/commands/XACK.ts | 1 - packages/client/lib/commands/XACKDEL.ts | 1 - packages/client/lib/commands/XADD.ts | 1 - .../client/lib/commands/XADD_NOMKSTREAM.ts | 1 - packages/client/lib/commands/XAUTOCLAIM.ts | 1 - .../client/lib/commands/XAUTOCLAIM_JUSTID.ts | 1 - packages/client/lib/commands/XCFGSET.ts | 1 - packages/client/lib/commands/XCLAIM.ts | 1 - packages/client/lib/commands/XCLAIM_JUSTID.ts | 1 - packages/client/lib/commands/XDEL.ts | 1 - packages/client/lib/commands/XDELEX.ts | 1 - packages/client/lib/commands/XGROUP_CREATE.ts | 1 - .../lib/commands/XGROUP_CREATECONSUMER.ts | 1 - .../client/lib/commands/XGROUP_DELCONSUMER.ts | 1 - .../client/lib/commands/XGROUP_DESTROY.ts | 1 - packages/client/lib/commands/XGROUP_SETID.ts | 1 - .../client/lib/commands/XINFO_CONSUMERS.ts | 1 - packages/client/lib/commands/XINFO_GROUPS.ts | 1 - packages/client/lib/commands/XINFO_STREAM.ts | 1 - packages/client/lib/commands/XLEN.ts | 2 - packages/client/lib/commands/XNACK.ts | 1 - packages/client/lib/commands/XPENDING.ts | 2 - .../client/lib/commands/XPENDING_RANGE.ts | 2 - packages/client/lib/commands/XRANGE.ts | 2 - packages/client/lib/commands/XREAD.ts | 1 - packages/client/lib/commands/XREADGROUP.ts | 1 - packages/client/lib/commands/XREVRANGE.ts | 2 - packages/client/lib/commands/XSETID.ts | 1 - packages/client/lib/commands/XTRIM.ts | 1 - packages/client/lib/commands/ZCARD.ts | 2 - packages/client/lib/commands/ZCOUNT.ts | 2 - packages/client/lib/commands/ZDIFF.ts | 1 - packages/client/lib/commands/ZDIFFSTORE.ts | 1 - .../client/lib/commands/ZDIFF_WITHSCORES.ts | 1 - packages/client/lib/commands/ZINTER.ts | 1 - packages/client/lib/commands/ZINTERCARD.ts | 1 - packages/client/lib/commands/ZINTERSTORE.ts | 1 - .../client/lib/commands/ZINTER_WITHSCORES.ts | 1 - packages/client/lib/commands/ZLEXCOUNT.ts | 2 - packages/client/lib/commands/ZMPOP.ts | 1 - packages/client/lib/commands/ZMSCORE.ts | 2 - packages/client/lib/commands/ZPOPMAX.ts | 1 - packages/client/lib/commands/ZPOPMAX_COUNT.ts | 1 - packages/client/lib/commands/ZPOPMIN.ts | 1 - packages/client/lib/commands/ZPOPMIN_COUNT.ts | 1 - packages/client/lib/commands/ZRANDMEMBER.ts | 1 - .../client/lib/commands/ZRANDMEMBER_COUNT.ts | 1 - .../commands/ZRANDMEMBER_COUNT_WITHSCORES.ts | 1 - packages/client/lib/commands/ZRANGE.ts | 2 - packages/client/lib/commands/ZRANGEBYLEX.ts | 2 - packages/client/lib/commands/ZRANGEBYSCORE.ts | 2 - .../lib/commands/ZRANGEBYSCORE_WITHSCORES.ts | 2 - packages/client/lib/commands/ZRANGESTORE.ts | 1 - .../client/lib/commands/ZRANGE_WITHSCORES.ts | 2 - packages/client/lib/commands/ZRANK.ts | 2 - .../client/lib/commands/ZRANK_WITHSCORE.ts | 2 - packages/client/lib/commands/ZREM.ts | 1 - .../client/lib/commands/ZREMRANGEBYLEX.ts | 1 - .../client/lib/commands/ZREMRANGEBYRANK.ts | 1 - .../client/lib/commands/ZREMRANGEBYSCORE.ts | 1 - packages/client/lib/commands/ZREVRANK.ts | 2 - .../client/lib/commands/ZREVRANK_WITHSCORE.ts | 2 - packages/client/lib/commands/ZSCAN.ts | 1 - packages/client/lib/commands/ZSCORE.ts | 2 - packages/client/lib/commands/ZUNION.ts | 1 - packages/client/lib/commands/ZUNIONSTORE.ts | 1 - .../client/lib/commands/ZUNION_WITHSCORES.ts | 1 - .../lib/commands/generic-transformers.spec.ts | 31 +- .../lib/commands/generic-transformers.ts | 28 +- packages/client/lib/sentinel/utils.ts | 13 +- packages/client/package.json | 2 +- ...rides.ts => command-metadata-overrides.ts} | 20 +- ...a.ts => generate-command-metadata-data.ts} | 52 +- packages/json/lib/commands/ARRAPPEND.ts | 1 - packages/json/lib/commands/ARRINDEX.ts | 1 - packages/json/lib/commands/ARRINSERT.ts | 1 - packages/json/lib/commands/ARRLEN.ts | 1 - packages/json/lib/commands/ARRPOP.ts | 1 - packages/json/lib/commands/ARRTRIM.ts | 1 - packages/json/lib/commands/CLEAR.ts | 1 - packages/json/lib/commands/DEBUG_MEMORY.ts | 1 - packages/json/lib/commands/DEL.ts | 1 - packages/json/lib/commands/FORGET.ts | 1 - packages/json/lib/commands/GET.ts | 1 - packages/json/lib/commands/MERGE.ts | 1 - packages/json/lib/commands/MGET.ts | 1 - packages/json/lib/commands/MSET.ts | 1 - packages/json/lib/commands/NUMINCRBY.ts | 1 - packages/json/lib/commands/NUMMULTBY.ts | 1 - packages/json/lib/commands/OBJKEYS.ts | 1 - packages/json/lib/commands/OBJLEN.ts | 1 - packages/json/lib/commands/RESP.ts | 1 - packages/json/lib/commands/SET.ts | 1 - packages/json/lib/commands/STRAPPEND.ts | 1 - packages/json/lib/commands/STRLEN.ts | 1 - packages/json/lib/commands/TOGGLE.ts | 1 - packages/json/lib/commands/TYPE.ts | 1 - packages/search/lib/commands/AGGREGATE.ts | 2 - .../lib/commands/AGGREGATE_WITHCURSOR.ts | 1 - packages/search/lib/commands/ALIASADD.ts | 2 - packages/search/lib/commands/ALIASDEL.ts | 2 - packages/search/lib/commands/ALIASUPDATE.ts | 2 - packages/search/lib/commands/ALTER.ts | 2 - packages/search/lib/commands/CREATE.ts | 2 - packages/search/lib/commands/CURSOR_DEL.ts | 2 - packages/search/lib/commands/CURSOR_READ.ts | 2 - packages/search/lib/commands/DICTADD.ts | 2 - packages/search/lib/commands/DICTDEL.ts | 2 - packages/search/lib/commands/DICTDUMP.ts | 2 - packages/search/lib/commands/DROPINDEX.ts | 2 - packages/search/lib/commands/EXPLAIN.ts | 2 - packages/search/lib/commands/EXPLAINCLI.ts | 2 - packages/search/lib/commands/INFO.ts | 2 - .../search/lib/commands/PROFILE_AGGREGATE.ts | 2 - .../search/lib/commands/PROFILE_SEARCH.ts | 2 - packages/search/lib/commands/SEARCH.ts | 2 - .../search/lib/commands/SEARCH_NOCONTENT.ts | 2 - packages/search/lib/commands/SPELLCHECK.ts | 2 - packages/search/lib/commands/SUGADD.ts | 1 - packages/search/lib/commands/SUGDEL.ts | 1 - packages/search/lib/commands/SUGGET.ts | 1 - .../lib/commands/SUGGET_WITHPAYLOADS.ts | 1 - .../search/lib/commands/SUGGET_WITHSCORES.ts | 1 - .../SUGGET_WITHSCORES_WITHPAYLOADS.ts | 1 - packages/search/lib/commands/SUGLEN.ts | 1 - packages/search/lib/commands/SYNDUMP.ts | 2 - packages/search/lib/commands/SYNUPDATE.ts | 2 - packages/search/lib/commands/TAGVALS.ts | 2 - packages/time-series/lib/commands/ADD.ts | 1 - packages/time-series/lib/commands/ALTER.ts | 1 - packages/time-series/lib/commands/CREATE.ts | 1 - .../time-series/lib/commands/CREATERULE.ts | 1 - packages/time-series/lib/commands/DECRBY.ts | 1 - packages/time-series/lib/commands/DEL.ts | 1 - .../time-series/lib/commands/DELETERULE.ts | 1 - packages/time-series/lib/commands/GET.ts | 1 - packages/time-series/lib/commands/INCRBY.ts | 1 - packages/time-series/lib/commands/INFO.ts | 1 - .../time-series/lib/commands/INFO_DEBUG.ts | 1 - packages/time-series/lib/commands/MADD.ts | 1 - packages/time-series/lib/commands/MGET.ts | 2 - .../lib/commands/MGET_SELECTED_LABELS.ts | 1 - .../lib/commands/MGET_WITHLABELS.ts | 1 - packages/time-series/lib/commands/MRANGE.ts | 2 - .../lib/commands/MRANGE_GROUPBY.ts | 1 - .../lib/commands/MRANGE_MULTIAGGR.ts | 2 - .../lib/commands/MRANGE_SELECTED_LABELS.ts | 1 - .../MRANGE_SELECTED_LABELS_GROUPBY.ts | 1 - .../MRANGE_SELECTED_LABELS_MULTIAGGR.ts | 2 - .../lib/commands/MRANGE_WITHLABELS.ts | 2 - .../lib/commands/MRANGE_WITHLABELS_GROUPBY.ts | 1 - .../commands/MRANGE_WITHLABELS_MULTIAGGR.ts | 2 - .../time-series/lib/commands/MREVRANGE.ts | 2 - .../lib/commands/MREVRANGE_GROUPBY.ts | 1 - .../lib/commands/MREVRANGE_MULTIAGGR.ts | 2 - .../lib/commands/MREVRANGE_SELECTED_LABELS.ts | 1 - .../MREVRANGE_SELECTED_LABELS_GROUPBY.ts | 1 - .../MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts | 2 - .../lib/commands/MREVRANGE_WITHLABELS.ts | 2 - .../commands/MREVRANGE_WITHLABELS_GROUPBY.ts | 1 - .../MREVRANGE_WITHLABELS_MULTIAGGR.ts | 2 - .../time-series/lib/commands/QUERYINDEX.ts | 2 - packages/time-series/lib/commands/RANGE.ts | 1 - .../lib/commands/RANGE_MULTIAGGR.ts | 1 - packages/time-series/lib/commands/READ.ts | 1 - packages/time-series/lib/commands/REVRANGE.ts | 1 - .../lib/commands/REVRANGE_MULTIAGGR.ts | 1 - 512 files changed, 6113 insertions(+), 3784 deletions(-) delete mode 100644 packages/client/lib/cluster/request-response-policies/static-policies-data.ts create mode 100644 packages/client/lib/command-metadata/command-metadata-data.ts rename packages/client/lib/{cluster/request-response-policies => command-metadata}/dynamic-policy-resolver-factory.ts (71%) rename packages/client/lib/{cluster/request-response-policies => command-metadata}/dynamic-policy-resolver.spec.ts (94%) create mode 100644 packages/client/lib/command-metadata/index.ts rename packages/client/lib/{cluster/request-response-policies => command-metadata}/policies-constants.ts (83%) create mode 100644 packages/client/lib/command-metadata/predicates.ts create mode 100644 packages/client/lib/command-metadata/resolve-then-fallback.spec.ts rename packages/client/lib/{cluster/request-response-policies/static-policy-resolver.spec.ts => command-metadata/static-metadata-resolver.spec.ts} (96%) rename packages/client/lib/{cluster/request-response-policies/static-policy-resolver.ts => command-metadata/static-metadata-resolver.ts} (50%) rename packages/client/lib/{cluster/request-response-policies => command-metadata}/types.ts (61%) rename packages/client/scripts/{static-policies-overrides.ts => command-metadata-overrides.ts} (67%) rename packages/client/scripts/{generate-static-policies-data.ts => generate-command-metadata-data.ts} (56%) diff --git a/packages/bloom/lib/commands/bloom/ADD.ts b/packages/bloom/lib/commands/bloom/ADD.ts index e12d9cfa1d2..0ab480d9ba4 100644 --- a/packages/bloom/lib/commands/bloom/ADD.ts +++ b/packages/bloom/lib/commands/bloom/ADD.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('BF.ADD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/CARD.ts b/packages/bloom/lib/commands/bloom/CARD.ts index c2f9aeb00fc..ac8a7ccf497 100644 --- a/packages/bloom/lib/commands/bloom/CARD.ts +++ b/packages/bloom/lib/commands/bloom/CARD.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('BF.CARD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/EXISTS.ts b/packages/bloom/lib/commands/bloom/EXISTS.ts index b3f19af9516..492718ccfc8 100644 --- a/packages/bloom/lib/commands/bloom/EXISTS.ts +++ b/packages/bloom/lib/commands/bloom/EXISTS.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('BF.EXISTS'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/INFO.ts b/packages/bloom/lib/commands/bloom/INFO.ts index 7074885a41e..e7135411a26 100644 --- a/packages/bloom/lib/commands/bloom/INFO.ts +++ b/packages/bloom/lib/commands/bloom/INFO.ts @@ -11,7 +11,6 @@ export type BfInfoReplyMap = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('BF.INFO'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/INSERT.ts b/packages/bloom/lib/commands/bloom/INSERT.ts index b8dcef325f8..cdd525ed8ec 100644 --- a/packages/bloom/lib/commands/bloom/INSERT.ts +++ b/packages/bloom/lib/commands/bloom/INSERT.ts @@ -12,7 +12,6 @@ export interface BfInsertOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/bloom/LOADCHUNK.ts b/packages/bloom/lib/commands/bloom/LOADCHUNK.ts index ef3cc4a3e12..6ece1af3562 100644 --- a/packages/bloom/lib/commands/bloom/LOADCHUNK.ts +++ b/packages/bloom/lib/commands/bloom/LOADCHUNK.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, iterator: number, chunk: RedisArgument) { parser.push('BF.LOADCHUNK'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/MADD.ts b/packages/bloom/lib/commands/bloom/MADD.ts index efbd932b403..c80585ac482 100644 --- a/packages/bloom/lib/commands/bloom/MADD.ts +++ b/packages/bloom/lib/commands/bloom/MADD.ts @@ -4,7 +4,6 @@ import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-t import { transformBooleanArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('BF.MADD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/MEXISTS.ts b/packages/bloom/lib/commands/bloom/MEXISTS.ts index a5a311a8e4c..e2683932069 100644 --- a/packages/bloom/lib/commands/bloom/MEXISTS.ts +++ b/packages/bloom/lib/commands/bloom/MEXISTS.ts @@ -4,7 +4,6 @@ import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-t import { transformBooleanArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('BF.MEXISTS'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/bloom/RESERVE.ts b/packages/bloom/lib/commands/bloom/RESERVE.ts index 00f17c1889f..05469d6fa6f 100644 --- a/packages/bloom/lib/commands/bloom/RESERVE.ts +++ b/packages/bloom/lib/commands/bloom/RESERVE.ts @@ -7,7 +7,6 @@ export interface BfReserveOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/bloom/SCANDUMP.ts b/packages/bloom/lib/commands/bloom/SCANDUMP.ts index d0472b649c5..242a0f04236 100644 --- a/packages/bloom/lib/commands/bloom/SCANDUMP.ts +++ b/packages/bloom/lib/commands/bloom/SCANDUMP.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, TuplesReply, NumberReply, BlobStringReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, iterator: number) { parser.push('BF.SCANDUMP'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/count-min-sketch/INCRBY.ts b/packages/bloom/lib/commands/count-min-sketch/INCRBY.ts index a011957ece6..a1c60deb77b 100644 --- a/packages/bloom/lib/commands/count-min-sketch/INCRBY.ts +++ b/packages/bloom/lib/commands/count-min-sketch/INCRBY.ts @@ -7,7 +7,6 @@ export interface BfIncrByItem { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/count-min-sketch/INFO.ts b/packages/bloom/lib/commands/count-min-sketch/INFO.ts index fef1cac97e7..cd02d108722 100644 --- a/packages/bloom/lib/commands/count-min-sketch/INFO.ts +++ b/packages/bloom/lib/commands/count-min-sketch/INFO.ts @@ -15,7 +15,6 @@ export interface CmsInfoReply { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('CMS.INFO'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/count-min-sketch/INITBYDIM.ts b/packages/bloom/lib/commands/count-min-sketch/INITBYDIM.ts index 44e6a75952f..34295b181fe 100644 --- a/packages/bloom/lib/commands/count-min-sketch/INITBYDIM.ts +++ b/packages/bloom/lib/commands/count-min-sketch/INITBYDIM.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, width: number, depth: number) { parser.push('CMS.INITBYDIM'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/count-min-sketch/INITBYPROB.ts b/packages/bloom/lib/commands/count-min-sketch/INITBYPROB.ts index 3b96120bd04..b17a63b5aec 100644 --- a/packages/bloom/lib/commands/count-min-sketch/INITBYPROB.ts +++ b/packages/bloom/lib/commands/count-min-sketch/INITBYPROB.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, error: number, probability: number) { parser.push('CMS.INITBYPROB'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/count-min-sketch/MERGE.ts b/packages/bloom/lib/commands/count-min-sketch/MERGE.ts index 4d959bd619d..15461038333 100644 --- a/packages/bloom/lib/commands/count-min-sketch/MERGE.ts +++ b/packages/bloom/lib/commands/count-min-sketch/MERGE.ts @@ -9,7 +9,6 @@ interface BfMergeSketch { export type BfMergeSketches = Array | Array; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/bloom/lib/commands/count-min-sketch/QUERY.ts b/packages/bloom/lib/commands/count-min-sketch/QUERY.ts index b55b51d1bbd..07a2512aebb 100644 --- a/packages/bloom/lib/commands/count-min-sketch/QUERY.ts +++ b/packages/bloom/lib/commands/count-min-sketch/QUERY.ts @@ -3,7 +3,6 @@ import { ArrayReply, NumberReply, Command, RedisArgument } from '@redis/client/d import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('CMS.QUERY'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/ADD.ts b/packages/bloom/lib/commands/cuckoo/ADD.ts index 37a5d1b5b86..00bbbf37f5e 100644 --- a/packages/bloom/lib/commands/cuckoo/ADD.ts +++ b/packages/bloom/lib/commands/cuckoo/ADD.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('CF.ADD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/ADDNX.ts b/packages/bloom/lib/commands/cuckoo/ADDNX.ts index ceaf62be21c..5206e85f9e7 100644 --- a/packages/bloom/lib/commands/cuckoo/ADDNX.ts +++ b/packages/bloom/lib/commands/cuckoo/ADDNX.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('CF.ADDNX'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/COUNT.ts b/packages/bloom/lib/commands/cuckoo/COUNT.ts index f0cd5a72105..6a87ed063ce 100644 --- a/packages/bloom/lib/commands/cuckoo/COUNT.ts +++ b/packages/bloom/lib/commands/cuckoo/COUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('CF.COUNT'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/DEL.ts b/packages/bloom/lib/commands/cuckoo/DEL.ts index c97b7c2d9fc..2865764bf3c 100644 --- a/packages/bloom/lib/commands/cuckoo/DEL.ts +++ b/packages/bloom/lib/commands/cuckoo/DEL.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('CF.DEL'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/EXISTS.ts b/packages/bloom/lib/commands/cuckoo/EXISTS.ts index 2299cb3de99..54569de79de 100644 --- a/packages/bloom/lib/commands/cuckoo/EXISTS.ts +++ b/packages/bloom/lib/commands/cuckoo/EXISTS.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformBooleanReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, item: RedisArgument) { parser.push('CF.EXISTS'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/INFO.ts b/packages/bloom/lib/commands/cuckoo/INFO.ts index 6a8f06f1e77..05c60f78184 100644 --- a/packages/bloom/lib/commands/cuckoo/INFO.ts +++ b/packages/bloom/lib/commands/cuckoo/INFO.ts @@ -14,7 +14,6 @@ export type CfInfoReplyMap = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('CF.INFO'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/INSERT.ts b/packages/bloom/lib/commands/cuckoo/INSERT.ts index 3ad3feee16d..ba848ed170f 100644 --- a/packages/bloom/lib/commands/cuckoo/INSERT.ts +++ b/packages/bloom/lib/commands/cuckoo/INSERT.ts @@ -28,7 +28,6 @@ export function parseCfInsertArguments( } export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('CF.INSERT'); parseCfInsertArguments(...args); diff --git a/packages/bloom/lib/commands/cuckoo/INSERTNX.ts b/packages/bloom/lib/commands/cuckoo/INSERTNX.ts index 81b1f88a422..af749a2599b 100644 --- a/packages/bloom/lib/commands/cuckoo/INSERTNX.ts +++ b/packages/bloom/lib/commands/cuckoo/INSERTNX.ts @@ -1,5 +1,5 @@ import { ArrayReply, Command, NumberReply } from '@redis/client/dist/lib/RESP/types'; -import INSERT, { parseCfInsertArguments } from './INSERT'; +import { parseCfInsertArguments } from './INSERT'; /** * Adds one or more items to a Cuckoo Filter only if they do not exist yet, creating the filter if needed @@ -11,7 +11,6 @@ import INSERT, { parseCfInsertArguments } from './INSERT'; * @param options.NOCREATE - If true, prevents automatic filter creation */ export default { - IS_READ_ONLY: INSERT.IS_READ_ONLY, parseCommand(...args: Parameters) { args[0].push('CF.INSERTNX'); parseCfInsertArguments(...args); diff --git a/packages/bloom/lib/commands/cuckoo/LOADCHUNK.ts b/packages/bloom/lib/commands/cuckoo/LOADCHUNK.ts index 8fb21be8e0d..feb4228efb1 100644 --- a/packages/bloom/lib/commands/cuckoo/LOADCHUNK.ts +++ b/packages/bloom/lib/commands/cuckoo/LOADCHUNK.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { SimpleStringReply, Command, RedisArgument } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, iterator: number, chunk: RedisArgument) { parser.push('CF.LOADCHUNK'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/cuckoo/RESERVE.ts b/packages/bloom/lib/commands/cuckoo/RESERVE.ts index 2685b0db06d..96dc797c536 100644 --- a/packages/bloom/lib/commands/cuckoo/RESERVE.ts +++ b/packages/bloom/lib/commands/cuckoo/RESERVE.ts @@ -8,7 +8,6 @@ export interface CfReserveOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/cuckoo/SCANDUMP.ts b/packages/bloom/lib/commands/cuckoo/SCANDUMP.ts index 25ef2c3f6da..520971e207b 100644 --- a/packages/bloom/lib/commands/cuckoo/SCANDUMP.ts +++ b/packages/bloom/lib/commands/cuckoo/SCANDUMP.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, TuplesReply, NumberReply, BlobStringReply, NullReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, iterator: number) { parser.push('CF.SCANDUMP'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/ADD.ts b/packages/bloom/lib/commands/t-digest/ADD.ts index 5534d58065b..01ed86c82a4 100644 --- a/packages/bloom/lib/commands/t-digest/ADD.ts +++ b/packages/bloom/lib/commands/t-digest/ADD.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { SimpleStringReply, Command, RedisArgument } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, values: Array) { parser.push('TDIGEST.ADD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/BYRANK.ts b/packages/bloom/lib/commands/t-digest/BYRANK.ts index 9c1ab0059f3..b272f9b029d 100644 --- a/packages/bloom/lib/commands/t-digest/BYRANK.ts +++ b/packages/bloom/lib/commands/t-digest/BYRANK.ts @@ -15,7 +15,6 @@ export function transformByRankArguments( } export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('TDIGEST.BYRANK'); transformByRankArguments(...args); diff --git a/packages/bloom/lib/commands/t-digest/BYREVRANK.ts b/packages/bloom/lib/commands/t-digest/BYREVRANK.ts index a94e5566bb1..9a3f33be00f 100644 --- a/packages/bloom/lib/commands/t-digest/BYREVRANK.ts +++ b/packages/bloom/lib/commands/t-digest/BYREVRANK.ts @@ -8,7 +8,6 @@ import BYRANK, { transformByRankArguments } from './BYRANK'; * @param ranks - Array of ranks to get value estimates for (descending order) */ export default { - IS_READ_ONLY: BYRANK.IS_READ_ONLY, parseCommand(...args: Parameters) { args[0].push('TDIGEST.BYREVRANK'); transformByRankArguments(...args); diff --git a/packages/bloom/lib/commands/t-digest/CDF.ts b/packages/bloom/lib/commands/t-digest/CDF.ts index 4d1d8ea2786..adb36617eda 100644 --- a/packages/bloom/lib/commands/t-digest/CDF.ts +++ b/packages/bloom/lib/commands/t-digest/CDF.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformDoubleArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, values: Array) { parser.push('TDIGEST.CDF'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/CREATE.ts b/packages/bloom/lib/commands/t-digest/CREATE.ts index 58b1e008284..4782b0adfa7 100644 --- a/packages/bloom/lib/commands/t-digest/CREATE.ts +++ b/packages/bloom/lib/commands/t-digest/CREATE.ts @@ -6,7 +6,6 @@ export interface TDigestCreateOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: TDigestCreateOptions) { parser.push('TDIGEST.CREATE'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/INFO.ts b/packages/bloom/lib/commands/t-digest/INFO.ts index 2cb9e93443c..087cda22bca 100644 --- a/packages/bloom/lib/commands/t-digest/INFO.ts +++ b/packages/bloom/lib/commands/t-digest/INFO.ts @@ -15,7 +15,6 @@ export type TdInfoReplyMap = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TDIGEST.INFO'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/MAX.ts b/packages/bloom/lib/commands/t-digest/MAX.ts index 140db6a3e48..0425b8c81f5 100644 --- a/packages/bloom/lib/commands/t-digest/MAX.ts +++ b/packages/bloom/lib/commands/t-digest/MAX.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformDoubleReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TDIGEST.MAX'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/MERGE.ts b/packages/bloom/lib/commands/t-digest/MERGE.ts index 80049d1e540..aa232d63e12 100644 --- a/packages/bloom/lib/commands/t-digest/MERGE.ts +++ b/packages/bloom/lib/commands/t-digest/MERGE.ts @@ -8,7 +8,6 @@ export interface TDigestMergeOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/bloom/lib/commands/t-digest/MIN.ts b/packages/bloom/lib/commands/t-digest/MIN.ts index d6e56fb672e..8ba54c8ee36 100644 --- a/packages/bloom/lib/commands/t-digest/MIN.ts +++ b/packages/bloom/lib/commands/t-digest/MIN.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformDoubleReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TDIGEST.MIN'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/QUANTILE.ts b/packages/bloom/lib/commands/t-digest/QUANTILE.ts index 1c27b5f6ec6..367a831c246 100644 --- a/packages/bloom/lib/commands/t-digest/QUANTILE.ts +++ b/packages/bloom/lib/commands/t-digest/QUANTILE.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformDoubleArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, quantiles: Array) { parser.push('TDIGEST.QUANTILE'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/RANK.ts b/packages/bloom/lib/commands/t-digest/RANK.ts index 053c0c544e9..8eca3eb9ff2 100644 --- a/packages/bloom/lib/commands/t-digest/RANK.ts +++ b/packages/bloom/lib/commands/t-digest/RANK.ts @@ -14,7 +14,6 @@ export function transformRankArguments( } export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('TDIGEST.RANK'); transformRankArguments(...args); diff --git a/packages/bloom/lib/commands/t-digest/RESET.ts b/packages/bloom/lib/commands/t-digest/RESET.ts index c2bda72d6d4..adeead207a4 100644 --- a/packages/bloom/lib/commands/t-digest/RESET.ts +++ b/packages/bloom/lib/commands/t-digest/RESET.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TDIGEST.RESET'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/t-digest/REVRANK.ts b/packages/bloom/lib/commands/t-digest/REVRANK.ts index e323e10190a..756188fe9e5 100644 --- a/packages/bloom/lib/commands/t-digest/REVRANK.ts +++ b/packages/bloom/lib/commands/t-digest/REVRANK.ts @@ -8,7 +8,6 @@ import RANK, { transformRankArguments } from './RANK'; * @param values - Array of values to get reverse ranks for */ export default { - IS_READ_ONLY: RANK.IS_READ_ONLY, parseCommand(...args: Parameters) { args[0].push('TDIGEST.REVRANK'); transformRankArguments(...args); diff --git a/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.ts b/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.ts index 1fd6360ab65..218a51722d4 100644 --- a/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.ts +++ b/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { transformDoubleReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/top-k/ADD.ts b/packages/bloom/lib/commands/top-k/ADD.ts index 244e6209c91..0bd63d28893 100644 --- a/packages/bloom/lib/commands/top-k/ADD.ts +++ b/packages/bloom/lib/commands/top-k/ADD.ts @@ -3,7 +3,6 @@ import { RedisArgument, ArrayReply, BlobStringReply, Command } from '@redis/clie import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('TOPK.ADD'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/COUNT.ts b/packages/bloom/lib/commands/top-k/COUNT.ts index 7e75a3b68aa..c70666c5b64 100644 --- a/packages/bloom/lib/commands/top-k/COUNT.ts +++ b/packages/bloom/lib/commands/top-k/COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, ArrayReply, NumberReply, Command } from '@redis/client/d import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('TOPK.COUNT'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/INCRBY.ts b/packages/bloom/lib/commands/top-k/INCRBY.ts index 9e9a49e18f9..0dcffc83ffb 100644 --- a/packages/bloom/lib/commands/top-k/INCRBY.ts +++ b/packages/bloom/lib/commands/top-k/INCRBY.ts @@ -11,7 +11,6 @@ function pushIncrByItem(parser: CommandParser, { item, incrementBy }: TopKIncrBy } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/bloom/lib/commands/top-k/INFO.ts b/packages/bloom/lib/commands/top-k/INFO.ts index 52ceb2a552a..5e587a381ba 100644 --- a/packages/bloom/lib/commands/top-k/INFO.ts +++ b/packages/bloom/lib/commands/top-k/INFO.ts @@ -11,7 +11,6 @@ export type TopKInfoReplyMap = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TOPK.INFO'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/LIST.ts b/packages/bloom/lib/commands/top-k/LIST.ts index d7adeaa193c..a04b99cae36 100644 --- a/packages/bloom/lib/commands/top-k/LIST.ts +++ b/packages/bloom/lib/commands/top-k/LIST.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TOPK.LIST'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/LIST_WITHCOUNT.ts b/packages/bloom/lib/commands/top-k/LIST_WITHCOUNT.ts index 2c0f10e785b..804ccc5c543 100644 --- a/packages/bloom/lib/commands/top-k/LIST_WITHCOUNT.ts +++ b/packages/bloom/lib/commands/top-k/LIST_WITHCOUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, NumberReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TOPK.LIST'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/QUERY.ts b/packages/bloom/lib/commands/top-k/QUERY.ts index a6fb4bae69e..32d1971becb 100644 --- a/packages/bloom/lib/commands/top-k/QUERY.ts +++ b/packages/bloom/lib/commands/top-k/QUERY.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { RedisVariadicArgument, transformBooleanArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, items: RedisVariadicArgument) { parser.push('TOPK.QUERY'); parser.pushKey(key); diff --git a/packages/bloom/lib/commands/top-k/RESERVE.ts b/packages/bloom/lib/commands/top-k/RESERVE.ts index ee3ee9a8cf4..ee44da0147b 100644 --- a/packages/bloom/lib/commands/top-k/RESERVE.ts +++ b/packages/bloom/lib/commands/top-k/RESERVE.ts @@ -8,7 +8,6 @@ export interface TopKReserveOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, topK: number, options?: TopKReserveOptions) { parser.push('TOPK.RESERVE'); parser.pushKey(key); diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index e50dc382309..c4180e1185c 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -283,21 +283,13 @@ export type CommandArguments = Array & { preserve?: unknown }; // export type ResponsePolicies = RESPONSE_POLICIES[keyof RESPONSE_POLICIES]; -// export type CommandPolicies = { -// request?: RequestPolicies | null; -// response?: ResponsePolicies | null; -// }; - export type Command = { CACHEABLE?: boolean; IS_READ_ONLY?: boolean; - /** - * @internal - * TODO: remove once `POLICIES` is implemented - */ + /** @deprecated Unused; superseded by request/response policies. */ IS_FORWARD_COMMAND?: boolean; + /** @deprecated Unused; keyless-ness is derived from server metadata (`isKeyless`). */ NOT_KEYED_COMMAND?: true; - // POLICIES?: CommandPolicies; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- arbitrary arg list per command parseCommand(this: void, parser: CommandParser, ...args: Array): void; TRANSFORM_LEGACY_REPLY?: boolean; diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 9e491c9c396..6c73b5e6bda 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -4,6 +4,7 @@ import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsP import RedisCommandsQueue, { CommandOptions } from './commands-queue'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; +import { defaultCommandMetadata, isCacheable } from '../command-metadata'; import { ClientClosedError, ClientOfflineError, DisconnectsClientError, WatchError } from '../errors'; import { URL } from 'node:url'; import { TcpSocketConnectOpts } from 'node:net'; @@ -1231,7 +1232,12 @@ export default class RedisClient< const fn = () => { return this.sendCommand(parser.redisArgs, commandOptions) }; - if (csc && command.CACHEABLE && defaultTypeMapping) { + // Resolve-then-fallback: CSC eligibility derives from the server flags/tips + // (see `isCacheable`); user scripts/functions/unknown modules miss the table + // and fall back to the hardcoded `Command.CACHEABLE`. + const cacheable = isCacheable(defaultCommandMetadata.lookup(parser.commandIdentifier), command.CACHEABLE); + + if (csc && cacheable && defaultTypeMapping) { return await csc.handleCache(this._self, parser as BasicCommandParser, fn, transformReply, commandOptions?.typeMapping); } else { const reply = await fn(); diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 33b748649b5..3f3fc8922f1 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,7 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandPolicies } from './request-response-policies'; +import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; import { captureCursorBinding } from './request-response-policies/ft-cursor'; @@ -403,7 +403,9 @@ export default class RedisCluster< this._commandOptions = { timeout: DEFAULT_COMMAND_TIMEOUT, ...options?.commandOptions }; - this._policyResolver = new StaticPolicyResolver(POLICIES); + // Shared process-wide resolver over the static metadata table. Kept as an + // instance field so a future per-connection dynamic resolver can replace it. + this._policyResolver = defaultCommandMetadata; } duplicate< @@ -520,7 +522,7 @@ export default class RedisCluster< // by contract, so default-keyed is always correct for them. Known // multi_shard commands that can't be split still throw from the splitter. const hasKeys = parser.keys.length > 0; - const policy: CommandPolicies = policyResult.ok + const policy: CommandMetadata = policyResult.ok ? policyResult.value : { request: hasKeys @@ -532,6 +534,12 @@ export default class RedisCluster< isKeyless: !hasKeys }; + // Resolve-then-fallback: replica-safety derives from the server `write` + // flag (see `isReplicaSafe`). On a table miss the synthesized `policy` has + // no `flags`, so the predicate falls back to the hardcoded `IS_READ_ONLY` + // threaded in as `isReadonly`. + const readonly = isReplicaSafe(policy, isReadonly); + const requestPolicy = policy.request const responsePolicy = policy.response @@ -545,7 +553,7 @@ export default class RedisCluster< const plan = await router( this._slots as unknown as Parameters[0], parser, - isReadonly, + readonly, policy.keySpecs ); @@ -557,7 +565,7 @@ export default class RedisCluster< const entryParser = entry.parser ?? parser; // Re-narrow the opaque routed client to this cluster's instantiation. const client = entry.client as RedisClientType | undefined; - return this._execute(entryParser, isReadonly, options, makeFn(entryParser), client); + return this._execute(entryParser, readonly, options, makeFn(entryParser), client); }); const reducer = RESPONSE_REDUCERS[responsePolicy]; diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index a94c2e872fb..8274ee1cef9 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -19,7 +19,7 @@ import { RESPONSE_POLICIES_WITH_DEFAULTS, type RequestPolicyWithDefaults, type ResponsePolicyWithDefaults -} from './policies-constants'; +} from '../../command-metadata/policies-constants'; import { SPECIAL_REQUEST_ROUTERS } from './ft-cursor'; // Routing runs *below* the typed command surface: routers never inspect the diff --git a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts index 161f73f6f72..b837b60af70 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -1,9 +1,9 @@ import { strict as assert } from 'node:assert'; import { - StaticPolicyResolver, + StaticMetadataResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS -} from '.'; +} from '../../command-metadata'; /** * Snapshot of the HLD "Command Routing Policy Table" — client interpretation column. @@ -64,7 +64,7 @@ const HLD_FT_TABLE: Record { - const resolver = new StaticPolicyResolver(); + const resolver = new StaticMetadataResolver(); for (const [command, expected] of Object.entries(HLD_FT_TABLE)) { it(`${command} resolves to the HLD policy`, () => { diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index a06604a8961..d28ac18869b 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -1,11 +1,6 @@ -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'; +// Cluster-only routing that *consumes* command metadata. The metadata table +// and its resolver now live in `lib/command-metadata/`; import those from there. export * from './dispatch'; export { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; -// export { type CommandRouter } from './command-router'; \ No newline at end of file +// export { type CommandRouter } from './command-router'; 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 deleted file mode 100644 index e3ecd8abf29..00000000000 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ /dev/null @@ -1,2928 +0,0 @@ -// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually. -// Source: Redis 8.8.0, 415 commands. -import { ModulePolicyRecords } from "./types"; - -export const POLICIES: ModulePolicyRecords = { - "ft": { - "aggregate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "aliasadd": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "aliasdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "aliasupdate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "alter": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "create": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "cursor": { - "request": "special", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "read": { - "request": "special", - "response": "default-keyless", - "isKeyless": true - }, - "del": { - "request": "special", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "dictadd": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dictdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dictdump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "drop": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dropindex": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "explain": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "explaincli": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "profile": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "search": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "spellcheck": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "sugadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sugdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sugget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "suglen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "syndump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "synupdate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "tagvals": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - }, - "bf": { - "add": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "card": { - "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 - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "insert": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "loadchunk": { - "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 - }, - "reserve": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "scandump": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "cf": { - "add": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "addnx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "compact": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "count": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "debug": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "del": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "exists": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "insert": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "insertnx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "loadchunk": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "mexists": { - "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 - } - }, - "cms": { - "incrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "initbydim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "initbyprob": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "merge": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "query": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "json": { - "arrappend": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arrindex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arrinsert": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arrlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arrpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arrtrim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "clear": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "debug": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "del": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "forget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "merge": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "mget": { - "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 - }, - "nummultby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "numpowby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "objkeys": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "objlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "resp": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "set": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "strappend": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "strlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "toggle": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "type": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "std": { - "vadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vcard": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vdim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vemb": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vgetattr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vinfo": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vismember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vlinks": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vrandmember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vrem": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vsetattr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "vsim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "acl": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "cat": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "deluser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "dryrun": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "genpass": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getuser": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "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 - }, - "save": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "setuser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "users": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "whoami": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "append": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ardel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ardelrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "argetrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "argrep": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arinfo": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arinsert": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arlastitems": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "armget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "armset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arnext": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arring": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arscan": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arseek": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "arset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "asking": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "auth": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bgrewriteaof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bgsave": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bitcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bitfield": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bitfield_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bitop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bitpos": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "blmove": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "blmpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "blpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "brpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "brpoplpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bzmpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bzpopmax": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "bzpopmin": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "client": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "caching": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getname": { - "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 - }, - "id": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "kill": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "list": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "no-evict": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "no-touch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pause": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reply": { - "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 - }, - "tracking": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "trackinginfo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "unblock": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "unpause": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "cluster": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "addslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "addslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bumpepoch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "count-failure-reports": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "countkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "failover": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "flushslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "forget": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "keyslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "links": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "meet": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "migration": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "myid": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "myshardid": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "nodes": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "replicas": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "replicate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "saveconfig": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "set-config-epoch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "shards": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "slaves": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "slot-stats": { - "request": "all_shards", - "response": "default-keyless", - "isKeyless": true - }, - "slots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "syncslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "command": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "count": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "docs": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getkeys": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getkeysandflags": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "list": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "config": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "get": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "resetstat": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "rewrite": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "set": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - } - } - }, - "copy": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "dbsize": { - "request": "all_shards", - "response": "agg_sum", - "isKeyless": true - }, - "debug": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "decr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "decrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "del": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 1, - "limit": 0 - } - } - ] - }, - "delex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "digest": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "discard": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dump": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "echo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "eval": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "eval_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "evalsha": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "evalsha_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "exec": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "exists": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 1, - "limit": 0 - } - } - ] - }, - "expire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "expireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "expiretime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "failover": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "fcall": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "fcall_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "flushall": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "flushdb": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "function": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "delete": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "dump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "flush": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", - "isKeyless": true - }, - "list": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "load": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "restore": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "stats": { - "request": "all_shards", - "response": "special", - "isKeyless": true - } - } - }, - "geoadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "geodist": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "geohash": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "geopos": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "georadius": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "georadius_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "georadiusbymember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "georadiusbymember_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "geosearch": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "geosearchstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "getbit": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "getdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "getex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "getrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "getset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hello": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "hexists": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hexpireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hexpiretime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hgetall": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hgetdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hgetex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hincrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hincrbyfloat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hkeys": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hmget": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hmset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hotkeys": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "get": { - "request": "special", - "response": "special", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "special", - "response": "default-keyless", - "isKeyless": true - }, - "start": { - "request": "special", - "response": "default-keyless", - "isKeyless": true - }, - "stop": { - "request": "special", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "hpersist": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpexpireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpexpiretime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpttl": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hrandfield": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hscan": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hsetex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hsetnx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hstrlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "httl": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hvals": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "incr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "incrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "incrbyfloat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "increx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "all_shards", - "response": "special", - "isKeyless": true - }, - "keys": { - "request": "all_shards", - "response": "default-keyless", - "isKeyless": true - }, - "lastsave": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "latency": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "doctor": { - "request": "all_nodes", - "response": "special", - "isKeyless": true - }, - "graph": { - "request": "all_nodes", - "response": "special", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "histogram": { - "request": "all_nodes", - "response": "special", - "isKeyless": true - }, - "history": { - "request": "all_nodes", - "response": "special", - "isKeyless": true - }, - "latest": { - "request": "all_nodes", - "response": "special", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - } - } - }, - "lcs": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lindex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "linsert": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "llen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lmove": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lmpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lolwut": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "lpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lpos": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lpushx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lrem": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "lset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ltrim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "memory": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "doctor": { - "request": "all_shards", - "response": "special", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "malloc-stats": { - "request": "all_shards", - "response": "special", - "isKeyless": true - }, - "purge": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "stats": { - "request": "all_shards", - "response": "special", - "isKeyless": true - }, - "usage": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } - }, - "mget": { - "request": "multi_shard", - "response": "default-keyed", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 1, - "limit": 0 - } - } - ] - }, - "migrate": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "module": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "help": { - "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 - }, - "loadex": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "unload": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "monitor": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "move": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "mset": { - "request": "multi_shard", - "response": "all_succeeded", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 2, - "limit": 0 - } - } - ] - }, - "msetex": { - "request": "multi_shard", - "response": "all_succeeded", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "keynum", - "keyNumIdx": 0, - "firstKey": 1, - "keyStep": 2 - } - } - ] - }, - "msetnx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "multi": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "object": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "encoding": { - "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 - }, - "idletime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "refcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } - }, - "persist": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pexpireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pexpiretime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pfadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pfcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pfdebug": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pfmerge": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "pfselftest": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ping": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "psetex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "psubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "psync": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pttl": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "publish": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pubsub": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "channels": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "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 - }, - "shardchannels": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "shardnumsub": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "punsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "quit": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "randomkey": { - "request": "all_shards", - "response": "special", - "isKeyless": true - }, - "readonly": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "readwrite": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "rename": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "renamenx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "replconf": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "replicaof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "restore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "restore-asking": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "role": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "rpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rpoplpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rpushx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "save": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "scan": { - "request": "special", - "response": "special", - "isKeyless": true - }, - "scard": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "script": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "debug": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "exists": { - "request": "all_shards", - "response": "agg_logical_and", - "isKeyless": true - }, - "flush": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", - "isKeyless": true - }, - "load": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - } - } - }, - "sdiff": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sdiffstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "select": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "set": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "setbit": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "setex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "setnx": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "setrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "shutdown": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "sinter": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sintercard": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sinterstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sismember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "slaveof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "slowlog": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "get": { - "request": "all_nodes", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "len": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - } - } - }, - "smembers": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "smismember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "smove": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sort": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sort_ro": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "spop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "spublish": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "srandmember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "srem": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sscan": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ssubscribe": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "strlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "subscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "substr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sunion": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sunionstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "sunsubscribe": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "swapdb": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "sync": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "time": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "touch": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 1, - "limit": 0 - } - } - ] - }, - "trimslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ttl": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "type": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "unlink": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false, - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "range", - "lastKey": -1, - "keyStep": 1, - "limit": 0 - } - } - ] - }, - "unsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "unwatch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "wait": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true - }, - "waitaof": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true - }, - "watch": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xack": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xackdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xautoclaim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xcfgset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xclaim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xdel": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xdelex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xgroup": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "create": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "createconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "delconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "destroy": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } - }, - "xidmprecord": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xinfo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "consumers": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "groups": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "stream": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } - }, - "xlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xnack": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xpending": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xread": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xreadgroup": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xrevrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xsetid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xtrim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zcard": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zdiff": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zdiffstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zincrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zinter": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zintercard": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zinterstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zlexcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zmpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zmscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zpopmax": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zpopmin": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrandmember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrangebylex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrangestore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrem": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zremrangebylex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zremrangebyrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zremrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrevrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrevrangebylex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrevrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrevrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zscan": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zunion": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zunionstore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "tdigest": { - "add": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "byrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "byrevrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "cdf": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "create": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "max": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "merge": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "min": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "quantile": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "reset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "revrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "trimmed_mean": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "timeseries": { - "refreshcluster": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - }, - "topk": { - "add": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "count": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "incrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "list": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "query": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "reserve": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "ts": { - "add": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "alter": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "create": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "createrule": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "decrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "del": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "deleterule": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "incrby": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "info": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "madd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "mget": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "mrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "mrevrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "queryindex": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "range": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "revrange": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } -} as const; diff --git a/packages/client/lib/command-metadata/command-metadata-data.ts b/packages/client/lib/command-metadata/command-metadata-data.ts new file mode 100644 index 00000000000..4af83d43d0c --- /dev/null +++ b/packages/client/lib/command-metadata/command-metadata-data.ts @@ -0,0 +1,5691 @@ +// This file is auto-generated by scripts/generate-command-metadata-data.ts — do not edit manually. +// Source: Redis 255.255.255, 421 commands. +import { ModuleMetadataRecords } from "./types"; + +export const COMMAND_METADATA: ModuleMetadataRecords = { + "ft": { + "aggregate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "aliasadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "aliasdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "aliaslist": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "aliasupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "alter": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "create": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "cursor": { + "request": "special", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "subcommands": { + "read": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + }, + "del": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "dictadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "dictdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "dictdump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "drop": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "module" + ] + }, + "dropindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "explain": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "explaincli": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "profile": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "search": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "spellcheck": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "sugadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "sugdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "sugget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "suglen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "syndump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "synupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "tagvals": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + } + }, + "bf": { + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "card": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + } + }, + "cf": { + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "addnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "compact": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module", + "fast" + ] + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "insertnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ], + "tips": [ + "dont_cache" + ] + } + }, + "cms": { + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "initbydim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "initbyprob": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + } + }, + "json": { + "arrappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "arrindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "arrinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "arrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "arrpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "arrtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "clear": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "movablekeys" + ], + "tips": [ + "dont_cache" + ] + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "forget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "mget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "mset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "numincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "nummultby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "numpowby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "objkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "objlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "resp": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "strappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "toggle": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + } + }, + "std": { + "vadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "vcard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vdim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vemb": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vgetattr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vinfo": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "vlinks": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "fast" + ] + }, + "vrandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "vrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "vrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "vsetattr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module", + "fast" + ] + }, + "vsim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "acl": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "cat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "deluser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "dryrun": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "genpass": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "getuser": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "log": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "save": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "setuser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "users": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "whoami": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + } + } + }, + "append": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "arcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "ardel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "ardelrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "arget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "argetrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "argrep": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "arinfo": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "arinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "arlastitems": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "arlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "armget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "armset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "arnext": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "arop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "arring": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "arscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "arseek": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "arset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "asking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "fast" + ] + }, + "auth": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "bgrewriteaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "bgsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "bitcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "bitfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "bitfield_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "bitop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "bitpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "blmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "blocking" + ] + }, + "blmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "blpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking" + ] + }, + "brpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking" + ] + }, + "brpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "blocking" + ] + }, + "bzmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "bzpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking", + "fast" + ] + }, + "bzpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking", + "fast" + ] + }, + "client": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "caching": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "getname": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "getredir": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "id": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "kill": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "no-evict": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "no-touch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "pause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "reply": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "setinfo": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "setname": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "tracking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "trackinginfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale" + ] + }, + "unblock": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "unpause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + } + } + }, + "cluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "addslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "addslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "bumpepoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "count-failure-reports": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "countkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "stale" + ] + }, + "delslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "delslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "flushslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "forget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "getkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "keyslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "links": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "meet": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "migration": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "myid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "myshardid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "nodes": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "replicas": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "replicate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "stale" + ] + }, + "saveconfig": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "set-config-epoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "setslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "shards": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "slaves": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "slot-stats": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "slots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "syncslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale", + "no_async_loading" + ], + "tips": [ + "nondeterministic_output" + ] + } + } + }, + "command": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output_order" + ], + "subcommands": { + "count": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "docs": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "getkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "getkeysandflags": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output_order" + ] + } + } + }, + "config": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "get": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "resetstat": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "rewrite": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "set": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + } + } + }, + "copy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "dbsize": { + "request": "all_shards", + "response": "agg_sum", + "isKeyless": true, + "flags": [ + "readonly", + "fast" + ] + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "decr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "del": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false, + "flags": [ + "write" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] + }, + "delex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "digest": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "discard": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "dump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "echo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ] + }, + "eval": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "eval_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "evalsha": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "evalsha_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "exec": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "skip_slowlog" + ] + }, + "exists": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] + }, + "expire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "expireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "expiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "stale" + ] + }, + "fcall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "fcall_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys", + "script_runner" + ] + }, + "flushall": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write" + ] + }, + "flushdb": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write" + ] + }, + "function": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "delete": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write", + "noscript" + ] + }, + "dump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript" + ] + }, + "flush": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write", + "noscript" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true, + "flags": [ + "noscript", + "allow_busy" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "load": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "noscript" + ] + }, + "restore": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "write", + "denyoom", + "noscript" + ] + }, + "stats": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [ + "noscript", + "allow_busy" + ], + "tips": [ + "nondeterministic_output" + ] + } + } + }, + "geoadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "geodist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "geohash": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "geopos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "georadius": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "georadius_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "georadiusbymember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "georadiusbymember_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "geosearch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "geosearchstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "getbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "getdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "getex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "getrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "getset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hello": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "hexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hgetall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "hgetdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hgetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hincrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "hlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hmget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hmset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hotkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "get": { + "request": "special", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "reset": { + "request": "special", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "start": { + "request": "special", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "stop": { + "request": "special", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + } + } + }, + "hpersist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hpexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hpexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "hpexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "hpttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "hrandfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "hscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "hset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hsetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hsetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "hstrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "httl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "hvals": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "incr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "incrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "increx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "info": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "keys": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "lastsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "latency": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "doctor": { + "request": "all_nodes", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "graph": { + "request": "all_nodes", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "histogram": { + "request": "all_nodes", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "history": { + "request": "all_nodes", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "latest": { + "request": "all_nodes", + "response": "special", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "reset": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + } + } + }, + "lcs": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "lindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "linsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "llen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "lmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "lmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "movablekeys" + ] + }, + "lolwut": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "fast" + ] + }, + "lpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "lpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "lpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "lpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "lrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "lrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "lset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "ltrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "memory": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "doctor": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "malloc-stats": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [], + "tips": [ + "nondeterministic_output" + ] + }, + "purge": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [] + }, + "stats": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [], + "tips": [ + "nondeterministic_output" + ] + }, + "usage": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + } + } + }, + "mget": { + "request": "multi_shard", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] + }, + "migrate": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "movablekeys" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "module": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "loadex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "unload": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading" + ] + } + } + }, + "monitor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "move": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "mset": { + "request": "multi_shard", + "response": "all_succeeded", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 2, + "limit": 0 + } + } + ] + }, + "msetex": { + "request": "multi_shard", + "response": "all_succeeded", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "keynum", + "keyNumIdx": 0, + "firstKey": 1, + "keyStep": 2 + } + } + ] + }, + "msetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "multi": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "object": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "encoding": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "freq": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "idletime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "refcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + } + } + }, + "persist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "pexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "pexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "pexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "pfadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "pfcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "pfdebug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "admin" + ] + }, + "pfmerge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "pfselftest": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin" + ] + }, + "ping": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "fast" + ] + }, + "psetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "psubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "denyoom", + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "psync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "pttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "publish": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale", + "fast" + ] + }, + "pubsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "channels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "numpat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "numsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "shardchannels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "shardnumsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "loading", + "stale" + ] + } + } + }, + "punsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "quit": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "randomkey": { + "request": "all_shards", + "response": "special", + "isKeyless": true, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "readonly": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ] + }, + "readwrite": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ] + }, + "rename": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "renamenx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "replconf": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale", + "allow_busy" + ] + }, + "replicaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "stale", + "no_async_loading" + ] + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "restore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "restore-asking": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "asking" + ] + }, + "role": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast" + ] + }, + "rpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "rpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "rpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "rpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "sadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "save": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "scan": { + "request": "special", + "response": "special", + "isKeyless": true, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "scard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "script": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript" + ] + }, + "exists": { + "request": "all_shards", + "response": "agg_logical_and", + "isKeyless": true, + "flags": [ + "noscript" + ] + }, + "flush": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "noscript" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true, + "flags": [ + "noscript", + "allow_busy" + ] + }, + "load": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "noscript", + "stale" + ] + } + } + }, + "sdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "sdiffcard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "sdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "select": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ] + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "setbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "setex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "setnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "setrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "shutdown": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "loading", + "stale", + "no_multi", + "allow_busy" + ] + }, + "sinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "sintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "sinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "sismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "slaveof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "stale", + "no_async_loading" + ] + }, + "slowlog": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "get": { + "request": "all_nodes", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "len": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "reset": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true, + "flags": [ + "admin", + "loading", + "stale" + ] + } + } + }, + "smembers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "smismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "smove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "sort": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "sort_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "spop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "spublish": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "pubsub", + "loading", + "stale", + "fast" + ] + }, + "srandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "srem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "sscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "ssubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "denyoom", + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "subscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "denyoom", + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "substr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "sunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output_order" + ] + }, + "sunioncard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "sunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "sunsubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "swapdb": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write", + "fast" + ] + }, + "sync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "time": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "touch": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "tips": [ + "dont_cache" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] + }, + "trimslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "write" + ] + }, + "ttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "unlink": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false, + "flags": [ + "write", + "fast" + ], + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] + }, + "unsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "unwatch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "wait": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true, + "flags": [ + "blocking" + ] + }, + "waitaof": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true, + "flags": [ + "blocking" + ] + }, + "watch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "xack": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xackdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "xautoclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "xcfgset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "xdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xdelex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xgroup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "createconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "delconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "destroy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "setid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + } + } + }, + "xidmprecord": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "xinfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "consumers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "groups": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "stream": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + } + } + }, + "xlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "xnack": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "xpending": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "xrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "xread": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "blocking", + "movablekeys" + ] + }, + "xreadgroup": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "xrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "xsetid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "xtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "zadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "zcard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "zdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "zincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "fast" + ] + }, + "zinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "zintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "zinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "zlexcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "movablekeys" + ] + }, + "zmscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "zpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "zrandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "zrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrangestore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, + "zrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "fast" + ] + }, + "zremrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "zremrangebyrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "zremrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write" + ] + }, + "zrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrevrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrevrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ] + }, + "zrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly" + ], + "tips": [ + "nondeterministic_output" + ] + }, + "zscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "fast" + ] + }, + "zunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "movablekeys" + ] + }, + "zunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "movablekeys" + ] + } + }, + "tdigest": { + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "byrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "byrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "cdf": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "max": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module", + "movablekeys" + ] + }, + "min": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "quantile": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "rank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "reset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "revrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "trimmed_mean": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + } + }, + "timeseries": { + "refreshcluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module", + "noscript" + ] + } + }, + "topk": { + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "list": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + } + }, + "ts": { + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "alter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "createrule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module", + "fast" + ] + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "deleterule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "module" + ] + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "module" + ] + }, + "mget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "mrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "mrevrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "nrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "movablekeys" + ] + }, + "nrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module", + "movablekeys" + ] + }, + "queryindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "range": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + }, + "read": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "revrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "readonly", + "module" + ] + } + } +} as const; diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts similarity index 71% rename from packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts rename to packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts index c23f1cefa61..735d94c96ec 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts @@ -1,8 +1,8 @@ -import type { CommandReply } from '../../commands/generic-transformers'; -import type { CommandPolicies } from './policies-constants'; +import type { CommandReply } from '../commands/generic-transformers'; +import type { CommandMetadata } 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'; +import type { PolicyResolver, ModuleMetadataRecords } from './types'; +import { StaticMetadataResolver } from './static-metadata-resolver'; /** * Function type that returns command information from Redis @@ -13,36 +13,36 @@ 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. + * appropriate routing policies for each command, returning a StaticMetadataResolver with the built policies. */ export class DynamicPolicyResolverFactory { /** - * Creates a StaticPolicyResolver by fetching command information from Redis + * Creates a StaticMetadataResolver 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 + * @returns A new StaticMetadataResolver with the fetched policies */ static async create( commandFetcher: CommandFetcher, fallbackResolver?: PolicyResolver ): Promise { const commands = await commandFetcher(); - const policies = DynamicPolicyResolverFactory.buildModulePolicyRecords(commands); + const policies = DynamicPolicyResolverFactory.buildModuleMetadataRecords(commands); - return new StaticPolicyResolver(policies, fallbackResolver); + return new StaticMetadataResolver(policies, fallbackResolver); } /** * Builds module->command policy records from COMMAND replies. * - * Also used by `scripts/generate-static-policies-data.ts` to regenerate - * `static-policies-data.ts`, so the static data is guaranteed to match what + * Also used by `scripts/generate-command-metadata-data.ts` to regenerate + * `command-metadata-data.ts`, so the static data is guaranteed to match what * this factory would derive at runtime. */ - static buildModulePolicyRecords(commands: Array): ModulePolicyRecords { - const policies: ModulePolicyRecords = {}; + static buildModuleMetadataRecords(commands: Array): ModuleMetadataRecords { + const policies: ModuleMetadataRecords = {}; for (const command of commands) { const parsed = DynamicPolicyResolverFactory.#parseCommandName(command.name); @@ -92,14 +92,14 @@ export class DynamicPolicyResolverFactory { } /** - * Builds CommandPolicies for a command based on its characteristics. + * Builds CommandMetadata 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 { + static #buildCommandPolicies(command: CommandReply): CommandMetadata { // Determine if command is keyless based on keySpecification const isKeyless = command.isKeyless @@ -111,13 +111,13 @@ export class DynamicPolicyResolverFactory { ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; - let subcommands: Record | undefined; + 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("\|") + const parts = subcommand.name.split("|") if(parts.length !== 2) { throw new Error(`Invalid subcommand name: ${subcommand.name}`); } @@ -133,8 +133,14 @@ export class DynamicPolicyResolverFactory { request, response: command.policies.response ?? defaultResponse, isKeyless, + // Mirror the raw server signals verbatim. Derivation (replica-safety, + // CSC eligibility) is NOT precomputed here — it lives in the + // `isReplicaSafe` / `isCacheable` predicates, so the static table and a + // dynamic live-`COMMAND` resolver feed the identical algorithm. + flags: [...command.flags], + tips: command.tips.length ? command.tips : undefined, // Only the multi_shard splitter consumes key specs. This builder also - // produces static-policies-data.ts, so copying them unconditionally + // produces command-metadata-data.ts, so copying them unconditionally // would pollute the generated data with specs nothing reads // (~tripling the file). keySpecs: request === REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/command-metadata/dynamic-policy-resolver.spec.ts similarity index 94% rename from packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts rename to packages/client/lib/command-metadata/dynamic-policy-resolver.spec.ts index f38843dae25..bf560a4fc0f 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/command-metadata/dynamic-policy-resolver.spec.ts @@ -1,7 +1,7 @@ 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'; +import type { CommandReply } from '../commands/generic-transformers'; +import { DynamicPolicyResolverFactory, type CommandFetcher, StaticMetadataResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from '.'; +import testUtils, { GLOBAL } from '../test-utils'; const createMockCommandFetcher = (commands: Array): CommandFetcher => async () => commands; @@ -9,15 +9,15 @@ const createMockCommandFetcher = (commands: Array): CommandFetcher describe('DynamicPolicyResolverFactory', () => { describe('create', () => { - it('should create StaticPolicyResolver with empty policies', async () => { + it('should create StaticMetadataResolver with empty policies', async () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - assert.ok(resolver instanceof StaticPolicyResolver); + assert.ok(resolver instanceof StaticMetadataResolver); }); - it('should create StaticPolicyResolver with fallback', async () => { + it('should create StaticMetadataResolver with fallback', async () => { const mockCommandFetcher = createMockCommandFetcher([]); - const fallbackResolver = new StaticPolicyResolver({ + const fallbackResolver = new StaticMetadataResolver({ std: { ping: { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, @@ -28,7 +28,7 @@ describe('DynamicPolicyResolverFactory', () => { }); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); - assert.ok(resolver instanceof StaticPolicyResolver); + assert.ok(resolver instanceof StaticMetadataResolver); const result = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); assert.equal(result.ok, true); @@ -52,6 +52,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + tips: [], keySpecs: [], subcommands: [] } @@ -80,6 +81,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: false, + tips: [], keySpecs: [], subcommands: [] } @@ -108,6 +110,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + tips: [], keySpecs: [], subcommands: [] } @@ -140,6 +143,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'multi_shard', response: 'all_succeeded' }, isKeyless: false, + tips: [], keySpecs: [...msetKeySpecs], subcommands: [] }, @@ -153,6 +157,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: false, + tips: [], keySpecs: [{ beginSearch: { type: 'index', index: 1 }, findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } @@ -190,6 +195,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'special' }, isKeyless: false, + tips: [], keySpecs: [], subcommands: [] } @@ -218,6 +224,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + tips: [], keySpecs: [], subcommands: [] } @@ -248,6 +255,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + tips: [], keySpecs: [], subcommands: [] } @@ -301,6 +309,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + tips: [], keySpecs: [], subcommands: [] }, @@ -314,6 +323,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: 'agg_sum' }, isKeyless: true, + tips: [], keySpecs: [], subcommands: [] } @@ -342,7 +352,7 @@ describe('DynamicPolicyResolverFactory', () => { it('should handle empty command list', async () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - assert.ok(resolver instanceof StaticPolicyResolver); + assert.ok(resolver instanceof StaticMetadataResolver); const result = resolver.resolvePolicy({ command: 'any-command', subcommand: undefined }); assert.equal(result.ok, false); @@ -353,7 +363,7 @@ describe('DynamicPolicyResolverFactory', () => { 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); + assert.ok(resolver instanceof StaticMetadataResolver); // Test that ping command is classified as keyless const pingResult = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); diff --git a/packages/client/lib/command-metadata/index.ts b/packages/client/lib/command-metadata/index.ts new file mode 100644 index 00000000000..672a61bd03b --- /dev/null +++ b/packages/client/lib/command-metadata/index.ts @@ -0,0 +1,20 @@ +export type { Either, PolicyResult, PolicyResolver, ModuleMetadataRecords, CommandMetadataRecords } from './types'; + +export { StaticMetadataResolver } from './static-metadata-resolver'; +export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; + +export * from './policies-constants'; +export { isReplicaSafe, isCacheable } from './predicates'; +export { COMMAND_METADATA } from './command-metadata-data'; + +import { StaticMetadataResolver } from './static-metadata-resolver'; +import { COMMAND_METADATA } from './command-metadata-data'; + +/** + * Process-wide resolver over the generated static metadata table. The table is + * static generated data, so a single shared instance serves every client + * (standalone, cluster, sentinel, pool) — no constructor threading required. + * Kept injectable (`withFallback`) so a future per-connection dynamic resolver + * built from each server's own `COMMAND` reply can override it. + */ +export const defaultCommandMetadata = new StaticMetadataResolver(COMMAND_METADATA); diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/command-metadata/policies-constants.ts similarity index 83% rename from packages/client/lib/cluster/request-response-policies/policies-constants.ts rename to packages/client/lib/command-metadata/policies-constants.ts index 2abe0ef69f4..efc66459d80 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/command-metadata/policies-constants.ts @@ -1,4 +1,4 @@ -import type { KeySpec } from '../../commands/generic-transformers'; +import type { KeySpec } from '../commands/generic-transformers'; export const REQUEST_POLICIES_WITH_DEFAULTS = { /** @@ -110,10 +110,10 @@ export const RESPONSE_POLICIES_WITH_DEFAULTS = { export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof typeof RESPONSE_POLICIES_WITH_DEFAULTS]; -export interface CommandPolicies { +export interface CommandMetadata { readonly request: RequestPolicyWithDefaults; readonly response: ResponsePolicyWithDefaults; - readonly subcommands?: Record; + readonly subcommands?: Record; readonly isKeyless: boolean; /** * COMMAND key specifications — the reconstruction recipe for splitting the @@ -121,4 +121,21 @@ export interface CommandPolicies { * commands never split, so their entries stay lean. */ readonly keySpecs?: ReadonlyArray; + /** + * Raw command flags from the server `COMMAND` reply (e.g. `write`, + * `readonly`, `script`). Stored verbatim rather than pre-cooked into + * booleans so the derivation lives in one place (`isReplicaSafe` / + * `isCacheable`) and the static table + a future dynamic live-`COMMAND` + * resolver feed the identical algorithm. Optional: absent for synthesized + * fallback policies and hand-written overrides, in which case the readers + * fall back to the hardcoded `Command.IS_READ_ONLY` / `Command.CACHEABLE`. + */ + readonly flags?: ReadonlyArray; + /** + * Raw command tips from the server `COMMAND` reply, minus the + * `request_policy:` / `response_policy:` tips (already captured as + * `request` / `response`). Carries `nondeterministic_output`, `dont_cache`, + * etc. — the signals `isCacheable` consumes. + */ + readonly tips?: ReadonlyArray; } \ No newline at end of file diff --git a/packages/client/lib/command-metadata/predicates.ts b/packages/client/lib/command-metadata/predicates.ts new file mode 100644 index 00000000000..05b0677d2f2 --- /dev/null +++ b/packages/client/lib/command-metadata/predicates.ts @@ -0,0 +1,66 @@ +import type { CommandMetadata } from './policies-constants'; + +/** + * Whether it is safe to route a command to a replica. + * + * In node-redis `Command.IS_READ_ONLY` means, for all intents and purposes, + * "safe to send to a replica" — it is consumed only by the cluster and sentinel + * routers to choose replica vs master. The server's `readonly` command flag is + * NOT the right signal: its definition is broader (it also drives ACL `@read`, + * key-spec RO/RW, etc.) and is not 1:1 with replica-safety. + * + * The authoritative signal is the `write` command flag. The server itself + * rejects a command on a read-only replica iff the command carries `CMD_WRITE` + * — see `processCommand` in redis/src/server.c: + * + * int is_write_command = (cmd_flags & CMD_WRITE) || ... + * if (server.masterhost && server.repl_slave_ro && !obey_client && is_write_command) + * rejectCommand(c, shared.roslaveerr); // -READONLY You can't write against a read only replica. + * + * So a command is replica-safe iff it does NOT carry the `write` flag. A command + * that carries neither `write` nor `readonly` (PING/INFO/admin/pubsub) is + * replica-safe under this rule. + * + * Resolve-then-fallback: built-ins / known modules hit the generated table + * (`meta.flags` present) → derived value wins. User scripts / functions / + * unknown modules miss the table (`meta`/`meta.flags` absent) → fall back to the + * hand-set `Command.IS_READ_ONLY`. No breaking change. + */ +export function isReplicaSafe( + meta: CommandMetadata | undefined, + fallback: boolean | undefined +): boolean { + return meta?.flags ? !meta.flags.includes('write') : !!fallback; +} + +/** + * Whether a command's reply is eligible for client-side caching (CSC). + * + * Implements the cross-client CSC "Command Eligibility" algorithm: a command is + * cacheable if all of the following hold — + * - no `dont_cache` tip (explicit negative override), + * - has the `readonly` flag, + * - takes at least one key-name argument (`!isKeyless`; CSC invalidation is + * key-tracking based, so keyless read-only commands like KEYS must not cache), + * - no `nondeterministic_output` tip (value nondeterminism; `*_output_order` + * is fine — HGETALL/SMEMBERS stay cacheable), + * - no `script` / `script_runner` flag (EVAL_RO/EVALSHA_RO/FCALL_RO). + * + * Resolve-then-fallback: table miss (no `meta.flags`) falls back to the hand-set + * `Command.CACHEABLE`. + */ +export function isCacheable( + meta: CommandMetadata | undefined, + fallback: boolean | undefined +): boolean { + if (!meta?.flags) return !!fallback; + const tips = meta.tips ?? []; + return !tips.includes('dont_cache') + && meta.flags.includes('readonly') + && !meta.isKeyless + && !tips.includes('nondeterministic_output') + // `script` (HLD name) / `script_runner` (the flag Redis 8.10 ships) mark the + // EVAL_RO/EVALSHA_RO/FCALL_RO family, which must not cache. + && !meta.flags.includes('script') + && !meta.flags.includes('script_runner'); +} diff --git a/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts b/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts new file mode 100644 index 00000000000..a50bc7ce6e5 --- /dev/null +++ b/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts @@ -0,0 +1,59 @@ +import { strict as assert } from 'node:assert'; +import { defaultCommandMetadata, isReplicaSafe, isCacheable } from '.'; +import type { CommandIdentifier } from '../client/parser'; + +// Mirrors the readers in cluster/index.ts, sentinel/utils.ts and +// client/index.ts: the predicates derive from the server flags/tips for known +// commands; anything absent from the table (user scripts/functions/unknown +// modules) falls back to the command's hardcoded field. +const id = (command: string, subcommand?: string): CommandIdentifier => ({ command, subcommand }); +const replicaSafe = (i: CommandIdentifier, hardcoded?: boolean) => + isReplicaSafe(defaultCommandMetadata.lookup(i), hardcoded); +const cacheable = (i: CommandIdentifier, hardcoded?: boolean) => + isCacheable(defaultCommandMetadata.lookup(i), hardcoded); + +describe('resolve-then-fallback', () => { + describe('isReplicaSafe (derived from the write flag)', () => { + it('keyed read (no write flag): replica-safe, derived true', () => { + assert.equal(replicaSafe(id('get'), false), true); + }); + + it('keyed write (write flag): not replica-safe, derived false wins over a wrong hardcoded true', () => { + assert.equal(replicaSafe(id('mset'), true), false); + }); + + it('non-data command (neither write nor readonly): replica-safe regardless of hardcoded', () => { + // PING carries no write flag, so it is replica-safe under the !write rule. + assert.equal(replicaSafe(id('ping'), false), true); + }); + + it('unknown command (user script/function): miss -> falls back to hardcoded', () => { + assert.equal(defaultCommandMetadata.lookup(id('definitelynotacommand')), undefined); + assert.equal(replicaSafe(id('definitelynotacommand'), true), true); + assert.equal(replicaSafe(id('definitelynotacommand'), false), false); + }); + }); + + describe('isCacheable (full CSC eligibility)', () => { + it('keyed readonly deterministic: cacheable', () => { + assert.equal(cacheable(id('get')), true); + }); + + it('nondeterministic_output: not cacheable, derived false wins over hardcoded true (XPENDING)', () => { + assert.equal(cacheable(id('xpending'), true), false); + }); + + it('keyless readonly: not cacheable (KEYS/RANDOMKEY are key-invalidation-unsafe)', () => { + assert.equal(cacheable(id('keys')), false); + }); + + it('dont_cache override: not cacheable (read-only script commands, TOUCH)', () => { + assert.equal(cacheable(id('eval_ro'), true), false); + assert.equal(cacheable(id('touch'), true), false); + }); + + it('unknown command: miss -> falls back to hardcoded', () => { + assert.equal(cacheable(id('definitelynotacommand'), true), true); + }); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts similarity index 96% rename from packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts rename to packages/client/lib/command-metadata/static-metadata-resolver.spec.ts index b75109b2b16..b468c5a7d01 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts @@ -1,12 +1,12 @@ import { strict as assert } from 'node:assert'; import { - StaticPolicyResolver, + StaticMetadataResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from '.'; -describe('StaticPolicyResolver', () => { - const resolver = new StaticPolicyResolver(); +describe('StaticMetadataResolver', () => { + const resolver = new StaticMetadataResolver(); describe('subcommand detection', () => { it('FT.SEARCH: second arg is an index name, not a subcommand', () => { @@ -105,7 +105,7 @@ describe('StaticPolicyResolver', () => { describe('fallback', () => { it('falls back to provided resolver on unknown command', () => { - const fallback = new StaticPolicyResolver({ + const fallback = new StaticMetadataResolver({ std: { customping: { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/command-metadata/static-metadata-resolver.ts similarity index 50% rename from packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts rename to packages/client/lib/command-metadata/static-metadata-resolver.ts index 4efc5c0254c..288a7c6f17d 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.ts @@ -1,38 +1,38 @@ -import type { PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; -import { POLICIES } from './static-policies-data'; -import { CommandIdentifier } from '../../client/parser'; -import type { CommandPolicies } from './policies-constants'; +import type { PolicyResult, PolicyResolver, ModuleMetadataRecords, CommandMetadataRecords } from './types'; +import { COMMAND_METADATA } from './command-metadata-data'; +import { CommandIdentifier } from '../client/parser'; +import type { CommandMetadata } from './policies-constants'; -const lowercaseCommandPolicies = (policies: CommandPolicies): CommandPolicies => { - if (!policies.subcommands) return policies; - const subcommands: Record = {}; - for (const [name, sub] of Object.entries(policies.subcommands)) { - subcommands[name.toLowerCase()] = lowercaseCommandPolicies(sub); +const lowercaseCommandMetadata = (metadata: CommandMetadata): CommandMetadata => { + if (!metadata.subcommands) return metadata; + const subcommands: Record = {}; + for (const [name, sub] of Object.entries(metadata.subcommands)) { + subcommands[name.toLowerCase()] = lowercaseCommandMetadata(sub); } - return { ...policies, subcommands }; + return { ...metadata, subcommands }; }; -const lowercaseModulePolicies = (policies: ModulePolicyRecords): ModulePolicyRecords => { - const out: ModulePolicyRecords = {}; - for (const [moduleName, commands] of Object.entries(policies)) { - const normalized: CommandPolicyRecords = {}; - for (const [commandName, policy] of Object.entries(commands)) { - normalized[commandName.toLowerCase()] = lowercaseCommandPolicies(policy); +const lowercaseModuleMetadata = (metadata: ModuleMetadataRecords): ModuleMetadataRecords => { + const out: ModuleMetadataRecords = {}; + for (const [moduleName, commands] of Object.entries(metadata)) { + const normalized: CommandMetadataRecords = {}; + for (const [commandName, entry] of Object.entries(commands)) { + normalized[commandName.toLowerCase()] = lowercaseCommandMetadata(entry); } out[moduleName.toLowerCase()] = normalized; } return out; }; -export class StaticPolicyResolver implements PolicyResolver { +export class StaticMetadataResolver implements PolicyResolver { private readonly fallbackResolver: PolicyResolver | null = null; - private readonly policies: ModulePolicyRecords; + private readonly policies: ModuleMetadataRecords; constructor( - policies: ModulePolicyRecords = POLICIES, + policies: ModuleMetadataRecords = COMMAND_METADATA, fallbackResolver?: PolicyResolver ) { - this.policies = lowercaseModulePolicies(policies); + this.policies = lowercaseModuleMetadata(policies); this.fallbackResolver = fallbackResolver || null; } @@ -40,10 +40,19 @@ export class StaticPolicyResolver implements PolicyResolver { * 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 + * @returns A new StaticMetadataResolver with the specified fallback */ - withFallback(fallbackResolver: PolicyResolver): StaticPolicyResolver { - return new StaticPolicyResolver(this.policies, fallbackResolver); + withFallback(fallbackResolver: PolicyResolver): StaticMetadataResolver { + return new StaticMetadataResolver(this.policies, fallbackResolver); + } + + /** + * Convenience over `resolvePolicy` for the resolve-then-fallback readers: + * returns the resolved metadata or `undefined` on any miss. + */ + lookup(commandIdentifier: CommandIdentifier): CommandMetadata | undefined { + const result = this.resolvePolicy(commandIdentifier); + return result.ok ? result.value : undefined; } resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/command-metadata/types.ts similarity index 61% rename from packages/client/lib/cluster/request-response-policies/types.ts rename to packages/client/lib/command-metadata/types.ts index 027746710a6..863fef1697b 100644 --- a/packages/client/lib/cluster/request-response-policies/types.ts +++ b/packages/client/lib/command-metadata/types.ts @@ -1,11 +1,11 @@ -import { CommandIdentifier } from '../../client/parser'; -import type { CommandPolicies } from './policies-constants'; +import { CommandIdentifier } from '../client/parser'; +import type { CommandMetadata } from './policies-constants'; export type Either = | { readonly ok: true; readonly value: TOk } | { readonly ok: false; readonly error: TError }; -export type PolicyResult = Either; +export type PolicyResult = Either; export interface PolicyResolver { @@ -14,6 +14,13 @@ export interface PolicyResolver { */ resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult; + /** + * Convenience over `resolvePolicy`: returns the resolved metadata or + * `undefined` on any miss, for the resolve-then-fallback predicates + * (`isReplicaSafe(resolver.lookup(id), command.IS_READ_ONLY)`). + */ + lookup(commandIdentifier: CommandIdentifier): CommandMetadata | undefined; + /** * Sets a fallback resolver to use when policies are not found in this resolver. * @@ -23,7 +30,7 @@ export interface PolicyResolver { withFallback(fallbackResolver: PolicyResolver): PolicyResolver; } -export type CommandPolicyRecords = Record; +export type CommandMetadataRecords = Record; // The response of the COMMAND command uses "." to separate the module name from the command name. // For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. -export type ModulePolicyRecords = Record; +export type ModuleMetadataRecords = Record; diff --git a/packages/client/lib/commands/ACL_CAT.ts b/packages/client/lib/commands/ACL_CAT.ts index ae094b732b8..c2f04985abd 100644 --- a/packages/client/lib/commands/ACL_CAT.ts +++ b/packages/client/lib/commands/ACL_CAT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, categoryName?: RedisArgument) { parser.push('ACL', 'CAT'); if (categoryName) { diff --git a/packages/client/lib/commands/ACL_DELUSER.ts b/packages/client/lib/commands/ACL_DELUSER.ts index 5aa66becf75..4214b983427 100644 --- a/packages/client/lib/commands/ACL_DELUSER.ts +++ b/packages/client/lib/commands/ACL_DELUSER.ts @@ -3,8 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisVariadicArgument) { parser.push('ACL', 'DELUSER'); parser.pushVariadic(username); diff --git a/packages/client/lib/commands/ACL_DRYRUN.ts b/packages/client/lib/commands/ACL_DRYRUN.ts index 09a51bc36f8..4fab5bdc652 100644 --- a/packages/client/lib/commands/ACL_DRYRUN.ts +++ b/packages/client/lib/commands/ACL_DRYRUN.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisArgument, command: Array) { parser.push('ACL', 'DRYRUN', username, ...command); }, diff --git a/packages/client/lib/commands/ACL_GENPASS.ts b/packages/client/lib/commands/ACL_GENPASS.ts index b5caa29b9b2..f9363691259 100644 --- a/packages/client/lib/commands/ACL_GENPASS.ts +++ b/packages/client/lib/commands/ACL_GENPASS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, bits?: number) { parser.push('ACL', 'GENPASS'); if (bits) { diff --git a/packages/client/lib/commands/ACL_GETUSER.ts b/packages/client/lib/commands/ACL_GETUSER.ts index b4764ad744e..7572190cdb3 100644 --- a/packages/client/lib/commands/ACL_GETUSER.ts +++ b/packages/client/lib/commands/ACL_GETUSER.ts @@ -18,8 +18,6 @@ type AclUser = TuplesToMapReply<[ ]>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisArgument) { parser.push('ACL', 'GETUSER', username); }, diff --git a/packages/client/lib/commands/ACL_LIST.ts b/packages/client/lib/commands/ACL_LIST.ts index b5f82cf272c..8ee1ed51335 100644 --- a/packages/client/lib/commands/ACL_LIST.ts +++ b/packages/client/lib/commands/ACL_LIST.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'LIST'); }, diff --git a/packages/client/lib/commands/ACL_LOAD.ts b/packages/client/lib/commands/ACL_LOAD.ts index dc4320b99fc..e9e961a8b66 100644 --- a/packages/client/lib/commands/ACL_LOAD.ts +++ b/packages/client/lib/commands/ACL_LOAD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'LOAD'); }, diff --git a/packages/client/lib/commands/ACL_LOG.ts b/packages/client/lib/commands/ACL_LOG.ts index d6f95cec834..224a7e73281 100644 --- a/packages/client/lib/commands/ACL_LOG.ts +++ b/packages/client/lib/commands/ACL_LOG.ts @@ -19,8 +19,6 @@ export type AclLogReply = ArrayReply>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, count?: number) { parser.push('ACL', 'LOG'); if (count != undefined) { diff --git a/packages/client/lib/commands/ACL_LOG_RESET.ts b/packages/client/lib/commands/ACL_LOG_RESET.ts index 9a692129bd2..391fbe85f08 100644 --- a/packages/client/lib/commands/ACL_LOG_RESET.ts +++ b/packages/client/lib/commands/ACL_LOG_RESET.ts @@ -1,10 +1,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; -import ACL_LOG from './ACL_LOG'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: ACL_LOG.IS_READ_ONLY, parseCommand(parser: CommandParser) { parser.push('ACL', 'LOG', 'RESET'); }, diff --git a/packages/client/lib/commands/ACL_SAVE.ts b/packages/client/lib/commands/ACL_SAVE.ts index ec24522724a..8c30bb61787 100644 --- a/packages/client/lib/commands/ACL_SAVE.ts +++ b/packages/client/lib/commands/ACL_SAVE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'SAVE'); }, diff --git a/packages/client/lib/commands/ACL_SETUSER.ts b/packages/client/lib/commands/ACL_SETUSER.ts index cad013f4d15..db4e615b566 100644 --- a/packages/client/lib/commands/ACL_SETUSER.ts +++ b/packages/client/lib/commands/ACL_SETUSER.ts @@ -3,8 +3,6 @@ import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisArgument, rule: RedisVariadicArgument) { parser.push('ACL', 'SETUSER', username); parser.pushVariadic(rule); diff --git a/packages/client/lib/commands/ACL_USERS.ts b/packages/client/lib/commands/ACL_USERS.ts index 6ce4c6d84ef..04e45c84cdc 100644 --- a/packages/client/lib/commands/ACL_USERS.ts +++ b/packages/client/lib/commands/ACL_USERS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'USERS'); }, diff --git a/packages/client/lib/commands/ACL_WHOAMI.ts b/packages/client/lib/commands/ACL_WHOAMI.ts index eb21a75af5f..4dc5b5a1240 100644 --- a/packages/client/lib/commands/ACL_WHOAMI.ts +++ b/packages/client/lib/commands/ACL_WHOAMI.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'WHOAMI'); }, diff --git a/packages/client/lib/commands/APPEND.ts b/packages/client/lib/commands/APPEND.ts index d4b6eda83f4..05b83db26d7 100644 --- a/packages/client/lib/commands/APPEND.ts +++ b/packages/client/lib/commands/APPEND.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, value: RedisArgument) { parser.push('APPEND'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARCOUNT.ts b/packages/client/lib/commands/ARCOUNT.ts index 301b675a3c7..4182def44af 100644 --- a/packages/client/lib/commands/ARCOUNT.ts +++ b/packages/client/lib/commands/ARCOUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ARCOUNT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARGET.ts b/packages/client/lib/commands/ARGET.ts index 6a563ce6754..290f7b906c0 100644 --- a/packages/client/lib/commands/ARGET.ts +++ b/packages/client/lib/commands/ARGET.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, index: number | string) { parser.push('ARGET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARGETRANGE.ts b/packages/client/lib/commands/ARGETRANGE.ts index 800bbd6e210..a55b01997a3 100644 --- a/packages/client/lib/commands/ARGETRANGE.ts +++ b/packages/client/lib/commands/ARGETRANGE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, start: number | string, end: number | string) { parser.push('ARGETRANGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARGREP.ts b/packages/client/lib/commands/ARGREP.ts index decbb5fb397..3e5f63bf344 100644 --- a/packages/client/lib/commands/ARGREP.ts +++ b/packages/client/lib/commands/ARGREP.ts @@ -67,7 +67,6 @@ export function parseArGrepArguments( export type ArGrepArguments = Tail>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, ...args: ArGrepArguments) { parser.push('ARGREP'); parseArGrepArguments(parser, ...args); diff --git a/packages/client/lib/commands/ARGREP_WITHVALUES.ts b/packages/client/lib/commands/ARGREP_WITHVALUES.ts index e27aa46ff89..2d13a5f39b6 100644 --- a/packages/client/lib/commands/ARGREP_WITHVALUES.ts +++ b/packages/client/lib/commands/ARGREP_WITHVALUES.ts @@ -8,7 +8,6 @@ export type ArGrepWithValuesReply = Array<{ }>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, ...args: ArGrepArguments) { parser.push('ARGREP'); parseArGrepArguments(parser, ...args); diff --git a/packages/client/lib/commands/ARINFO.ts b/packages/client/lib/commands/ARINFO.ts index 1c3e78bf00f..b917dd1a11a 100644 --- a/packages/client/lib/commands/ARINFO.ts +++ b/packages/client/lib/commands/ARINFO.ts @@ -9,7 +9,6 @@ export interface ArInfoOptions { export type ArInfoReply = MapReply; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: ArInfoOptions) { parser.push('ARINFO'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARLASTITEMS.ts b/packages/client/lib/commands/ARLASTITEMS.ts index c617a723cf9..d26ba3a6a17 100644 --- a/packages/client/lib/commands/ARLASTITEMS.ts +++ b/packages/client/lib/commands/ARLASTITEMS.ts @@ -6,7 +6,6 @@ export interface ArLastItemsOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ARLEN.ts b/packages/client/lib/commands/ARLEN.ts index eaa8a7b5bea..765fd4a4db3 100644 --- a/packages/client/lib/commands/ARLEN.ts +++ b/packages/client/lib/commands/ARLEN.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ARLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARMGET.ts b/packages/client/lib/commands/ARMGET.ts index 900d58861cc..f27a72614f0 100644 --- a/packages/client/lib/commands/ARMGET.ts +++ b/packages/client/lib/commands/ARMGET.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, indices: number | string | Array) { parser.push('ARMGET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ARNEXT.ts b/packages/client/lib/commands/ARNEXT.ts index 92e74f7b9be..07920f598d4 100644 --- a/packages/client/lib/commands/ARNEXT.ts +++ b/packages/client/lib/commands/ARNEXT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ARNEXT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/AROP.ts b/packages/client/lib/commands/AROP.ts index 3716f75b9a5..e2576a2b5fc 100644 --- a/packages/client/lib/commands/AROP.ts +++ b/packages/client/lib/commands/AROP.ts @@ -15,7 +15,6 @@ export const AR_OPERATIONS = { export type ArOperation = typeof AR_OPERATIONS[keyof typeof AR_OPERATIONS]; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ARSCAN.ts b/packages/client/lib/commands/ARSCAN.ts index 765e86eebd8..45c5d9cd89c 100644 --- a/packages/client/lib/commands/ARSCAN.ts +++ b/packages/client/lib/commands/ARSCAN.ts @@ -11,7 +11,6 @@ export type ArScanReply = Array<{ }>; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ASKING.ts b/packages/client/lib/commands/ASKING.ts index 92ce8f72390..01080e9c45d 100644 --- a/packages/client/lib/commands/ASKING.ts +++ b/packages/client/lib/commands/ASKING.ts @@ -4,8 +4,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; export const ASKING_CMD = 'ASKING'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push(ASKING_CMD); }, diff --git a/packages/client/lib/commands/AUTH.ts b/packages/client/lib/commands/AUTH.ts index 85b48b0026e..e3cda7bb274 100644 --- a/packages/client/lib/commands/AUTH.ts +++ b/packages/client/lib/commands/AUTH.ts @@ -7,8 +7,6 @@ export interface AuthOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, { username, password }: AuthOptions) { parser.push('AUTH'); if (username !== undefined) { diff --git a/packages/client/lib/commands/BGREWRITEAOF.ts b/packages/client/lib/commands/BGREWRITEAOF.ts index c658f3e8529..b587bad1fcc 100644 --- a/packages/client/lib/commands/BGREWRITEAOF.ts +++ b/packages/client/lib/commands/BGREWRITEAOF.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('BGREWRITEAOF'); }, diff --git a/packages/client/lib/commands/BGSAVE.ts b/packages/client/lib/commands/BGSAVE.ts index 1fd6c6b5bdb..445a591e5cb 100644 --- a/packages/client/lib/commands/BGSAVE.ts +++ b/packages/client/lib/commands/BGSAVE.ts @@ -6,8 +6,6 @@ export interface BgSaveOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, options?: BgSaveOptions) { parser.push('BGSAVE'); if (options?.SCHEDULE) { diff --git a/packages/client/lib/commands/BITCOUNT.ts b/packages/client/lib/commands/BITCOUNT.ts index decfb754db5..e139564098e 100644 --- a/packages/client/lib/commands/BITCOUNT.ts +++ b/packages/client/lib/commands/BITCOUNT.ts @@ -8,8 +8,6 @@ export interface BitCountRange { } export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, range?: BitCountRange) { parser.push('BITCOUNT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/BITFIELD.ts b/packages/client/lib/commands/BITFIELD.ts index f095b4cf7a6..c96508e8c1b 100644 --- a/packages/client/lib/commands/BITFIELD.ts +++ b/packages/client/lib/commands/BITFIELD.ts @@ -40,7 +40,6 @@ export type BitFieldRoOperations = Array< >; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, operations: BitFieldOperations) { parser.push('BITFIELD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/BITFIELD_RO.ts b/packages/client/lib/commands/BITFIELD_RO.ts index 66001718b80..7b737aa4247 100644 --- a/packages/client/lib/commands/BITFIELD_RO.ts +++ b/packages/client/lib/commands/BITFIELD_RO.ts @@ -7,8 +7,6 @@ export type BitFieldRoOperations = Array< >; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, operations: BitFieldRoOperations) { parser.push('BITFIELD_RO'); parser.pushKey(key); diff --git a/packages/client/lib/commands/BITOP.ts b/packages/client/lib/commands/BITOP.ts index a766ef963df..7168af5822e 100644 --- a/packages/client/lib/commands/BITOP.ts +++ b/packages/client/lib/commands/BITOP.ts @@ -5,7 +5,6 @@ import { RedisVariadicArgument } from './generic-transformers'; export type BitOperations = 'AND' | 'OR' | 'XOR' | 'NOT' | 'DIFF' | 'DIFF1' | 'ANDOR' | 'ONE'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, operation: BitOperations, diff --git a/packages/client/lib/commands/BITPOS.ts b/packages/client/lib/commands/BITPOS.ts index 57e3a63b681..9d28474b28a 100644 --- a/packages/client/lib/commands/BITPOS.ts +++ b/packages/client/lib/commands/BITPOS.ts @@ -3,8 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { BitValue } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, bit: BitValue, diff --git a/packages/client/lib/commands/BLMOVE.ts b/packages/client/lib/commands/BLMOVE.ts index b0ada7cdb20..f7ee3821f09 100644 --- a/packages/client/lib/commands/BLMOVE.ts +++ b/packages/client/lib/commands/BLMOVE.ts @@ -3,7 +3,6 @@ import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/type import { ListSide } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, source: RedisArgument, diff --git a/packages/client/lib/commands/BLMPOP.ts b/packages/client/lib/commands/BLMPOP.ts index 15d03f8d822..30a01499af6 100644 --- a/packages/client/lib/commands/BLMPOP.ts +++ b/packages/client/lib/commands/BLMPOP.ts @@ -3,7 +3,6 @@ import { Command } from '../RESP/types'; import LMPOP, { LMPopArguments, parseLMPopArguments } from './LMPOP'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, timeout: number, ...args: LMPopArguments) { parser.push('BLMPOP', timeout.toString()); parseLMPopArguments(parser, ...args); diff --git a/packages/client/lib/commands/BLPOP.ts b/packages/client/lib/commands/BLPOP.ts index aa0b30e768e..a01be83fc18 100644 --- a/packages/client/lib/commands/BLPOP.ts +++ b/packages/client/lib/commands/BLPOP.ts @@ -3,7 +3,6 @@ import { UnwrapReply, NullReply, TuplesReply, BlobStringReply, Command } from '. import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisVariadicArgument, timeout: number) { parser.push('BLPOP'); parser.pushKeys(key); diff --git a/packages/client/lib/commands/BRPOP.ts b/packages/client/lib/commands/BRPOP.ts index 401a951556d..b4aae1d67a7 100644 --- a/packages/client/lib/commands/BRPOP.ts +++ b/packages/client/lib/commands/BRPOP.ts @@ -4,7 +4,6 @@ import { RedisVariadicArgument } from './generic-transformers'; import BLPOP from './BLPOP'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisVariadicArgument, timeout: number) { parser.push('BRPOP'); parser.pushKeys(key); diff --git a/packages/client/lib/commands/BRPOPLPUSH.ts b/packages/client/lib/commands/BRPOPLPUSH.ts index 72f63a1c1e5..9a799db9af9 100644 --- a/packages/client/lib/commands/BRPOPLPUSH.ts +++ b/packages/client/lib/commands/BRPOPLPUSH.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, source: RedisArgument, destination: RedisArgument, timeout: number) { parser.push('BRPOPLPUSH'); parser.pushKeys([source, destination]); diff --git a/packages/client/lib/commands/BZMPOP.ts b/packages/client/lib/commands/BZMPOP.ts index 98079b7a20d..523db06280b 100644 --- a/packages/client/lib/commands/BZMPOP.ts +++ b/packages/client/lib/commands/BZMPOP.ts @@ -3,7 +3,6 @@ import { Command } from '../RESP/types'; import ZMPOP, { parseZMPopArguments, ZMPopArguments } from './ZMPOP'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, timeout: number, ...args: ZMPopArguments) { parser.push('BZMPOP', timeout.toString()); parseZMPopArguments(parser, ...args); diff --git a/packages/client/lib/commands/BZPOPMAX.ts b/packages/client/lib/commands/BZPOPMAX.ts index e87bc2ca98e..f4becbd3035 100644 --- a/packages/client/lib/commands/BZPOPMAX.ts +++ b/packages/client/lib/commands/BZPOPMAX.ts @@ -3,7 +3,6 @@ import { NullReply, TuplesReply, BlobStringReply, DoubleReply, UnwrapReply, Comm import { RedisVariadicArgument, transformDoubleReply } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, keys: RedisVariadicArgument, timeout: number) { parser.push('BZPOPMAX'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/BZPOPMIN.ts b/packages/client/lib/commands/BZPOPMIN.ts index 9dc4c47e13d..a1c870d16ea 100644 --- a/packages/client/lib/commands/BZPOPMIN.ts +++ b/packages/client/lib/commands/BZPOPMIN.ts @@ -4,7 +4,6 @@ import { RedisVariadicArgument } from './generic-transformers'; import BZPOPMAX from './BZPOPMAX'; export default { - IS_READ_ONLY: BZPOPMAX.IS_READ_ONLY, parseCommand(parser: CommandParser, keys: RedisVariadicArgument, timeout: number) { parser.push('BZPOPMIN'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/CLIENT_CACHING.ts b/packages/client/lib/commands/CLIENT_CACHING.ts index 9987e49c99b..f52c2f1e2c3 100644 --- a/packages/client/lib/commands/CLIENT_CACHING.ts +++ b/packages/client/lib/commands/CLIENT_CACHING.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_GETNAME.ts b/packages/client/lib/commands/CLIENT_GETNAME.ts index 2e18c43cd5f..49b15109538 100644 --- a/packages/client/lib/commands/CLIENT_GETNAME.ts +++ b/packages/client/lib/commands/CLIENT_GETNAME.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'GETNAME'); }, diff --git a/packages/client/lib/commands/CLIENT_GETREDIR.ts b/packages/client/lib/commands/CLIENT_GETREDIR.ts index 80cc6418dab..c4a4272c010 100644 --- a/packages/client/lib/commands/CLIENT_GETREDIR.ts +++ b/packages/client/lib/commands/CLIENT_GETREDIR.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'GETREDIR'); }, diff --git a/packages/client/lib/commands/CLIENT_ID.ts b/packages/client/lib/commands/CLIENT_ID.ts index da58786ec3c..8a03b528970 100644 --- a/packages/client/lib/commands/CLIENT_ID.ts +++ b/packages/client/lib/commands/CLIENT_ID.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'ID'); }, diff --git a/packages/client/lib/commands/CLIENT_INFO.ts b/packages/client/lib/commands/CLIENT_INFO.ts index 8908bdb2600..9f9002aff13 100644 --- a/packages/client/lib/commands/CLIENT_INFO.ts +++ b/packages/client/lib/commands/CLIENT_INFO.ts @@ -65,8 +65,6 @@ export interface ClientInfoReply { const CLIENT_INFO_REGEX = /([^\s=]+)=([^\s]*)/g; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'INFO'); }, diff --git a/packages/client/lib/commands/CLIENT_KILL.ts b/packages/client/lib/commands/CLIENT_KILL.ts index 24f8f0873f1..1a64d079fa1 100644 --- a/packages/client/lib/commands/CLIENT_KILL.ts +++ b/packages/client/lib/commands/CLIENT_KILL.ts @@ -48,8 +48,6 @@ export interface ClientKillMaxAge extends ClientKillFilterCommon) { parser.push('CLIENT', 'KILL'); diff --git a/packages/client/lib/commands/CLIENT_LIST.ts b/packages/client/lib/commands/CLIENT_LIST.ts index 1e7f3d9ab40..46ad746de48 100644 --- a/packages/client/lib/commands/CLIENT_LIST.ts +++ b/packages/client/lib/commands/CLIENT_LIST.ts @@ -15,8 +15,6 @@ export interface ListFilterId { export type ListFilter = ListFilterType | ListFilterId; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter?: ListFilter) { parser.push('CLIENT', 'LIST'); if (filter) { diff --git a/packages/client/lib/commands/CLIENT_NO-EVICT.ts b/packages/client/lib/commands/CLIENT_NO-EVICT.ts index de2f65270e2..8248146fb61 100644 --- a/packages/client/lib/commands/CLIENT_NO-EVICT.ts +++ b/packages/client/lib/commands/CLIENT_NO-EVICT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_NO-TOUCH.ts b/packages/client/lib/commands/CLIENT_NO-TOUCH.ts index 8c6deff4af5..4c1269a908c 100644 --- a/packages/client/lib/commands/CLIENT_NO-TOUCH.ts +++ b/packages/client/lib/commands/CLIENT_NO-TOUCH.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_PAUSE.ts b/packages/client/lib/commands/CLIENT_PAUSE.ts index ae6e4376364..17d5843de27 100644 --- a/packages/client/lib/commands/CLIENT_PAUSE.ts +++ b/packages/client/lib/commands/CLIENT_PAUSE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, timeout: number, mode?: 'WRITE' | 'ALL') { parser.push('CLIENT', 'PAUSE', timeout.toString()); if (mode) { diff --git a/packages/client/lib/commands/CLIENT_SETNAME.ts b/packages/client/lib/commands/CLIENT_SETNAME.ts index 335891e8308..da26b39ce43 100644 --- a/packages/client/lib/commands/CLIENT_SETNAME.ts +++ b/packages/client/lib/commands/CLIENT_SETNAME.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, name: RedisArgument) { parser.push('CLIENT', 'SETNAME', name); }, diff --git a/packages/client/lib/commands/CLIENT_TRACKING.ts b/packages/client/lib/commands/CLIENT_TRACKING.ts index df70a3705f9..30e4debbf2e 100644 --- a/packages/client/lib/commands/CLIENT_TRACKING.ts +++ b/packages/client/lib/commands/CLIENT_TRACKING.ts @@ -27,8 +27,6 @@ export type ClientTrackingOptions = CommonOptions & ( ); export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, mode: M, diff --git a/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts b/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts index fe6e090455c..f19c960d26c 100644 --- a/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts +++ b/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts @@ -8,8 +8,6 @@ type TrackingInfo = TuplesToMapReply<[ ]>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'TRACKINGINFO'); }, diff --git a/packages/client/lib/commands/CLIENT_UNBLOCK.ts b/packages/client/lib/commands/CLIENT_UNBLOCK.ts index 7e285909500..b9282f70538 100644 --- a/packages/client/lib/commands/CLIENT_UNBLOCK.ts +++ b/packages/client/lib/commands/CLIENT_UNBLOCK.ts @@ -9,8 +9,6 @@ export const CLIENT_UNBLOCK_MODES = { export type ClientUnblockMode = typeof CLIENT_UNBLOCK_MODES[keyof typeof CLIENT_UNBLOCK_MODES]; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, clientId: number | `${number}`, mode?: ClientUnblockMode) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_UNPAUSE.ts b/packages/client/lib/commands/CLIENT_UNPAUSE.ts index c202e50a5df..c96a316cad4 100644 --- a/packages/client/lib/commands/CLIENT_UNPAUSE.ts +++ b/packages/client/lib/commands/CLIENT_UNPAUSE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'UNPAUSE'); }, diff --git a/packages/client/lib/commands/CLUSTER_ADDSLOTS.ts b/packages/client/lib/commands/CLUSTER_ADDSLOTS.ts index 0f5c4513d1d..d491fb1aaa0 100644 --- a/packages/client/lib/commands/CLUSTER_ADDSLOTS.ts +++ b/packages/client/lib/commands/CLUSTER_ADDSLOTS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, slots: number | Array) { parser.push('CLUSTER', 'ADDSLOTS'); parser.pushVariadicNumber(slots); diff --git a/packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts b/packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts index 40780731981..99f75bf89d9 100644 --- a/packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts +++ b/packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts @@ -3,8 +3,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; import { parseSlotRangesArguments, SlotRange } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, ranges: SlotRange | Array) { parser.push('CLUSTER', 'ADDSLOTSRANGE'); parseSlotRangesArguments(parser, ranges); diff --git a/packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts b/packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts index 04b62f85424..be35f733f49 100644 --- a/packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts +++ b/packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'BUMPEPOCH'); }, diff --git a/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts b/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts index 0ac311f7ecd..d6885bee6c0 100644 --- a/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts +++ b/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'COUNT-FAILURE-REPORTS', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts b/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts index 63b4a8e02e2..3a06c0cafe1 100644 --- a/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, slot: number) { parser.push('CLUSTER', 'COUNTKEYSINSLOT', slot.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_DELSLOTS.ts b/packages/client/lib/commands/CLUSTER_DELSLOTS.ts index 9be6e962a18..48aed216358 100644 --- a/packages/client/lib/commands/CLUSTER_DELSLOTS.ts +++ b/packages/client/lib/commands/CLUSTER_DELSLOTS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, slots: number | Array) { parser.push('CLUSTER', 'DELSLOTS'); parser.pushVariadicNumber(slots); diff --git a/packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts b/packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts index 64c04021ba1..c49c2b19bad 100644 --- a/packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts +++ b/packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts @@ -3,8 +3,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; import { parseSlotRangesArguments, SlotRange } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser:CommandParser, ranges: SlotRange | Array) { parser.push('CLUSTER', 'DELSLOTSRANGE'); parseSlotRangesArguments(parser, ranges); diff --git a/packages/client/lib/commands/CLUSTER_FAILOVER.ts b/packages/client/lib/commands/CLUSTER_FAILOVER.ts index f74d65bd691..0e124e2a52c 100644 --- a/packages/client/lib/commands/CLUSTER_FAILOVER.ts +++ b/packages/client/lib/commands/CLUSTER_FAILOVER.ts @@ -13,8 +13,6 @@ export interface ClusterFailoverOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser:CommandParser, options?: ClusterFailoverOptions) { parser.push('CLUSTER', 'FAILOVER'); diff --git a/packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts b/packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts index dab22b2e740..de5a8b0d008 100644 --- a/packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts +++ b/packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'FLUSHSLOTS'); }, diff --git a/packages/client/lib/commands/CLUSTER_FORGET.ts b/packages/client/lib/commands/CLUSTER_FORGET.ts index 2928c3e9075..3593a7babd7 100644 --- a/packages/client/lib/commands/CLUSTER_FORGET.ts +++ b/packages/client/lib/commands/CLUSTER_FORGET.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'FORGET', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts b/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts index 2fd630ea1af..271f17d838f 100644 --- a/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, slot: number, count: number) { parser.push('CLUSTER', 'GETKEYSINSLOT', slot.toString(), count.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_INFO.ts b/packages/client/lib/commands/CLUSTER_INFO.ts index 53140b38819..f3eac2bd06b 100644 --- a/packages/client/lib/commands/CLUSTER_INFO.ts +++ b/packages/client/lib/commands/CLUSTER_INFO.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { VerbatimStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'INFO'); }, diff --git a/packages/client/lib/commands/CLUSTER_KEYSLOT.ts b/packages/client/lib/commands/CLUSTER_KEYSLOT.ts index a7f7de2d7bd..d7d64ddb49c 100644 --- a/packages/client/lib/commands/CLUSTER_KEYSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_KEYSLOT.ts @@ -2,13 +2,11 @@ import { CommandParser } from '../client/parser'; import { Command, NumberReply, RedisArgument } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('CLUSTER', 'KEYSLOT'); // Use pushKey so a configured `keyPrefix` is applied to the reported key: the returned - // slot then matches where prefixed commands for `key` are actually routed. The command - // stays NOT_KEYED_COMMAND because its result is identical on any node. + // slot then matches where prefixed commands for `key` are actually routed. The command's + // result is identical on any node, so key-based routing is irrelevant to correctness. parser.pushKey(key); }, transformReply: undefined as unknown as () => NumberReply diff --git a/packages/client/lib/commands/CLUSTER_LINKS.ts b/packages/client/lib/commands/CLUSTER_LINKS.ts index e98f61e762b..98495a23c52 100644 --- a/packages/client/lib/commands/CLUSTER_LINKS.ts +++ b/packages/client/lib/commands/CLUSTER_LINKS.ts @@ -11,8 +11,6 @@ type ClusterLinksReply = ArrayReply>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'LINKS'); }, diff --git a/packages/client/lib/commands/CLUSTER_MEET.ts b/packages/client/lib/commands/CLUSTER_MEET.ts index 804e5963d19..b7a4cc9e1f8 100644 --- a/packages/client/lib/commands/CLUSTER_MEET.ts +++ b/packages/client/lib/commands/CLUSTER_MEET.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, host: string, port: number) { parser.push('CLUSTER', 'MEET', host, port.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_MYID.ts b/packages/client/lib/commands/CLUSTER_MYID.ts index 2aae7cdd8e0..24ae955e0de 100644 --- a/packages/client/lib/commands/CLUSTER_MYID.ts +++ b/packages/client/lib/commands/CLUSTER_MYID.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'MYID'); }, diff --git a/packages/client/lib/commands/CLUSTER_MYSHARDID.ts b/packages/client/lib/commands/CLUSTER_MYSHARDID.ts index ccde3ee249b..6539adb86e8 100644 --- a/packages/client/lib/commands/CLUSTER_MYSHARDID.ts +++ b/packages/client/lib/commands/CLUSTER_MYSHARDID.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'MYSHARDID'); }, diff --git a/packages/client/lib/commands/CLUSTER_NODES.ts b/packages/client/lib/commands/CLUSTER_NODES.ts index c8b59f88224..115e85e329f 100644 --- a/packages/client/lib/commands/CLUSTER_NODES.ts +++ b/packages/client/lib/commands/CLUSTER_NODES.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { VerbatimStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'NODES'); }, diff --git a/packages/client/lib/commands/CLUSTER_REPLICAS.ts b/packages/client/lib/commands/CLUSTER_REPLICAS.ts index eb60e560b45..ed395a5f479 100644 --- a/packages/client/lib/commands/CLUSTER_REPLICAS.ts +++ b/packages/client/lib/commands/CLUSTER_REPLICAS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'REPLICAS', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_REPLICATE.ts b/packages/client/lib/commands/CLUSTER_REPLICATE.ts index d7312ae108e..5bffde3ee70 100644 --- a/packages/client/lib/commands/CLUSTER_REPLICATE.ts +++ b/packages/client/lib/commands/CLUSTER_REPLICATE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'REPLICATE', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_RESET.ts b/packages/client/lib/commands/CLUSTER_RESET.ts index 2ba1a6eaf20..209e424e7a3 100644 --- a/packages/client/lib/commands/CLUSTER_RESET.ts +++ b/packages/client/lib/commands/CLUSTER_RESET.ts @@ -6,8 +6,6 @@ export interface ClusterResetOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, options?: ClusterResetOptions) { parser.push('CLUSTER', 'RESET'); diff --git a/packages/client/lib/commands/CLUSTER_SAVECONFIG.ts b/packages/client/lib/commands/CLUSTER_SAVECONFIG.ts index 08da2a45b89..dca1f589d46 100644 --- a/packages/client/lib/commands/CLUSTER_SAVECONFIG.ts +++ b/packages/client/lib/commands/CLUSTER_SAVECONFIG.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'SAVECONFIG'); }, diff --git a/packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts b/packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts index ba423df7fb7..108a46f40bb 100644 --- a/packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts +++ b/packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, configEpoch: number) { parser.push('CLUSTER', 'SET-CONFIG-EPOCH', configEpoch.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_SETSLOT.ts b/packages/client/lib/commands/CLUSTER_SETSLOT.ts index 1f74316a3f3..25d368b4d8a 100644 --- a/packages/client/lib/commands/CLUSTER_SETSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_SETSLOT.ts @@ -11,8 +11,6 @@ export const CLUSTER_SLOT_STATES = { export type ClusterSlotState = typeof CLUSTER_SLOT_STATES[keyof typeof CLUSTER_SLOT_STATES]; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, slot: number, state: ClusterSlotState, nodeId?: RedisArgument) { parser.push('CLUSTER', 'SETSLOT', slot.toString(), state); diff --git a/packages/client/lib/commands/CLUSTER_SLOTS.ts b/packages/client/lib/commands/CLUSTER_SLOTS.ts index f6f967abe28..9725161e9fc 100644 --- a/packages/client/lib/commands/CLUSTER_SLOTS.ts +++ b/packages/client/lib/commands/CLUSTER_SLOTS.ts @@ -17,8 +17,6 @@ type ClusterSlotsRawReply = ArrayReply<[ export type ClusterSlotsNode = ReturnType; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'SLOTS'); }, diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 6646dffd76a..908006694d3 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -26,6 +26,8 @@ describe('COMMAND', () => { categories: new Set([CommandCategories.FAST]), policies: { request: undefined, response: undefined }, isKeyless: true, + nondeterministicOutput: false, + tips: [], keySpecs: [], subcommands: [] } @@ -43,6 +45,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + nondeterministicOutput: false, + tips: [], keySpecs: [], subcommands: [] } @@ -60,6 +64,9 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: false, + nondeterministicOutput: false, + // invalid policy tips are still recognized as policy tips and dropped + tips: [], keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } @@ -77,6 +84,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + nondeterministicOutput: false, + tips: [], keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } @@ -94,23 +103,9 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, isKeyless: true, - keySpecs: [], - 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, + nondeterministicOutput: false, + // the leading '' is a non-policy tip and is mirrored verbatim + tips: [''], keySpecs: [], subcommands: [] } @@ -130,6 +125,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'special' }, isKeyless: true, + nondeterministicOutput: true, + tips: ['nondeterministic_output'], keySpecs: [], subcommands: [] } @@ -148,6 +145,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + nondeterministicOutput: true, + tips: ['nondeterministic_output'], keySpecs: [], subcommands: [] } @@ -167,6 +166,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + nondeterministicOutput: true, + tips: ['nondeterministic_output'], keySpecs: [], subcommands: [] } @@ -184,6 +185,8 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: true, + nondeterministicOutput: true, + tips: ['nondeterministic_output', 'nondeterministic_output_order'], keySpecs: [], subcommands: [] } diff --git a/packages/client/lib/commands/COMMAND.ts b/packages/client/lib/commands/COMMAND.ts index 52eb7eb2fea..7fb7d84e885 100644 --- a/packages/client/lib/commands/COMMAND.ts +++ b/packages/client/lib/commands/COMMAND.ts @@ -3,8 +3,6 @@ import { ArrayReply, Command, UnwrapReply } from '../RESP/types'; import { CommandRawReply, CommandReply, transformCommandReply } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('COMMAND'); }, diff --git a/packages/client/lib/commands/COMMAND_COUNT.ts b/packages/client/lib/commands/COMMAND_COUNT.ts index ef561920b0b..27ab786490f 100644 --- a/packages/client/lib/commands/COMMAND_COUNT.ts +++ b/packages/client/lib/commands/COMMAND_COUNT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('COMMAND', 'COUNT'); }, diff --git a/packages/client/lib/commands/COMMAND_GETKEYS.ts b/packages/client/lib/commands/COMMAND_GETKEYS.ts index 97c5cb69ce2..2573ed5b7b9 100644 --- a/packages/client/lib/commands/COMMAND_GETKEYS.ts +++ b/packages/client/lib/commands/COMMAND_GETKEYS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, args: Array) { parser.push('COMMAND', 'GETKEYS'); parser.push(...args); diff --git a/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts b/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts index 72c1e16a2d1..3a210b0ac82 100644 --- a/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts +++ b/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts @@ -7,8 +7,6 @@ export type CommandGetKeysAndFlagsRawReply = ArrayReply>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, args: Array) { parser.push('COMMAND', 'GETKEYSANDFLAGS'); parser.push(...args); diff --git a/packages/client/lib/commands/COMMAND_INFO.ts b/packages/client/lib/commands/COMMAND_INFO.ts index fdf03780652..a4396e43271 100644 --- a/packages/client/lib/commands/COMMAND_INFO.ts +++ b/packages/client/lib/commands/COMMAND_INFO.ts @@ -3,8 +3,6 @@ import { ArrayReply, Command, UnwrapReply } from '../RESP/types'; import { CommandRawReply, CommandReply, transformCommandReply } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, commands: Array) { parser.push('COMMAND', 'INFO', ...commands); }, diff --git a/packages/client/lib/commands/COMMAND_LIST.ts b/packages/client/lib/commands/COMMAND_LIST.ts index ba518b70eca..4e1770b57e5 100644 --- a/packages/client/lib/commands/COMMAND_LIST.ts +++ b/packages/client/lib/commands/COMMAND_LIST.ts @@ -17,8 +17,6 @@ export interface CommandListOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, options?: CommandListOptions) { parser.push('COMMAND', 'LIST'); diff --git a/packages/client/lib/commands/CONFIG_GET.ts b/packages/client/lib/commands/CONFIG_GET.ts index e8339c4d9a0..47dff705b30 100644 --- a/packages/client/lib/commands/CONFIG_GET.ts +++ b/packages/client/lib/commands/CONFIG_GET.ts @@ -3,8 +3,6 @@ import { MapReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument, transformTuplesReply } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, parameters: RedisVariadicArgument) { parser.push('CONFIG', 'GET'); parser.pushVariadic(parameters); diff --git a/packages/client/lib/commands/CONFIG_RESETSTAT.ts b/packages/client/lib/commands/CONFIG_RESETSTAT.ts index 15de5ba7808..c6c108e9cc3 100644 --- a/packages/client/lib/commands/CONFIG_RESETSTAT.ts +++ b/packages/client/lib/commands/CONFIG_RESETSTAT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CONFIG', 'RESETSTAT'); }, diff --git a/packages/client/lib/commands/CONFIG_REWRITE.ts b/packages/client/lib/commands/CONFIG_REWRITE.ts index ae6712ffb57..98a53adf294 100644 --- a/packages/client/lib/commands/CONFIG_REWRITE.ts +++ b/packages/client/lib/commands/CONFIG_REWRITE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CONFIG', 'REWRITE'); }, diff --git a/packages/client/lib/commands/CONFIG_SET.ts b/packages/client/lib/commands/CONFIG_SET.ts index dd1bbc29ef2..039a32e04d5 100644 --- a/packages/client/lib/commands/CONFIG_SET.ts +++ b/packages/client/lib/commands/CONFIG_SET.ts @@ -6,8 +6,6 @@ type SingleParameter = [parameter: RedisArgument, value: RedisArgument]; type MultipleParameters = [config: Record]; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, ...[parameterOrConfig, value]: SingleParameter | MultipleParameters diff --git a/packages/client/lib/commands/COPY.ts b/packages/client/lib/commands/COPY.ts index 8caa66822ef..3419e94ac05 100644 --- a/packages/client/lib/commands/COPY.ts +++ b/packages/client/lib/commands/COPY.ts @@ -7,7 +7,6 @@ export interface CopyCommandOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, source: RedisArgument, destination: RedisArgument, options?: CopyCommandOptions) { parser.push('COPY'); parser.pushKeys([source, destination]); diff --git a/packages/client/lib/commands/DBSIZE.ts b/packages/client/lib/commands/DBSIZE.ts index 1ba1f060476..330507894b0 100644 --- a/packages/client/lib/commands/DBSIZE.ts +++ b/packages/client/lib/commands/DBSIZE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('DBSIZE'); }, diff --git a/packages/client/lib/commands/DEL.ts b/packages/client/lib/commands/DEL.ts index da0803f4d1b..a1a86ab85bb 100644 --- a/packages/client/lib/commands/DEL.ts +++ b/packages/client/lib/commands/DEL.ts @@ -3,7 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('DEL'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/DELEX.ts b/packages/client/lib/commands/DELEX.ts index 10b1cfee0c4..3946af8d165 100644 --- a/packages/client/lib/commands/DELEX.ts +++ b/packages/client/lib/commands/DELEX.ts @@ -23,7 +23,6 @@ export const DelexCondition = { type DelexCondition = (typeof DelexCondition)[keyof typeof DelexCondition]; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/DIGEST.ts b/packages/client/lib/commands/DIGEST.ts index 64c32da983a..4d6dc1ee4b3 100644 --- a/packages/client/lib/commands/DIGEST.ts +++ b/packages/client/lib/commands/DIGEST.ts @@ -2,7 +2,6 @@ import { CommandParser } from "../client/parser"; import { Command, RedisArgument, SimpleStringReply } from "../RESP/types"; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push("DIGEST"); parser.pushKey(key); diff --git a/packages/client/lib/commands/DUMP.ts b/packages/client/lib/commands/DUMP.ts index e442c1cdb2f..cb719fa0f46 100644 --- a/packages/client/lib/commands/DUMP.ts +++ b/packages/client/lib/commands/DUMP.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('DUMP'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ECHO.ts b/packages/client/lib/commands/ECHO.ts index 7935bdc0101..dea50c947ea 100644 --- a/packages/client/lib/commands/ECHO.ts +++ b/packages/client/lib/commands/ECHO.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, message: RedisArgument) { parser.push('ECHO', message); }, diff --git a/packages/client/lib/commands/EVAL.ts b/packages/client/lib/commands/EVAL.ts index cdb8025b0be..490b786599c 100644 --- a/packages/client/lib/commands/EVAL.ts +++ b/packages/client/lib/commands/EVAL.ts @@ -24,7 +24,6 @@ export function parseEvalArguments( } export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('EVAL'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/EVALSHA.ts b/packages/client/lib/commands/EVALSHA.ts index 5a9cc771358..c0738ee439e 100644 --- a/packages/client/lib/commands/EVALSHA.ts +++ b/packages/client/lib/commands/EVALSHA.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('EVALSHA'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/EVALSHA_RO.ts b/packages/client/lib/commands/EVALSHA_RO.ts index 24fadb3f486..0533423f91f 100644 --- a/packages/client/lib/commands/EVALSHA_RO.ts +++ b/packages/client/lib/commands/EVALSHA_RO.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('EVALSHA_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/EVAL_RO.ts b/packages/client/lib/commands/EVAL_RO.ts index 2438fd9d1dd..b4a50f3c9d6 100644 --- a/packages/client/lib/commands/EVAL_RO.ts +++ b/packages/client/lib/commands/EVAL_RO.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('EVAL_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/EXISTS.ts b/packages/client/lib/commands/EXISTS.ts index 8ebb28269fe..91090c8ac48 100644 --- a/packages/client/lib/commands/EXISTS.ts +++ b/packages/client/lib/commands/EXISTS.ts @@ -3,8 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('EXISTS'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/EXPIRETIME.ts b/packages/client/lib/commands/EXPIRETIME.ts index 2bb97fb737b..ca8c7d1dbe8 100644 --- a/packages/client/lib/commands/EXPIRETIME.ts +++ b/packages/client/lib/commands/EXPIRETIME.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('EXPIRETIME'); parser.pushKey(key); diff --git a/packages/client/lib/commands/FCALL.ts b/packages/client/lib/commands/FCALL.ts index 622060f693c..871b823252f 100644 --- a/packages/client/lib/commands/FCALL.ts +++ b/packages/client/lib/commands/FCALL.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('FCALL'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/FCALL_RO.ts b/packages/client/lib/commands/FCALL_RO.ts index 95effb0e698..cafda712d1c 100644 --- a/packages/client/lib/commands/FCALL_RO.ts +++ b/packages/client/lib/commands/FCALL_RO.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('FCALL_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/FLUSHALL.ts b/packages/client/lib/commands/FLUSHALL.ts index c39535e8864..e9997b2b6ef 100644 --- a/packages/client/lib/commands/FLUSHALL.ts +++ b/packages/client/lib/commands/FLUSHALL.ts @@ -9,8 +9,6 @@ export const REDIS_FLUSH_MODES = { export type RedisFlushMode = typeof REDIS_FLUSH_MODES[keyof typeof REDIS_FLUSH_MODES]; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, mode?: RedisFlushMode) { parser.push('FLUSHALL'); if (mode) { diff --git a/packages/client/lib/commands/FLUSHDB.ts b/packages/client/lib/commands/FLUSHDB.ts index 5639f69a611..fb1143e86fa 100644 --- a/packages/client/lib/commands/FLUSHDB.ts +++ b/packages/client/lib/commands/FLUSHDB.ts @@ -3,8 +3,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; import { RedisFlushMode } from './FLUSHALL'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, mode?: RedisFlushMode) { parser.push('FLUSHDB'); if (mode) { diff --git a/packages/client/lib/commands/FUNCTION_DELETE.ts b/packages/client/lib/commands/FUNCTION_DELETE.ts index dbfb044928e..16dfcebd348 100644 --- a/packages/client/lib/commands/FUNCTION_DELETE.ts +++ b/packages/client/lib/commands/FUNCTION_DELETE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, library: RedisArgument) { parser.push('FUNCTION', 'DELETE', library); }, diff --git a/packages/client/lib/commands/FUNCTION_DUMP.ts b/packages/client/lib/commands/FUNCTION_DUMP.ts index 2d0dbdd4455..7812e44fe48 100644 --- a/packages/client/lib/commands/FUNCTION_DUMP.ts +++ b/packages/client/lib/commands/FUNCTION_DUMP.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('FUNCTION', 'DUMP') }, diff --git a/packages/client/lib/commands/FUNCTION_FLUSH.ts b/packages/client/lib/commands/FUNCTION_FLUSH.ts index 4ca59e4464e..d07ae23785e 100644 --- a/packages/client/lib/commands/FUNCTION_FLUSH.ts +++ b/packages/client/lib/commands/FUNCTION_FLUSH.ts @@ -3,8 +3,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; import { RedisFlushMode } from './FLUSHALL'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, mode?: RedisFlushMode) { parser.push('FUNCTION', 'FLUSH'); diff --git a/packages/client/lib/commands/FUNCTION_KILL.ts b/packages/client/lib/commands/FUNCTION_KILL.ts index 8b5351e93ab..2e427c46623 100644 --- a/packages/client/lib/commands/FUNCTION_KILL.ts +++ b/packages/client/lib/commands/FUNCTION_KILL.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('FUNCTION', 'KILL'); }, diff --git a/packages/client/lib/commands/FUNCTION_LIST.ts b/packages/client/lib/commands/FUNCTION_LIST.ts index 82e3697eadc..5516ed62dfa 100644 --- a/packages/client/lib/commands/FUNCTION_LIST.ts +++ b/packages/client/lib/commands/FUNCTION_LIST.ts @@ -18,8 +18,6 @@ export type FunctionListReplyItem = [ export type FunctionListReply = ArrayReply>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, options?: FunctionListOptions) { parser.push('FUNCTION', 'LIST'); diff --git a/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts b/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts index 208bc5fd303..e7104990819 100644 --- a/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts +++ b/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts @@ -7,8 +7,6 @@ export type FunctionListWithCodeReply = ArrayReply>; export default { - NOT_KEYED_COMMAND: FUNCTION_LIST.NOT_KEYED_COMMAND, - IS_READ_ONLY: FUNCTION_LIST.IS_READ_ONLY, parseCommand(...args: Parameters) { FUNCTION_LIST.parseCommand(...args); args[0].push('WITHCODE'); diff --git a/packages/client/lib/commands/FUNCTION_LOAD.ts b/packages/client/lib/commands/FUNCTION_LOAD.ts index 40b8ea8c0f4..2b2c21164ad 100644 --- a/packages/client/lib/commands/FUNCTION_LOAD.ts +++ b/packages/client/lib/commands/FUNCTION_LOAD.ts @@ -6,8 +6,6 @@ export interface FunctionLoadOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, code: RedisArgument, options?: FunctionLoadOptions) { parser.push('FUNCTION', 'LOAD'); diff --git a/packages/client/lib/commands/FUNCTION_RESTORE.ts b/packages/client/lib/commands/FUNCTION_RESTORE.ts index 944813f25e5..6191e0e31e6 100644 --- a/packages/client/lib/commands/FUNCTION_RESTORE.ts +++ b/packages/client/lib/commands/FUNCTION_RESTORE.ts @@ -6,8 +6,6 @@ export interface FunctionRestoreOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, dump: RedisArgument, options?: FunctionRestoreOptions) { parser.push('FUNCTION', 'RESTORE', dump); diff --git a/packages/client/lib/commands/FUNCTION_STATS.ts b/packages/client/lib/commands/FUNCTION_STATS.ts index 9e418e80860..cca3bb48211 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.ts @@ -21,8 +21,6 @@ type FunctionStatsReply = TuplesToMapReply<[ ]>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('FUNCTION', 'STATS'); }, diff --git a/packages/client/lib/commands/GEOADD.ts b/packages/client/lib/commands/GEOADD.ts index 31bf457e158..89df0dcbe82 100644 --- a/packages/client/lib/commands/GEOADD.ts +++ b/packages/client/lib/commands/GEOADD.ts @@ -20,7 +20,6 @@ export interface GeoAddOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEODIST.ts b/packages/client/lib/commands/GEODIST.ts index ba4d3080a71..64e9cba8cdb 100644 --- a/packages/client/lib/commands/GEODIST.ts +++ b/packages/client/lib/commands/GEODIST.ts @@ -3,8 +3,6 @@ import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/type import { GeoUnits } from './GEOSEARCH'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member1: RedisArgument, diff --git a/packages/client/lib/commands/GEOHASH.ts b/packages/client/lib/commands/GEOHASH.ts index c3265d13157..2aeba39095e 100644 --- a/packages/client/lib/commands/GEOHASH.ts +++ b/packages/client/lib/commands/GEOHASH.ts @@ -3,8 +3,6 @@ import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/typ import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisVariadicArgument) { parser.push('GEOHASH'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GEOPOS.ts b/packages/client/lib/commands/GEOPOS.ts index 6bdbb65ac46..b457bdb9121 100644 --- a/packages/client/lib/commands/GEOPOS.ts +++ b/packages/client/lib/commands/GEOPOS.ts @@ -3,8 +3,6 @@ import { RedisArgument, ArrayReply, TuplesReply, BlobStringReply, NullReply, Unw import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisVariadicArgument) { parser.push('GEOPOS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GEORADIUS.ts b/packages/client/lib/commands/GEORADIUS.ts index 5e8d880ab5e..2de623a1ba1 100644 --- a/packages/client/lib/commands/GEORADIUS.ts +++ b/packages/client/lib/commands/GEORADIUS.ts @@ -17,7 +17,6 @@ export function parseGeoRadiusArguments( } export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { args[0].push('GEORADIUS'); return parseGeoRadiusArguments(...args); diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER.ts index be4ca54650c..622b0d1fa55 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER.ts @@ -17,7 +17,6 @@ export function parseGeoRadiusByMemberArguments( } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO.ts index 335eea08133..8c9956c6d10 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO.ts @@ -2,8 +2,6 @@ import { Command } from '../RESP/types'; import GEORADIUSBYMEMBER, { parseGeoRadiusByMemberArguments } from './GEORADIUSBYMEMBER'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; parser.push('GEORADIUSBYMEMBER_RO'); diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.ts index 06835438016..a1e4be74629 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.ts @@ -2,8 +2,6 @@ import { Command } from '../RESP/types'; import GEORADIUSBYMEMBER_WITH, { parseGeoRadiusByMemberWithArguments } from './GEORADIUSBYMEMBER_WITH'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; parser.push('GEORADIUSBYMEMBER_RO'); diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_STORE.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_STORE.ts index 676df34dd5a..22f4bb1f4bb 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_STORE.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_STORE.ts @@ -1,6 +1,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; -import GEORADIUSBYMEMBER, { parseGeoRadiusByMemberArguments } from './GEORADIUSBYMEMBER'; +import { parseGeoRadiusByMemberArguments } from './GEORADIUSBYMEMBER'; import { GeoSearchOptions, GeoUnits } from './GEOSEARCH'; export interface GeoRadiusStoreOptions extends GeoSearchOptions { @@ -8,7 +8,6 @@ export interface GeoRadiusStoreOptions extends GeoSearchOptions { } export default { - IS_READ_ONLY: GEORADIUSBYMEMBER.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.ts index eefae0b27a9..011014af1ab 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.ts @@ -1,6 +1,5 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, Command } from '../RESP/types'; -import GEORADIUSBYMEMBER from './GEORADIUSBYMEMBER'; import { GeoSearchOptions, GeoUnits, parseGeoSearchOptions } from './GEOSEARCH'; import GEOSEARCH_WITH, { GeoReplyWith } from './GEOSEARCH_WITH'; @@ -22,7 +21,6 @@ export function parseGeoRadiusByMemberWithArguments( } export default { - IS_READ_ONLY: GEORADIUSBYMEMBER.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEORADIUS_RO.ts b/packages/client/lib/commands/GEORADIUS_RO.ts index 5db65d9dc9b..230c49fc22a 100644 --- a/packages/client/lib/commands/GEORADIUS_RO.ts +++ b/packages/client/lib/commands/GEORADIUS_RO.ts @@ -2,8 +2,6 @@ import { Command } from '../RESP/types'; import GEORADIUS, { parseGeoRadiusArguments } from './GEORADIUS'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('GEORADIUS_RO'); parseGeoRadiusArguments(...args); diff --git a/packages/client/lib/commands/GEORADIUS_RO_WITH.ts b/packages/client/lib/commands/GEORADIUS_RO_WITH.ts index cee1679382b..7c683dcaffd 100644 --- a/packages/client/lib/commands/GEORADIUS_RO_WITH.ts +++ b/packages/client/lib/commands/GEORADIUS_RO_WITH.ts @@ -3,8 +3,6 @@ import { parseGeoRadiusWithArguments } from './GEORADIUS_WITH'; import GEORADIUS_WITH from './GEORADIUS_WITH'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('GEORADIUS_RO'); parseGeoRadiusWithArguments(...args); diff --git a/packages/client/lib/commands/GEORADIUS_STORE.ts b/packages/client/lib/commands/GEORADIUS_STORE.ts index 18459d44217..f9dfca5e55a 100644 --- a/packages/client/lib/commands/GEORADIUS_STORE.ts +++ b/packages/client/lib/commands/GEORADIUS_STORE.ts @@ -1,6 +1,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; -import GEORADIUS, { parseGeoRadiusArguments } from './GEORADIUS'; +import { parseGeoRadiusArguments } from './GEORADIUS'; import { GeoCoordinates, GeoSearchOptions, GeoUnits } from './GEOSEARCH'; export interface GeoRadiusStoreOptions extends GeoSearchOptions { @@ -8,7 +8,6 @@ export interface GeoRadiusStoreOptions extends GeoSearchOptions { } export default { - IS_READ_ONLY: GEORADIUS.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEORADIUS_WITH.ts b/packages/client/lib/commands/GEORADIUS_WITH.ts index ac4c8b7bb1b..c21ea5f556d 100644 --- a/packages/client/lib/commands/GEORADIUS_WITH.ts +++ b/packages/client/lib/commands/GEORADIUS_WITH.ts @@ -1,6 +1,6 @@ import { CommandParser } from '../client/parser'; import { Command, RedisArgument } from '../RESP/types'; -import GEORADIUS, { parseGeoRadiusArguments } from './GEORADIUS'; +import { parseGeoRadiusArguments } from './GEORADIUS'; import { GeoCoordinates, GeoSearchOptions, GeoUnits } from './GEOSEARCH'; import GEOSEARCH_WITH, { GeoReplyWith } from './GEOSEARCH_WITH'; @@ -19,7 +19,6 @@ export function parseGeoRadiusWithArguments( } export default { - IS_READ_ONLY: GEORADIUS.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEOSEARCH.ts b/packages/client/lib/commands/GEOSEARCH.ts index 869dc60bec9..348bc1834e3 100644 --- a/packages/client/lib/commands/GEOSEARCH.ts +++ b/packages/client/lib/commands/GEOSEARCH.ts @@ -79,7 +79,6 @@ export function parseGeoSearchOptions( } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GEOSEARCHSTORE.ts b/packages/client/lib/commands/GEOSEARCHSTORE.ts index 34c6e0988e2..f080019366d 100644 --- a/packages/client/lib/commands/GEOSEARCHSTORE.ts +++ b/packages/client/lib/commands/GEOSEARCHSTORE.ts @@ -7,7 +7,6 @@ export interface GeoSearchStoreOptions extends GeoSearchOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/client/lib/commands/GEOSEARCH_WITH.ts b/packages/client/lib/commands/GEOSEARCH_WITH.ts index 2cf7132f7ae..224b9e22227 100644 --- a/packages/client/lib/commands/GEOSEARCH_WITH.ts +++ b/packages/client/lib/commands/GEOSEARCH_WITH.ts @@ -23,7 +23,6 @@ export interface GeoReplyWithMember { } export default { - IS_READ_ONLY: GEOSEARCH.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/GET.ts b/packages/client/lib/commands/GET.ts index ca013752ae5..b4670c0bd53 100644 --- a/packages/client/lib/commands/GET.ts +++ b/packages/client/lib/commands/GET.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('GET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GETBIT.ts b/packages/client/lib/commands/GETBIT.ts index 023ba0fb607..6f729b8d484 100644 --- a/packages/client/lib/commands/GETBIT.ts +++ b/packages/client/lib/commands/GETBIT.ts @@ -3,8 +3,6 @@ import { NumberReply, Command, RedisArgument } from '../RESP/types'; import { BitValue } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, offset: number) { parser.push('GETBIT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GETDEL.ts b/packages/client/lib/commands/GETDEL.ts index a39014109f1..68a54b1c2cc 100644 --- a/packages/client/lib/commands/GETDEL.ts +++ b/packages/client/lib/commands/GETDEL.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('GETDEL'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GETEX.ts b/packages/client/lib/commands/GETEX.ts index 87319e17136..8f64ac70606 100644 --- a/packages/client/lib/commands/GETEX.ts +++ b/packages/client/lib/commands/GETEX.ts @@ -38,7 +38,6 @@ export type GetExOptions = { }; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options: GetExOptions) { parser.push('GETEX'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GETRANGE.ts b/packages/client/lib/commands/GETRANGE.ts index ce0db6e3c03..9c41bbf88d4 100644 --- a/packages/client/lib/commands/GETRANGE.ts +++ b/packages/client/lib/commands/GETRANGE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, start: number, end: number) { parser.push('GETRANGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/GETSET.ts b/packages/client/lib/commands/GETSET.ts index 1b3312548e4..b4f59d8bd9b 100644 --- a/packages/client/lib/commands/GETSET.ts +++ b/packages/client/lib/commands/GETSET.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, value: RedisArgument) { parser.push('GETSET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HEXISTS.ts b/packages/client/lib/commands/HEXISTS.ts index 9bb517b7df4..692cf0ee8ba 100644 --- a/packages/client/lib/commands/HEXISTS.ts +++ b/packages/client/lib/commands/HEXISTS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, field: RedisArgument) { parser.push('HEXISTS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HEXPIRETIME.ts b/packages/client/lib/commands/HEXPIRETIME.ts index 697d327db16..e29c5e42db9 100644 --- a/packages/client/lib/commands/HEXPIRETIME.ts +++ b/packages/client/lib/commands/HEXPIRETIME.ts @@ -10,7 +10,6 @@ export const HASH_EXPIRATION_TIME = { } as const; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HGET.ts b/packages/client/lib/commands/HGET.ts index fcd9334eb0a..22c861072df 100644 --- a/packages/client/lib/commands/HGET.ts +++ b/packages/client/lib/commands/HGET.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, field: RedisArgument) { parser.push('HGET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HGETALL.ts b/packages/client/lib/commands/HGETALL.ts index 8d53669cdd4..00efbc7566d 100644 --- a/packages/client/lib/commands/HGETALL.ts +++ b/packages/client/lib/commands/HGETALL.ts @@ -3,8 +3,6 @@ import { RedisArgument, MapReply, BlobStringReply, Command } from '../RESP/types import { transformTuplesReply } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('HGETALL'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HKEYS.ts b/packages/client/lib/commands/HKEYS.ts index f07a1ac127f..fde0521d546 100644 --- a/packages/client/lib/commands/HKEYS.ts +++ b/packages/client/lib/commands/HKEYS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('HKEYS') parser.pushKey(key); diff --git a/packages/client/lib/commands/HLEN.ts b/packages/client/lib/commands/HLEN.ts index e3b89da3e7d..5b73171606a 100644 --- a/packages/client/lib/commands/HLEN.ts +++ b/packages/client/lib/commands/HLEN.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('HLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HMGET.ts b/packages/client/lib/commands/HMGET.ts index 51ba937339f..6515bb24cd3 100644 --- a/packages/client/lib/commands/HMGET.ts +++ b/packages/client/lib/commands/HMGET.ts @@ -3,8 +3,6 @@ import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from ' import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, fields: RedisVariadicArgument) { parser.push('HMGET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index dcc5b5ca59c..bdb18d4a995 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -175,8 +175,6 @@ function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null * server-side payload is treated as a fixed schema, not a generic map. */ export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('HOTKEYS', 'GET'); }, diff --git a/packages/client/lib/commands/HOTKEYS_RESET.ts b/packages/client/lib/commands/HOTKEYS_RESET.ts index 42221235f68..e6965021c1c 100644 --- a/packages/client/lib/commands/HOTKEYS_RESET.ts +++ b/packages/client/lib/commands/HOTKEYS_RESET.ts @@ -10,8 +10,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; * - ACTIVE -> ERROR (must stop first) */ export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser) { parser.push('HOTKEYS', 'RESET'); }, diff --git a/packages/client/lib/commands/HOTKEYS_START.ts b/packages/client/lib/commands/HOTKEYS_START.ts index 5f9736473f6..2dd5a799dc8 100644 --- a/packages/client/lib/commands/HOTKEYS_START.ts +++ b/packages/client/lib/commands/HOTKEYS_START.ts @@ -55,8 +55,6 @@ export interface HotkeysStartOptions { * - ACTIVE -> ERROR */ export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, options: HotkeysStartOptions) { parser.push('HOTKEYS', 'START'); diff --git a/packages/client/lib/commands/HOTKEYS_STOP.ts b/packages/client/lib/commands/HOTKEYS_STOP.ts index 05c2a42dd2f..f19c791489e 100644 --- a/packages/client/lib/commands/HOTKEYS_STOP.ts +++ b/packages/client/lib/commands/HOTKEYS_STOP.ts @@ -12,8 +12,6 @@ import { SimpleStringReply, NullReply, Command } from '../RESP/types'; * Note: Returns null if no session was started or is already stopped. */ export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser) { parser.push('HOTKEYS', 'STOP'); }, diff --git a/packages/client/lib/commands/HPEXPIREAT.ts b/packages/client/lib/commands/HPEXPIREAT.ts index 14288d7ae90..ff4df264092 100644 --- a/packages/client/lib/commands/HPEXPIREAT.ts +++ b/packages/client/lib/commands/HPEXPIREAT.ts @@ -4,7 +4,6 @@ import { RedisVariadicArgument, transformPXAT } from './generic-transformers'; import { HashExpiration } from './HEXPIRE'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HPEXPIRETIME.ts b/packages/client/lib/commands/HPEXPIRETIME.ts index cacce25a85f..4d800eecab4 100644 --- a/packages/client/lib/commands/HPEXPIRETIME.ts +++ b/packages/client/lib/commands/HPEXPIRETIME.ts @@ -3,7 +3,6 @@ import { ArrayReply, Command, NullReply, NumberReply, RedisArgument } from '../R import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HPTTL.ts b/packages/client/lib/commands/HPTTL.ts index b9cd54a850d..23b03413900 100644 --- a/packages/client/lib/commands/HPTTL.ts +++ b/packages/client/lib/commands/HPTTL.ts @@ -3,7 +3,6 @@ import { ArrayReply, Command, NullReply, NumberReply, RedisArgument } from '../R import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HRANDFIELD.ts b/packages/client/lib/commands/HRANDFIELD.ts index 3383b94dcb2..88c9c28e276 100644 --- a/packages/client/lib/commands/HRANDFIELD.ts +++ b/packages/client/lib/commands/HRANDFIELD.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('HRANDFIELD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HRANDFIELD_COUNT.ts b/packages/client/lib/commands/HRANDFIELD_COUNT.ts index 62abe97e350..270a10da54e 100644 --- a/packages/client/lib/commands/HRANDFIELD_COUNT.ts +++ b/packages/client/lib/commands/HRANDFIELD_COUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { parser.push('HRANDFIELD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HRANDFIELD_COUNT_WITHVALUES.ts b/packages/client/lib/commands/HRANDFIELD_COUNT_WITHVALUES.ts index aa8ebad1b93..ff163b44923 100644 --- a/packages/client/lib/commands/HRANDFIELD_COUNT_WITHVALUES.ts +++ b/packages/client/lib/commands/HRANDFIELD_COUNT_WITHVALUES.ts @@ -7,7 +7,6 @@ export type HRandFieldCountWithValuesReply = Array<{ }>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { parser.push('HRANDFIELD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HSCAN.ts b/packages/client/lib/commands/HSCAN.ts index e1e40663a07..5c6406ec5bd 100644 --- a/packages/client/lib/commands/HSCAN.ts +++ b/packages/client/lib/commands/HSCAN.ts @@ -8,7 +8,6 @@ export interface HScanEntry { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HSCAN_NOVALUES.ts b/packages/client/lib/commands/HSCAN_NOVALUES.ts index eff61a7aab0..04018543ca2 100644 --- a/packages/client/lib/commands/HSCAN_NOVALUES.ts +++ b/packages/client/lib/commands/HSCAN_NOVALUES.ts @@ -2,7 +2,6 @@ import { BlobStringReply, Command } from '../RESP/types'; import HSCAN from './HSCAN'; export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/HSETNX.ts b/packages/client/lib/commands/HSETNX.ts index 130d7cd81d3..0c73b912cce 100644 --- a/packages/client/lib/commands/HSETNX.ts +++ b/packages/client/lib/commands/HSETNX.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, Command, NumberReply } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HSTRLEN.ts b/packages/client/lib/commands/HSTRLEN.ts index 2468747d4c9..05152b66904 100644 --- a/packages/client/lib/commands/HSTRLEN.ts +++ b/packages/client/lib/commands/HSTRLEN.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, field: RedisArgument) { parser.push('HSTRLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/HTTL.ts b/packages/client/lib/commands/HTTL.ts index 4b8fe5d7e85..f3d094eecdf 100644 --- a/packages/client/lib/commands/HTTL.ts +++ b/packages/client/lib/commands/HTTL.ts @@ -3,7 +3,6 @@ import { ArrayReply, Command, NullReply, NumberReply, RedisArgument } from '../R import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/HVALS.ts b/packages/client/lib/commands/HVALS.ts index ab17e47f533..09dea4fc5a0 100644 --- a/packages/client/lib/commands/HVALS.ts +++ b/packages/client/lib/commands/HVALS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('HVALS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/INFO.ts b/packages/client/lib/commands/INFO.ts index 82cbd497a5b..e80fc723cd4 100644 --- a/packages/client/lib/commands/INFO.ts +++ b/packages/client/lib/commands/INFO.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, VerbatimStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, section?: RedisArgument) { parser.push('INFO'); diff --git a/packages/client/lib/commands/KEYS.ts b/packages/client/lib/commands/KEYS.ts index e516245d2ee..57ac442a6c5 100644 --- a/packages/client/lib/commands/KEYS.ts +++ b/packages/client/lib/commands/KEYS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, pattern: RedisArgument) { parser.push('KEYS', pattern); }, diff --git a/packages/client/lib/commands/LASTSAVE.ts b/packages/client/lib/commands/LASTSAVE.ts index 447cb95ab6d..f2f87970633 100644 --- a/packages/client/lib/commands/LASTSAVE.ts +++ b/packages/client/lib/commands/LASTSAVE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LASTSAVE'); }, diff --git a/packages/client/lib/commands/LATENCY_DOCTOR.ts b/packages/client/lib/commands/LATENCY_DOCTOR.ts index 49c830b3065..9c77a88a8b1 100644 --- a/packages/client/lib/commands/LATENCY_DOCTOR.ts +++ b/packages/client/lib/commands/LATENCY_DOCTOR.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LATENCY', 'DOCTOR'); }, diff --git a/packages/client/lib/commands/LATENCY_GRAPH.ts b/packages/client/lib/commands/LATENCY_GRAPH.ts index 20251c3cded..8f022cbd8f2 100644 --- a/packages/client/lib/commands/LATENCY_GRAPH.ts +++ b/packages/client/lib/commands/LATENCY_GRAPH.ts @@ -23,8 +23,6 @@ export const LATENCY_EVENTS = { export type LatencyEvent = typeof LATENCY_EVENTS[keyof typeof LATENCY_EVENTS]; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, event: LatencyEvent) { parser.push('LATENCY', 'GRAPH', event); }, diff --git a/packages/client/lib/commands/LATENCY_HISTOGRAM.ts b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts index 209560b0428..566f06759d5 100644 --- a/packages/client/lib/commands/LATENCY_HISTOGRAM.ts +++ b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts @@ -12,8 +12,6 @@ type Histogram = Record n; export default { - CACHEABLE: false, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, ...commands: string[]) { const args = ['LATENCY', 'HISTOGRAM']; if (commands.length !== 0) { diff --git a/packages/client/lib/commands/LATENCY_HISTORY.ts b/packages/client/lib/commands/LATENCY_HISTORY.ts index 6e0e4d5c560..655f8fa8d80 100644 --- a/packages/client/lib/commands/LATENCY_HISTORY.ts +++ b/packages/client/lib/commands/LATENCY_HISTORY.ts @@ -21,8 +21,6 @@ export type LatencyEventType = ( ); export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, event: LatencyEventType) { parser.push('LATENCY', 'HISTORY', event); }, diff --git a/packages/client/lib/commands/LATENCY_LATEST.ts b/packages/client/lib/commands/LATENCY_LATEST.ts index 2ce3efd291c..b463431375d 100644 --- a/packages/client/lib/commands/LATENCY_LATEST.ts +++ b/packages/client/lib/commands/LATENCY_LATEST.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LATENCY', 'LATEST'); }, diff --git a/packages/client/lib/commands/LATENCY_RESET.ts b/packages/client/lib/commands/LATENCY_RESET.ts index b6e9165c5c5..69fcbfa8bc4 100644 --- a/packages/client/lib/commands/LATENCY_RESET.ts +++ b/packages/client/lib/commands/LATENCY_RESET.ts @@ -5,8 +5,6 @@ import { LATENCY_EVENTS, LatencyEvent } from './LATENCY_GRAPH'; export { LATENCY_EVENTS, LatencyEvent }; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, ...events: Array) { const args = ['LATENCY', 'RESET']; if (events.length > 0) { diff --git a/packages/client/lib/commands/LCS.ts b/packages/client/lib/commands/LCS.ts index ed4f11ad990..14e93b48104 100644 --- a/packages/client/lib/commands/LCS.ts +++ b/packages/client/lib/commands/LCS.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key1: RedisArgument, diff --git a/packages/client/lib/commands/LCS_IDX.ts b/packages/client/lib/commands/LCS_IDX.ts index cb0a6b07657..fd840dec9d8 100644 --- a/packages/client/lib/commands/LCS_IDX.ts +++ b/packages/client/lib/commands/LCS_IDX.ts @@ -24,7 +24,6 @@ export type LcsIdxReply = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: LCS.IS_READ_ONLY, parseCommand( parser: CommandParser, key1: RedisArgument, diff --git a/packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts b/packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts index d2a743983e1..2101293e2cf 100644 --- a/packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts +++ b/packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts @@ -15,7 +15,6 @@ export type LcsIdxWithMatchLenReply = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: LCS_IDX.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; LCS_IDX.parseCommand(...args); diff --git a/packages/client/lib/commands/LCS_LEN.ts b/packages/client/lib/commands/LCS_LEN.ts index a1f92d914a4..c4e64a11fdc 100644 --- a/packages/client/lib/commands/LCS_LEN.ts +++ b/packages/client/lib/commands/LCS_LEN.ts @@ -2,7 +2,6 @@ import { NumberReply, Command } from '../RESP/types'; import LCS from './LCS'; export default { - IS_READ_ONLY: LCS.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/LINDEX.ts b/packages/client/lib/commands/LINDEX.ts index 6335fc40c2c..29024a69355 100644 --- a/packages/client/lib/commands/LINDEX.ts +++ b/packages/client/lib/commands/LINDEX.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, index: number) { parser.push('LINDEX'); parser.pushKey(key); diff --git a/packages/client/lib/commands/LINSERT.ts b/packages/client/lib/commands/LINSERT.ts index 8a40ac66630..5cd2fe482cf 100644 --- a/packages/client/lib/commands/LINSERT.ts +++ b/packages/client/lib/commands/LINSERT.ts @@ -4,7 +4,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; type LInsertPosition = 'BEFORE' | 'AFTER'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/LLEN.ts b/packages/client/lib/commands/LLEN.ts index 674e022e60d..b4253054408 100644 --- a/packages/client/lib/commands/LLEN.ts +++ b/packages/client/lib/commands/LLEN.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('LLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/LMOVE.ts b/packages/client/lib/commands/LMOVE.ts index f3ac847e900..7559e5d27d6 100644 --- a/packages/client/lib/commands/LMOVE.ts +++ b/packages/client/lib/commands/LMOVE.ts @@ -3,7 +3,6 @@ import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/type import { ListSide } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, source: RedisArgument, diff --git a/packages/client/lib/commands/LMPOP.ts b/packages/client/lib/commands/LMPOP.ts index c8095e42e75..606c2a65a9d 100644 --- a/packages/client/lib/commands/LMPOP.ts +++ b/packages/client/lib/commands/LMPOP.ts @@ -23,7 +23,6 @@ export function parseLMPopArguments( export type LMPopArguments = Tail>; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, ...args: LMPopArguments) { parser.push('LMPOP'); parseLMPopArguments(parser, ...args); diff --git a/packages/client/lib/commands/LOLWUT.ts b/packages/client/lib/commands/LOLWUT.ts index 372bf536967..f36db5e28f2 100644 --- a/packages/client/lib/commands/LOLWUT.ts +++ b/packages/client/lib/commands/LOLWUT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, version?: number, ...optionalArguments: Array) { parser.push('LOLWUT'); if (version) { diff --git a/packages/client/lib/commands/LPOP_COUNT.ts b/packages/client/lib/commands/LPOP_COUNT.ts index 6d9aba42c21..965a91319a2 100644 --- a/packages/client/lib/commands/LPOP_COUNT.ts +++ b/packages/client/lib/commands/LPOP_COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, NullReply, ArrayReply, BlobStringReply, Command } from ' import LPOP from './LPOP'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { LPOP.parseCommand(parser, key); parser.push(count.toString()) diff --git a/packages/client/lib/commands/LPOS.ts b/packages/client/lib/commands/LPOS.ts index bb05ba6555d..f81e785d2e6 100644 --- a/packages/client/lib/commands/LPOS.ts +++ b/packages/client/lib/commands/LPOS.ts @@ -7,8 +7,6 @@ export interface LPosOptions { } export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/LPOS_COUNT.ts b/packages/client/lib/commands/LPOS_COUNT.ts index e782a2d26ee..ebe61230075 100644 --- a/packages/client/lib/commands/LPOS_COUNT.ts +++ b/packages/client/lib/commands/LPOS_COUNT.ts @@ -3,8 +3,6 @@ import { RedisArgument, ArrayReply, NumberReply, Command } from '../RESP/types'; import LPOS, { LPosOptions } from './LPOS'; export default { - CACHEABLE: LPOS.CACHEABLE, - IS_READ_ONLY: LPOS.IS_READ_ONLY, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/LRANGE.ts b/packages/client/lib/commands/LRANGE.ts index ab033dd88a4..257dd0dd154 100644 --- a/packages/client/lib/commands/LRANGE.ts +++ b/packages/client/lib/commands/LRANGE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, start: number, stop: number) { parser.push('LRANGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/LREM.ts b/packages/client/lib/commands/LREM.ts index bb97e3882e7..18e17531c5e 100644 --- a/packages/client/lib/commands/LREM.ts +++ b/packages/client/lib/commands/LREM.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, count: number, element: RedisArgument) { parser.push('LREM'); parser.pushKey(key); diff --git a/packages/client/lib/commands/LSET.ts b/packages/client/lib/commands/LSET.ts index 0fe646fbb73..f11f4269f59 100644 --- a/packages/client/lib/commands/LSET.ts +++ b/packages/client/lib/commands/LSET.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, index: number, element: RedisArgument) { parser.push('LSET'); parser.pushKey(key); diff --git a/packages/client/lib/commands/MEMORY_DOCTOR.ts b/packages/client/lib/commands/MEMORY_DOCTOR.ts index 3a2d808db10..9487333330f 100644 --- a/packages/client/lib/commands/MEMORY_DOCTOR.ts +++ b/packages/client/lib/commands/MEMORY_DOCTOR.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'DOCTOR'); }, diff --git a/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts b/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts index af6b5db3347..4d65b8eb860 100644 --- a/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts +++ b/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'MALLOC-STATS'); }, diff --git a/packages/client/lib/commands/MEMORY_PURGE.ts b/packages/client/lib/commands/MEMORY_PURGE.ts index bbd02890786..14fd9555b11 100644 --- a/packages/client/lib/commands/MEMORY_PURGE.ts +++ b/packages/client/lib/commands/MEMORY_PURGE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'PURGE'); }, diff --git a/packages/client/lib/commands/MEMORY_STATS.ts b/packages/client/lib/commands/MEMORY_STATS.ts index dd5eca7000a..6e7cd3186ad 100644 --- a/packages/client/lib/commands/MEMORY_STATS.ts +++ b/packages/client/lib/commands/MEMORY_STATS.ts @@ -36,8 +36,6 @@ export type MemoryStatsReply = TuplesToMapReply<[ ]>; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'STATS'); }, diff --git a/packages/client/lib/commands/MEMORY_USAGE.ts b/packages/client/lib/commands/MEMORY_USAGE.ts index ff5336d32e5..9996e58ea53 100644 --- a/packages/client/lib/commands/MEMORY_USAGE.ts +++ b/packages/client/lib/commands/MEMORY_USAGE.ts @@ -10,7 +10,6 @@ export interface MemoryUsageOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: MemoryUsageOptions) { parser.push('MEMORY', 'USAGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/MGET.ts b/packages/client/lib/commands/MGET.ts index ce1e9ba7781..4f3e457b123 100644 --- a/packages/client/lib/commands/MGET.ts +++ b/packages/client/lib/commands/MGET.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: Array) { parser.push('MGET'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/MIGRATE.ts b/packages/client/lib/commands/MIGRATE.ts index ee03b97acd8..cf3e691d8a5 100644 --- a/packages/client/lib/commands/MIGRATE.ts +++ b/packages/client/lib/commands/MIGRATE.ts @@ -9,7 +9,6 @@ export interface MigrateOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, host: RedisArgument, diff --git a/packages/client/lib/commands/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index 791c1a53e54..b153257e110 100644 --- a/packages/client/lib/commands/MODULE_LIST.ts +++ b/packages/client/lib/commands/MODULE_LIST.ts @@ -46,8 +46,6 @@ function transformModuleListReply(reply: Array) { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MODULE', 'LIST'); }, diff --git a/packages/client/lib/commands/MODULE_LOAD.ts b/packages/client/lib/commands/MODULE_LOAD.ts index ceb90c1c353..1c29a7d5c82 100644 --- a/packages/client/lib/commands/MODULE_LOAD.ts +++ b/packages/client/lib/commands/MODULE_LOAD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, path: RedisArgument, moduleArguments?: Array) { parser.push('MODULE', 'LOAD', path); diff --git a/packages/client/lib/commands/MODULE_UNLOAD.ts b/packages/client/lib/commands/MODULE_UNLOAD.ts index 1acc359d0d4..98c2884bc2e 100644 --- a/packages/client/lib/commands/MODULE_UNLOAD.ts +++ b/packages/client/lib/commands/MODULE_UNLOAD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, name: RedisArgument) { parser.push('MODULE', 'UNLOAD', name); }, diff --git a/packages/client/lib/commands/MSET.ts b/packages/client/lib/commands/MSET.ts index f761854f09c..173e4f42a66 100644 --- a/packages/client/lib/commands/MSET.ts +++ b/packages/client/lib/commands/MSET.ts @@ -32,7 +32,6 @@ export function parseMSetArguments(parser: CommandParser, toSet: MSetArguments) } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, toSet: MSetArguments) { parser.push('MSET'); return parseMSetArguments(parser, toSet); diff --git a/packages/client/lib/commands/MSETNX.ts b/packages/client/lib/commands/MSETNX.ts index 3ecce9525de..031520b5999 100644 --- a/packages/client/lib/commands/MSETNX.ts +++ b/packages/client/lib/commands/MSETNX.ts @@ -3,7 +3,6 @@ import { SimpleStringReply, Command } from '../RESP/types'; import { MSetArguments, parseMSetArguments } from './MSET'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, toSet: MSetArguments) { parser.push('MSETNX'); return parseMSetArguments(parser, toSet); diff --git a/packages/client/lib/commands/OBJECT_ENCODING.ts b/packages/client/lib/commands/OBJECT_ENCODING.ts index 3a795f6fb64..90477059d85 100644 --- a/packages/client/lib/commands/OBJECT_ENCODING.ts +++ b/packages/client/lib/commands/OBJECT_ENCODING.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('OBJECT', 'ENCODING'); parser.pushKey(key); diff --git a/packages/client/lib/commands/OBJECT_FREQ.ts b/packages/client/lib/commands/OBJECT_FREQ.ts index dad1124b101..0b49d39b580 100644 --- a/packages/client/lib/commands/OBJECT_FREQ.ts +++ b/packages/client/lib/commands/OBJECT_FREQ.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('OBJECT', 'FREQ'); parser.pushKey(key); diff --git a/packages/client/lib/commands/OBJECT_IDLETIME.ts b/packages/client/lib/commands/OBJECT_IDLETIME.ts index 2bd32f4e65d..dca0c7c47c2 100644 --- a/packages/client/lib/commands/OBJECT_IDLETIME.ts +++ b/packages/client/lib/commands/OBJECT_IDLETIME.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('OBJECT', 'IDLETIME'); parser.pushKey(key); diff --git a/packages/client/lib/commands/OBJECT_REFCOUNT.ts b/packages/client/lib/commands/OBJECT_REFCOUNT.ts index 4bee4dea60c..be2401bd7bd 100644 --- a/packages/client/lib/commands/OBJECT_REFCOUNT.ts +++ b/packages/client/lib/commands/OBJECT_REFCOUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('OBJECT', 'REFCOUNT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/PEXPIRE.ts b/packages/client/lib/commands/PEXPIRE.ts index 4053f46c8e2..de0002e9cf0 100644 --- a/packages/client/lib/commands/PEXPIRE.ts +++ b/packages/client/lib/commands/PEXPIRE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/PEXPIREAT.ts b/packages/client/lib/commands/PEXPIREAT.ts index e454447c970..7db96ddb881 100644 --- a/packages/client/lib/commands/PEXPIREAT.ts +++ b/packages/client/lib/commands/PEXPIREAT.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { transformPXAT } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/PEXPIRETIME.ts b/packages/client/lib/commands/PEXPIRETIME.ts index b5d04eae230..df0d79cb314 100644 --- a/packages/client/lib/commands/PEXPIRETIME.ts +++ b/packages/client/lib/commands/PEXPIRETIME.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('PEXPIRETIME'); parser.pushKey(key); diff --git a/packages/client/lib/commands/PFADD.ts b/packages/client/lib/commands/PFADD.ts index 94c2d1d5ae6..30b018f4a6f 100644 --- a/packages/client/lib/commands/PFADD.ts +++ b/packages/client/lib/commands/PFADD.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, element?: RedisVariadicArgument) { parser.push('PFADD') parser.pushKey(key); diff --git a/packages/client/lib/commands/PFCOUNT.ts b/packages/client/lib/commands/PFCOUNT.ts index 46d2e2ed71f..f508c9d3994 100644 --- a/packages/client/lib/commands/PFCOUNT.ts +++ b/packages/client/lib/commands/PFCOUNT.ts @@ -3,7 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('PFCOUNT'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/PING.ts b/packages/client/lib/commands/PING.ts index 26807eeeba4..d3a2d723d7e 100644 --- a/packages/client/lib/commands/PING.ts +++ b/packages/client/lib/commands/PING.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, message?: RedisArgument) { parser.push('PING'); if (message) { diff --git a/packages/client/lib/commands/PTTL.ts b/packages/client/lib/commands/PTTL.ts index 5717c51179f..3ca9718eccc 100644 --- a/packages/client/lib/commands/PTTL.ts +++ b/packages/client/lib/commands/PTTL.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('PTTL'); parser.pushKey(key); diff --git a/packages/client/lib/commands/PUBLISH.ts b/packages/client/lib/commands/PUBLISH.ts index 1b38dde15d1..cfce0c0cacc 100644 --- a/packages/client/lib/commands/PUBLISH.ts +++ b/packages/client/lib/commands/PUBLISH.ts @@ -2,9 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, - IS_FORWARD_COMMAND: true, parseCommand(parser: CommandParser, channel: RedisArgument, message: RedisArgument) { parser.push('PUBLISH', channel, message); }, diff --git a/packages/client/lib/commands/PUBSUB_CHANNELS.ts b/packages/client/lib/commands/PUBSUB_CHANNELS.ts index 0f53c79a78a..efea1f2928d 100644 --- a/packages/client/lib/commands/PUBSUB_CHANNELS.ts +++ b/packages/client/lib/commands/PUBSUB_CHANNELS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, pattern?: RedisArgument) { parser.push('PUBSUB', 'CHANNELS'); diff --git a/packages/client/lib/commands/PUBSUB_NUMPAT.ts b/packages/client/lib/commands/PUBSUB_NUMPAT.ts index 173446e023b..6e7561b3f8a 100644 --- a/packages/client/lib/commands/PUBSUB_NUMPAT.ts +++ b/packages/client/lib/commands/PUBSUB_NUMPAT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('PUBSUB', 'NUMPAT'); }, diff --git a/packages/client/lib/commands/PUBSUB_NUMSUB.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.ts index 845f587a834..7dfa8222f1d 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.ts @@ -3,8 +3,6 @@ import { ArrayReply, BlobStringReply, NumberReply, UnwrapReply, Command } from ' import { RedisVariadicArgument } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, channels?: RedisVariadicArgument) { parser.push('PUBSUB', 'NUMSUB'); diff --git a/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts b/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts index 46ac2005fc3..6ddcbfdfa8a 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, pattern?: RedisArgument) { parser.push('PUBSUB', 'SHARDCHANNELS'); diff --git a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts index f05822787c0..b76bb057f0d 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts @@ -3,7 +3,6 @@ import { ArrayReply, BlobStringReply, NumberReply, UnwrapReply, Command } from ' import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, /** * Constructs the PUBSUB SHARDNUMSUB command * diff --git a/packages/client/lib/commands/RANDOMKEY.ts b/packages/client/lib/commands/RANDOMKEY.ts index 97d040a0d1d..adb25f6610c 100644 --- a/packages/client/lib/commands/RANDOMKEY.ts +++ b/packages/client/lib/commands/RANDOMKEY.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('RANDOMKEY'); }, diff --git a/packages/client/lib/commands/READONLY.ts b/packages/client/lib/commands/READONLY.ts index ce3300c5321..16d1d2f3624 100644 --- a/packages/client/lib/commands/READONLY.ts +++ b/packages/client/lib/commands/READONLY.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('READONLY'); }, diff --git a/packages/client/lib/commands/READWRITE.ts b/packages/client/lib/commands/READWRITE.ts index 7d9d8c7e00a..8616ef44a82 100644 --- a/packages/client/lib/commands/READWRITE.ts +++ b/packages/client/lib/commands/READWRITE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('READWRITE'); }, diff --git a/packages/client/lib/commands/RENAME.ts b/packages/client/lib/commands/RENAME.ts index 245851ca31a..4cb0c4346e1 100644 --- a/packages/client/lib/commands/RENAME.ts +++ b/packages/client/lib/commands/RENAME.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, newKey: RedisArgument) { parser.push('RENAME'); parser.pushKeys([key, newKey]); diff --git a/packages/client/lib/commands/RENAMENX.ts b/packages/client/lib/commands/RENAMENX.ts index 0e8d4f73cf3..77d827d0098 100644 --- a/packages/client/lib/commands/RENAMENX.ts +++ b/packages/client/lib/commands/RENAMENX.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, newKey: RedisArgument) { parser.push('RENAMENX'); parser.pushKeys([key, newKey]); diff --git a/packages/client/lib/commands/REPLICAOF.ts b/packages/client/lib/commands/REPLICAOF.ts index c4b09bc4fb8..45a0c9dc5ab 100644 --- a/packages/client/lib/commands/REPLICAOF.ts +++ b/packages/client/lib/commands/REPLICAOF.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, host: string, port: number) { parser.push('REPLICAOF', host, port.toString()); }, diff --git a/packages/client/lib/commands/RESTORE-ASKING.ts b/packages/client/lib/commands/RESTORE-ASKING.ts index e8de532b6a4..e7aa91435f1 100644 --- a/packages/client/lib/commands/RESTORE-ASKING.ts +++ b/packages/client/lib/commands/RESTORE-ASKING.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('RESTORE-ASKING'); }, diff --git a/packages/client/lib/commands/RESTORE.ts b/packages/client/lib/commands/RESTORE.ts index 88d41e9aee5..002069f24f1 100644 --- a/packages/client/lib/commands/RESTORE.ts +++ b/packages/client/lib/commands/RESTORE.ts @@ -17,7 +17,6 @@ export interface RestoreOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ROLE.ts b/packages/client/lib/commands/ROLE.ts index 71079f28ccc..4e8617f4be9 100644 --- a/packages/client/lib/commands/ROLE.ts +++ b/packages/client/lib/commands/ROLE.ts @@ -35,8 +35,6 @@ type SentinelRole = [ type Role = TuplesReply; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ROLE'); }, diff --git a/packages/client/lib/commands/SAVE.ts b/packages/client/lib/commands/SAVE.ts index ee78884083c..6a9dbecc39f 100644 --- a/packages/client/lib/commands/SAVE.ts +++ b/packages/client/lib/commands/SAVE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('SAVE'); }, diff --git a/packages/client/lib/commands/SCAN.ts b/packages/client/lib/commands/SCAN.ts index c07436326c7..3a86cf9a138 100644 --- a/packages/client/lib/commands/SCAN.ts +++ b/packages/client/lib/commands/SCAN.ts @@ -70,8 +70,6 @@ export interface ScanOptions extends ScanCommonOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, cursor: RedisArgument, options?: ScanOptions) { parser.push('SCAN'); parseScanArguments(parser, cursor, options); diff --git a/packages/client/lib/commands/SCARD.ts b/packages/client/lib/commands/SCARD.ts index 61d4792d996..d9d0c5d84ea 100644 --- a/packages/client/lib/commands/SCARD.ts +++ b/packages/client/lib/commands/SCARD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('SCARD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SCRIPT_DEBUG.ts b/packages/client/lib/commands/SCRIPT_DEBUG.ts index b0d3079068f..75e75ed0732 100644 --- a/packages/client/lib/commands/SCRIPT_DEBUG.ts +++ b/packages/client/lib/commands/SCRIPT_DEBUG.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, mode: 'YES' | 'SYNC' | 'NO') { parser.push('SCRIPT', 'DEBUG', mode); }, diff --git a/packages/client/lib/commands/SCRIPT_EXISTS.ts b/packages/client/lib/commands/SCRIPT_EXISTS.ts index b0f6cbe2275..c6b6de083e5 100644 --- a/packages/client/lib/commands/SCRIPT_EXISTS.ts +++ b/packages/client/lib/commands/SCRIPT_EXISTS.ts @@ -3,8 +3,6 @@ import { ArrayReply, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, sha1: RedisVariadicArgument) { parser.push('SCRIPT', 'EXISTS'); parser.pushVariadic(sha1); diff --git a/packages/client/lib/commands/SCRIPT_FLUSH.ts b/packages/client/lib/commands/SCRIPT_FLUSH.ts index 1e05a619bad..24224debde2 100644 --- a/packages/client/lib/commands/SCRIPT_FLUSH.ts +++ b/packages/client/lib/commands/SCRIPT_FLUSH.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, mode?: 'ASYNC' | 'SYNC') { parser.push('SCRIPT', 'FLUSH'); diff --git a/packages/client/lib/commands/SCRIPT_KILL.ts b/packages/client/lib/commands/SCRIPT_KILL.ts index 26953506235..d99b805dd62 100644 --- a/packages/client/lib/commands/SCRIPT_KILL.ts +++ b/packages/client/lib/commands/SCRIPT_KILL.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('SCRIPT', 'KILL'); }, diff --git a/packages/client/lib/commands/SCRIPT_LOAD.ts b/packages/client/lib/commands/SCRIPT_LOAD.ts index 58f7c00dfcd..6eeedc00853 100644 --- a/packages/client/lib/commands/SCRIPT_LOAD.ts +++ b/packages/client/lib/commands/SCRIPT_LOAD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command, RedisArgument } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, script: RedisArgument) { parser.push('SCRIPT', 'LOAD', script); }, diff --git a/packages/client/lib/commands/SDIFF.ts b/packages/client/lib/commands/SDIFF.ts index bd78edc93db..5330bac3dd0 100644 --- a/packages/client/lib/commands/SDIFF.ts +++ b/packages/client/lib/commands/SDIFF.ts @@ -3,8 +3,6 @@ import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('SDIFF'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/SETBIT.ts b/packages/client/lib/commands/SETBIT.ts index 5cd29260071..00484f2391a 100644 --- a/packages/client/lib/commands/SETBIT.ts +++ b/packages/client/lib/commands/SETBIT.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { BitValue } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, offset: number, value: BitValue) { parser.push('SETBIT'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SHUTDOWN.ts b/packages/client/lib/commands/SHUTDOWN.ts index efea081e8eb..8a8ea1bbab1 100644 --- a/packages/client/lib/commands/SHUTDOWN.ts +++ b/packages/client/lib/commands/SHUTDOWN.ts @@ -17,8 +17,6 @@ export interface ShutdownOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, options?: ShutdownOptions) { parser.push('SHUTDOWN'); diff --git a/packages/client/lib/commands/SINTER.ts b/packages/client/lib/commands/SINTER.ts index 19ecdbb41ca..e9ad7c2c76f 100644 --- a/packages/client/lib/commands/SINTER.ts +++ b/packages/client/lib/commands/SINTER.ts @@ -3,8 +3,6 @@ import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('SINTER'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/SINTERCARD.ts b/packages/client/lib/commands/SINTERCARD.ts index f3b80e82dd3..88ef66e4b92 100644 --- a/packages/client/lib/commands/SINTERCARD.ts +++ b/packages/client/lib/commands/SINTERCARD.ts @@ -12,7 +12,6 @@ export interface SInterCardOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument, options?: SInterCardOptions | number) { parser.push('SINTERCARD'); parser.pushKeysLength(keys); diff --git a/packages/client/lib/commands/SINTERSTORE.ts b/packages/client/lib/commands/SINTERSTORE.ts index 06db0af9cb0..de1ead71f6f 100644 --- a/packages/client/lib/commands/SINTERSTORE.ts +++ b/packages/client/lib/commands/SINTERSTORE.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, destination: RedisArgument, keys: RedisVariadicArgument) { parser.push('SINTERSTORE'); parser.pushKey(destination) diff --git a/packages/client/lib/commands/SISMEMBER.ts b/packages/client/lib/commands/SISMEMBER.ts index 6192ca2605f..827f16a9c3d 100644 --- a/packages/client/lib/commands/SISMEMBER.ts +++ b/packages/client/lib/commands/SISMEMBER.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command, RedisArgument } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisArgument) { parser.push('SISMEMBER'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SMEMBERS.ts b/packages/client/lib/commands/SMEMBERS.ts index 6d018e999f4..5e1f69a0b62 100644 --- a/packages/client/lib/commands/SMEMBERS.ts +++ b/packages/client/lib/commands/SMEMBERS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, SetReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('SMEMBERS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SMISMEMBER.ts b/packages/client/lib/commands/SMISMEMBER.ts index f0f3a143c7f..7f4aa430b43 100644 --- a/packages/client/lib/commands/SMISMEMBER.ts +++ b/packages/client/lib/commands/SMISMEMBER.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, members: Array) { parser.push('SMISMEMBER'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SMOVE.ts b/packages/client/lib/commands/SMOVE.ts index d87eeefdfbf..1cc6a436c75 100644 --- a/packages/client/lib/commands/SMOVE.ts +++ b/packages/client/lib/commands/SMOVE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, source: RedisArgument, destination: RedisArgument, member: RedisArgument) { parser.push('SMOVE'); parser.pushKeys([source, destination]); diff --git a/packages/client/lib/commands/SORT.ts b/packages/client/lib/commands/SORT.ts index 43790151470..e4f0da6e6cb 100644 --- a/packages/client/lib/commands/SORT.ts +++ b/packages/client/lib/commands/SORT.ts @@ -67,7 +67,6 @@ export function parseSortArguments( } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: SortOptions) { parser.push('SORT'); parseSortArguments(parser, key, options); diff --git a/packages/client/lib/commands/SORT_RO.ts b/packages/client/lib/commands/SORT_RO.ts index 9901907c223..b6512e9065d 100644 --- a/packages/client/lib/commands/SORT_RO.ts +++ b/packages/client/lib/commands/SORT_RO.ts @@ -2,7 +2,6 @@ import { Command } from '../RESP/types'; import SORT, { parseSortArguments } from './SORT'; export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/SORT_STORE.ts b/packages/client/lib/commands/SORT_STORE.ts index 4d0d9b99dfc..eecd0c6a5e7 100644 --- a/packages/client/lib/commands/SORT_STORE.ts +++ b/packages/client/lib/commands/SORT_STORE.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import SORT, { SortOptions } from './SORT'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, source: RedisArgument, destination: RedisArgument, options?: SortOptions) { SORT.parseCommand(parser, source, options); parser.push('STORE'); diff --git a/packages/client/lib/commands/SPOP.ts b/packages/client/lib/commands/SPOP.ts index 38f40989e63..ac907b0cdda 100644 --- a/packages/client/lib/commands/SPOP.ts +++ b/packages/client/lib/commands/SPOP.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('SPOP'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SPOP_COUNT.ts b/packages/client/lib/commands/SPOP_COUNT.ts index 62acc621ac9..5fcab402d73 100644 --- a/packages/client/lib/commands/SPOP_COUNT.ts +++ b/packages/client/lib/commands/SPOP_COUNT.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, Command, ArrayReply } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { parser.push('SPOP'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SPUBLISH.ts b/packages/client/lib/commands/SPUBLISH.ts index d664c034ae4..ca8734605c7 100644 --- a/packages/client/lib/commands/SPUBLISH.ts +++ b/packages/client/lib/commands/SPUBLISH.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, channel: RedisArgument, message: RedisArgument) { parser.push('SPUBLISH'); // The channel routes the command to the correct shard (like a key) but must NOT be diff --git a/packages/client/lib/commands/SRANDMEMBER.ts b/packages/client/lib/commands/SRANDMEMBER.ts index 4285f7aa17c..50277094e97 100644 --- a/packages/client/lib/commands/SRANDMEMBER.ts +++ b/packages/client/lib/commands/SRANDMEMBER.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('SRANDMEMBER') parser.pushKey(key); diff --git a/packages/client/lib/commands/SRANDMEMBER_COUNT.ts b/packages/client/lib/commands/SRANDMEMBER_COUNT.ts index dd72245c3b3..876308bf76f 100644 --- a/packages/client/lib/commands/SRANDMEMBER_COUNT.ts +++ b/packages/client/lib/commands/SRANDMEMBER_COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/typ import SRANDMEMBER from './SRANDMEMBER'; export default { - IS_READ_ONLY: SRANDMEMBER.IS_READ_ONLY, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { SRANDMEMBER.parseCommand(parser, key); parser.push(count.toString()); diff --git a/packages/client/lib/commands/SREM.ts b/packages/client/lib/commands/SREM.ts index 75053474cce..6c79eb7b57d 100644 --- a/packages/client/lib/commands/SREM.ts +++ b/packages/client/lib/commands/SREM.ts @@ -3,7 +3,6 @@ import { NumberReply, Command, RedisArgument } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, members: RedisVariadicArgument) { parser.push('SREM'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SSCAN.ts b/packages/client/lib/commands/SSCAN.ts index 43971519bad..aeb27c74412 100644 --- a/packages/client/lib/commands/SSCAN.ts +++ b/packages/client/lib/commands/SSCAN.ts @@ -3,7 +3,6 @@ import { RedisArgument, BlobStringReply, Command } from '../RESP/types'; import { ScanCommonOptions, parseScanArguments} from './SCAN'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/STRLEN.ts b/packages/client/lib/commands/STRLEN.ts index 34e0430fc9e..a72769165c5 100644 --- a/packages/client/lib/commands/STRLEN.ts +++ b/packages/client/lib/commands/STRLEN.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('STRLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/SUNION.ts b/packages/client/lib/commands/SUNION.ts index 3d9a5954a7c..4552fc36528 100644 --- a/packages/client/lib/commands/SUNION.ts +++ b/packages/client/lib/commands/SUNION.ts @@ -3,8 +3,6 @@ import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('SUNION'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/SUNIONSTORE.ts b/packages/client/lib/commands/SUNIONSTORE.ts index e2f43ecb1c8..4b5689d4855 100644 --- a/packages/client/lib/commands/SUNIONSTORE.ts +++ b/packages/client/lib/commands/SUNIONSTORE.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, destination: RedisArgument, keys: RedisVariadicArgument) { parser.push('SUNIONSTORE'); parser.pushKey(destination); diff --git a/packages/client/lib/commands/SWAPDB.ts b/packages/client/lib/commands/SWAPDB.ts index e59c75715cd..313f0055841 100644 --- a/packages/client/lib/commands/SWAPDB.ts +++ b/packages/client/lib/commands/SWAPDB.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, index1: number, index2: number) { parser.push('SWAPDB', index1.toString(), index2.toString()); }, diff --git a/packages/client/lib/commands/TIME.ts b/packages/client/lib/commands/TIME.ts index b25af710e1c..9f04abc69db 100644 --- a/packages/client/lib/commands/TIME.ts +++ b/packages/client/lib/commands/TIME.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('TIME'); }, diff --git a/packages/client/lib/commands/TOUCH.ts b/packages/client/lib/commands/TOUCH.ts index c765c9f8347..908576f95b5 100644 --- a/packages/client/lib/commands/TOUCH.ts +++ b/packages/client/lib/commands/TOUCH.ts @@ -3,7 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisVariadicArgument) { parser.push('TOUCH'); parser.pushKeys(key); diff --git a/packages/client/lib/commands/TTL.ts b/packages/client/lib/commands/TTL.ts index 8420089fcb9..b8f420ade76 100644 --- a/packages/client/lib/commands/TTL.ts +++ b/packages/client/lib/commands/TTL.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TTL'); parser.pushKey(key); diff --git a/packages/client/lib/commands/TYPE.ts b/packages/client/lib/commands/TYPE.ts index ffc592994db..4d2f5fd1bd4 100644 --- a/packages/client/lib/commands/TYPE.ts +++ b/packages/client/lib/commands/TYPE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('TYPE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/UNLINK.ts b/packages/client/lib/commands/UNLINK.ts index 14d1e700277..e2396d8cea0 100644 --- a/packages/client/lib/commands/UNLINK.ts +++ b/packages/client/lib/commands/UNLINK.ts @@ -3,7 +3,6 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('UNLINK'); parser.pushKeys(keys); diff --git a/packages/client/lib/commands/VCARD.ts b/packages/client/lib/commands/VCARD.ts index 5ae86cdbfd2..b4ea49dfbb4 100644 --- a/packages/client/lib/commands/VCARD.ts +++ b/packages/client/lib/commands/VCARD.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('VCARD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VDIM.ts b/packages/client/lib/commands/VDIM.ts index d0b26a06575..8f0d6eaf3d2 100644 --- a/packages/client/lib/commands/VDIM.ts +++ b/packages/client/lib/commands/VDIM.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('VDIM'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VEMB.ts b/packages/client/lib/commands/VEMB.ts index 3fd6ba3fc34..db724536a61 100644 --- a/packages/client/lib/commands/VEMB.ts +++ b/packages/client/lib/commands/VEMB.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '../RESP/types'; import { transformDoubleArrayReply } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { parser.push('VEMB'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VEMB_RAW.ts b/packages/client/lib/commands/VEMB_RAW.ts index d5d106cbcac..74bce9cf784 100644 --- a/packages/client/lib/commands/VEMB_RAW.ts +++ b/packages/client/lib/commands/VEMB_RAW.ts @@ -36,7 +36,6 @@ const transformRawVembReply = { }; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/VGETATTR.ts b/packages/client/lib/commands/VGETATTR.ts index 55239da1590..659c08cbfa9 100644 --- a/packages/client/lib/commands/VGETATTR.ts +++ b/packages/client/lib/commands/VGETATTR.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '../RESP/types'; import { transformRedisJsonNullReply } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { parser.push('VGETATTR'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VINFO.ts b/packages/client/lib/commands/VINFO.ts index 1b719d338f1..bd456081ffd 100644 --- a/packages/client/lib/commands/VINFO.ts +++ b/packages/client/lib/commands/VINFO.ts @@ -11,7 +11,6 @@ export type VInfoReplyMap = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, /** * Retrieve metadata and internal details about a vector set, including size, dimensions, quantization type, and graph structure * diff --git a/packages/client/lib/commands/VLINKS.ts b/packages/client/lib/commands/VLINKS.ts index 69e0c5d3eb0..71231af25ba 100644 --- a/packages/client/lib/commands/VLINKS.ts +++ b/packages/client/lib/commands/VLINKS.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { parser.push('VLINKS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VLINKS_WITHSCORES.ts b/packages/client/lib/commands/VLINKS_WITHSCORES.ts index 48e7c5b4660..e1c1fc7cbf4 100644 --- a/packages/client/lib/commands/VLINKS_WITHSCORES.ts +++ b/packages/client/lib/commands/VLINKS_WITHSCORES.ts @@ -23,7 +23,6 @@ function transformVLinksWithScoresReply(reply: Array>): A } export default { - IS_READ_ONLY: VLINKS.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/VRANDMEMBER.ts b/packages/client/lib/commands/VRANDMEMBER.ts index 84a3543d1aa..330af13b24c 100644 --- a/packages/client/lib/commands/VRANDMEMBER.ts +++ b/packages/client/lib/commands/VRANDMEMBER.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, ArrayReply, Command, NullReply } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, count?: number) { parser.push('VRANDMEMBER'); parser.pushKey(key); diff --git a/packages/client/lib/commands/VRANGE.ts b/packages/client/lib/commands/VRANGE.ts index 3aaf5e1ec1e..a3efc24ef0e 100644 --- a/packages/client/lib/commands/VRANGE.ts +++ b/packages/client/lib/commands/VRANGE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/VSIM.ts b/packages/client/lib/commands/VSIM.ts index d8fba1e843c..cb19484ceef 100644 --- a/packages/client/lib/commands/VSIM.ts +++ b/packages/client/lib/commands/VSIM.ts @@ -13,7 +13,6 @@ export interface VSimOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/VSIM_WITHSCORES.ts b/packages/client/lib/commands/VSIM_WITHSCORES.ts index d5dd4d638e0..7beca97f825 100644 --- a/packages/client/lib/commands/VSIM_WITHSCORES.ts +++ b/packages/client/lib/commands/VSIM_WITHSCORES.ts @@ -10,7 +10,6 @@ import { transformDoubleReply } from './generic-transformers'; import VSIM from './VSIM'; export default { - IS_READ_ONLY: VSIM.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/WAIT.ts b/packages/client/lib/commands/WAIT.ts index df45a12373d..765f6cf75d1 100644 --- a/packages/client/lib/commands/WAIT.ts +++ b/packages/client/lib/commands/WAIT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, numberOfReplicas: number, timeout: number) { parser.push('WAIT', numberOfReplicas.toString(), timeout.toString()); }, diff --git a/packages/client/lib/commands/XACK.ts b/packages/client/lib/commands/XACK.ts index 2500134f1c8..304a669a9c1 100644 --- a/packages/client/lib/commands/XACK.ts +++ b/packages/client/lib/commands/XACK.ts @@ -3,7 +3,6 @@ import { NumberReply, Command, RedisArgument } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, group: RedisArgument, id: RedisVariadicArgument) { parser.push('XACK'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XACKDEL.ts b/packages/client/lib/commands/XACKDEL.ts index 2afd5206fc6..0b30b1e3d07 100644 --- a/packages/client/lib/commands/XACKDEL.ts +++ b/packages/client/lib/commands/XACKDEL.ts @@ -10,7 +10,6 @@ import { RedisVariadicArgument } from "./generic-transformers"; * Acknowledges and deletes one or multiple messages for a stream consumer group */ export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XADD.ts b/packages/client/lib/commands/XADD.ts index 4d5873ea26f..9d6aefd3d51 100644 --- a/packages/client/lib/commands/XADD.ts +++ b/packages/client/lib/commands/XADD.ts @@ -106,7 +106,6 @@ export function parseXAddArguments( } export default { - IS_READ_ONLY: false, parseCommand(...args: Tail>) { return parseXAddArguments(undefined, ...args); }, diff --git a/packages/client/lib/commands/XADD_NOMKSTREAM.ts b/packages/client/lib/commands/XADD_NOMKSTREAM.ts index 6184de8aa55..f5f312256e0 100644 --- a/packages/client/lib/commands/XADD_NOMKSTREAM.ts +++ b/packages/client/lib/commands/XADD_NOMKSTREAM.ts @@ -6,7 +6,6 @@ import { parseXAddArguments } from './XADD'; * Command for adding entries to an existing stream without creating it if it doesn't exist */ export default { - IS_READ_ONLY: false, parseCommand(...args: Tail>) { return parseXAddArguments('NOMKSTREAM', ...args); }, diff --git a/packages/client/lib/commands/XAUTOCLAIM.ts b/packages/client/lib/commands/XAUTOCLAIM.ts index ebd884a89cf..b338f5af57e 100644 --- a/packages/client/lib/commands/XAUTOCLAIM.ts +++ b/packages/client/lib/commands/XAUTOCLAIM.ts @@ -25,7 +25,6 @@ export type XAutoClaimRawReply = TuplesReply<[ ]>; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XAUTOCLAIM_JUSTID.ts b/packages/client/lib/commands/XAUTOCLAIM_JUSTID.ts index 0dca8620689..2f36b3a69eb 100644 --- a/packages/client/lib/commands/XAUTOCLAIM_JUSTID.ts +++ b/packages/client/lib/commands/XAUTOCLAIM_JUSTID.ts @@ -15,7 +15,6 @@ type XAutoClaimJustIdRawReply = TuplesReply<[ ]>; export default { - IS_READ_ONLY: XAUTOCLAIM.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; XAUTOCLAIM.parseCommand(...args); diff --git a/packages/client/lib/commands/XCFGSET.ts b/packages/client/lib/commands/XCFGSET.ts index 2aa2de641d5..e88cf448806 100644 --- a/packages/client/lib/commands/XCFGSET.ts +++ b/packages/client/lib/commands/XCFGSET.ts @@ -29,7 +29,6 @@ export interface XCfgSetOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XCLAIM.ts b/packages/client/lib/commands/XCLAIM.ts index af79d74e8aa..d28014378ee 100644 --- a/packages/client/lib/commands/XCLAIM.ts +++ b/packages/client/lib/commands/XCLAIM.ts @@ -20,7 +20,6 @@ export interface XClaimOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XCLAIM_JUSTID.ts b/packages/client/lib/commands/XCLAIM_JUSTID.ts index cd6052e1534..d02bb43723f 100644 --- a/packages/client/lib/commands/XCLAIM_JUSTID.ts +++ b/packages/client/lib/commands/XCLAIM_JUSTID.ts @@ -5,7 +5,6 @@ import XCLAIM from './XCLAIM'; * Command variant for XCLAIM that returns only message IDs */ export default { - IS_READ_ONLY: XCLAIM.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; XCLAIM.parseCommand(...args); diff --git a/packages/client/lib/commands/XDEL.ts b/packages/client/lib/commands/XDEL.ts index db7fb4fbfaa..424bb5f8e44 100644 --- a/packages/client/lib/commands/XDEL.ts +++ b/packages/client/lib/commands/XDEL.ts @@ -6,7 +6,6 @@ import { RedisVariadicArgument } from './generic-transformers'; * Command for removing messages from a stream */ export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, id: RedisVariadicArgument) { parser.push('XDEL'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XDELEX.ts b/packages/client/lib/commands/XDELEX.ts index b41f52b0ae6..0a396baf308 100644 --- a/packages/client/lib/commands/XDELEX.ts +++ b/packages/client/lib/commands/XDELEX.ts @@ -10,7 +10,6 @@ import { RedisVariadicArgument } from "./generic-transformers"; * Deletes one or multiple entries from the stream */ export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XGROUP_CREATE.ts b/packages/client/lib/commands/XGROUP_CREATE.ts index fbbaf1b67be..99934dfd8d3 100644 --- a/packages/client/lib/commands/XGROUP_CREATE.ts +++ b/packages/client/lib/commands/XGROUP_CREATE.ts @@ -16,7 +16,6 @@ export interface XGroupCreateOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XGROUP_CREATECONSUMER.ts b/packages/client/lib/commands/XGROUP_CREATECONSUMER.ts index 37af9a181ae..5a09705cf24 100644 --- a/packages/client/lib/commands/XGROUP_CREATECONSUMER.ts +++ b/packages/client/lib/commands/XGROUP_CREATECONSUMER.ts @@ -5,7 +5,6 @@ import { RedisArgument, Command, NumberReply } from '../RESP/types'; * Command for creating a new consumer in a consumer group */ export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XGROUP_DELCONSUMER.ts b/packages/client/lib/commands/XGROUP_DELCONSUMER.ts index 37bfb028dc4..9594901eab1 100644 --- a/packages/client/lib/commands/XGROUP_DELCONSUMER.ts +++ b/packages/client/lib/commands/XGROUP_DELCONSUMER.ts @@ -5,7 +5,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; * Command for removing a consumer from a consumer group */ export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XGROUP_DESTROY.ts b/packages/client/lib/commands/XGROUP_DESTROY.ts index 9af3a61843c..fcf1569608c 100644 --- a/packages/client/lib/commands/XGROUP_DESTROY.ts +++ b/packages/client/lib/commands/XGROUP_DESTROY.ts @@ -5,7 +5,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; * Command for removing a consumer group */ export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, group: RedisArgument) { parser.push('XGROUP', 'DESTROY'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XGROUP_SETID.ts b/packages/client/lib/commands/XGROUP_SETID.ts index dea7c981aba..b28f27867c3 100644 --- a/packages/client/lib/commands/XGROUP_SETID.ts +++ b/packages/client/lib/commands/XGROUP_SETID.ts @@ -12,7 +12,6 @@ export interface XGroupSetIdOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XINFO_CONSUMERS.ts b/packages/client/lib/commands/XINFO_CONSUMERS.ts index 2e7052d34f7..8d902b30dd7 100644 --- a/packages/client/lib/commands/XINFO_CONSUMERS.ts +++ b/packages/client/lib/commands/XINFO_CONSUMERS.ts @@ -18,7 +18,6 @@ export type XInfoConsumersReply = ArrayReply>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, group: RedisArgument) { parser.push('XINFO', 'CONSUMERS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XINFO_GROUPS.ts b/packages/client/lib/commands/XINFO_GROUPS.ts index b2469c8551f..ab00df11f0f 100644 --- a/packages/client/lib/commands/XINFO_GROUPS.ts +++ b/packages/client/lib/commands/XINFO_GROUPS.ts @@ -16,7 +16,6 @@ export type XInfoGroupsReply = ArrayReply>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('XINFO', 'GROUPS'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XINFO_STREAM.ts b/packages/client/lib/commands/XINFO_STREAM.ts index 4a5b0310d65..4a31a6fdcc8 100644 --- a/packages/client/lib/commands/XINFO_STREAM.ts +++ b/packages/client/lib/commands/XINFO_STREAM.ts @@ -51,7 +51,6 @@ export type XInfoStreamReply = TuplesToMapReply<[ ]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('XINFO', 'STREAM'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XLEN.ts b/packages/client/lib/commands/XLEN.ts index 34b40a57606..d13b31b1793 100644 --- a/packages/client/lib/commands/XLEN.ts +++ b/packages/client/lib/commands/XLEN.ts @@ -5,8 +5,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; * Command for getting the length of a stream */ export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('XLEN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XNACK.ts b/packages/client/lib/commands/XNACK.ts index 5de397c049b..7081be9fab1 100644 --- a/packages/client/lib/commands/XNACK.ts +++ b/packages/client/lib/commands/XNACK.ts @@ -9,7 +9,6 @@ export interface XNackOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XPENDING.ts b/packages/client/lib/commands/XPENDING.ts index 41e71ed1a7f..5724afcc141 100644 --- a/packages/client/lib/commands/XPENDING.ts +++ b/packages/client/lib/commands/XPENDING.ts @@ -20,8 +20,6 @@ type XPendingRawReply = TuplesReply<[ ]>; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, group: RedisArgument) { parser.push('XPENDING'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XPENDING_RANGE.ts b/packages/client/lib/commands/XPENDING_RANGE.ts index 3610a0d42a8..aa72281d179 100644 --- a/packages/client/lib/commands/XPENDING_RANGE.ts +++ b/packages/client/lib/commands/XPENDING_RANGE.ts @@ -28,8 +28,6 @@ type XPendingRangeRawReply = ArrayReply>; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XRANGE.ts b/packages/client/lib/commands/XRANGE.ts index 3ab4836919a..c562a33423c 100644 --- a/packages/client/lib/commands/XRANGE.ts +++ b/packages/client/lib/commands/XRANGE.ts @@ -34,8 +34,6 @@ export function xRangeArguments( } export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, ...args: Parameters) { parser.push('XRANGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XREAD.ts b/packages/client/lib/commands/XREAD.ts index a484edf5058..e67a64b81e2 100644 --- a/packages/client/lib/commands/XREAD.ts +++ b/packages/client/lib/commands/XREAD.ts @@ -53,7 +53,6 @@ export interface XReadOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, streams: XReadStreams, options?: XReadOptions) { parser.push('XREAD'); diff --git a/packages/client/lib/commands/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index 9f3b1974ea2..8f7f404fbac 100644 --- a/packages/client/lib/commands/XREADGROUP.ts +++ b/packages/client/lib/commands/XREADGROUP.ts @@ -23,7 +23,6 @@ export interface XReadGroupOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, group: RedisArgument, diff --git a/packages/client/lib/commands/XREVRANGE.ts b/packages/client/lib/commands/XREVRANGE.ts index d579f3af5ad..f1340e2e782 100644 --- a/packages/client/lib/commands/XREVRANGE.ts +++ b/packages/client/lib/commands/XREVRANGE.ts @@ -15,8 +15,6 @@ export interface XRevRangeOptions { * Command for reading stream entries in reverse order */ export default { - CACHEABLE: XRANGE.CACHEABLE, - IS_READ_ONLY: XRANGE.IS_READ_ONLY, parseCommand(parser: CommandParser, key: RedisArgument, ...args: Parameters) { parser.push('XREVRANGE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/XSETID.ts b/packages/client/lib/commands/XSETID.ts index 55112dc668c..48762ee8c2e 100644 --- a/packages/client/lib/commands/XSETID.ts +++ b/packages/client/lib/commands/XSETID.ts @@ -8,7 +8,6 @@ export interface XSetIdOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/XTRIM.ts b/packages/client/lib/commands/XTRIM.ts index ef93c21c5b7..a26430c6e30 100644 --- a/packages/client/lib/commands/XTRIM.ts +++ b/packages/client/lib/commands/XTRIM.ts @@ -21,7 +21,6 @@ export interface XTrimOptions { * Command for trimming a stream to a specified length or minimum ID */ export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZCARD.ts b/packages/client/lib/commands/ZCARD.ts index b36ea685993..584b891ed30 100644 --- a/packages/client/lib/commands/ZCARD.ts +++ b/packages/client/lib/commands/ZCARD.ts @@ -5,8 +5,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; * Command for getting the number of members in a sorted set */ export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ZCARD'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZCOUNT.ts b/packages/client/lib/commands/ZCOUNT.ts index ccbc3d13d9b..33f1ea1c610 100644 --- a/packages/client/lib/commands/ZCOUNT.ts +++ b/packages/client/lib/commands/ZCOUNT.ts @@ -3,8 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { transformStringDoubleArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZDIFF.ts b/packages/client/lib/commands/ZDIFF.ts index 28135dc9c13..5e5ad7e5c0a 100644 --- a/packages/client/lib/commands/ZDIFF.ts +++ b/packages/client/lib/commands/ZDIFF.ts @@ -3,7 +3,6 @@ import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { parser.push('ZDIFF'); parser.pushKeysLength(keys); diff --git a/packages/client/lib/commands/ZDIFFSTORE.ts b/packages/client/lib/commands/ZDIFFSTORE.ts index d83a4bdc851..cb94fc0da8d 100644 --- a/packages/client/lib/commands/ZDIFFSTORE.ts +++ b/packages/client/lib/commands/ZDIFFSTORE.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, destination: RedisArgument, inputKeys: RedisVariadicArgument) { parser.push('ZDIFFSTORE'); parser.pushKey(destination); diff --git a/packages/client/lib/commands/ZDIFF_WITHSCORES.ts b/packages/client/lib/commands/ZDIFF_WITHSCORES.ts index 4088f106dc6..ad2c7d4ea23 100644 --- a/packages/client/lib/commands/ZDIFF_WITHSCORES.ts +++ b/packages/client/lib/commands/ZDIFF_WITHSCORES.ts @@ -5,7 +5,6 @@ import ZDIFF from './ZDIFF'; export default { - IS_READ_ONLY: ZDIFF.IS_READ_ONLY, parseCommand(parser: CommandParser, keys: RedisVariadicArgument) { ZDIFF.parseCommand(parser, keys); parser.push('WITHSCORES'); diff --git a/packages/client/lib/commands/ZINTER.ts b/packages/client/lib/commands/ZINTER.ts index 8df33f7ab51..55225e23b06 100644 --- a/packages/client/lib/commands/ZINTER.ts +++ b/packages/client/lib/commands/ZINTER.ts @@ -29,7 +29,6 @@ export function parseZInterArguments( } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: ZInterKeysType, options?: ZInterOptions) { parser.push('ZINTER'); parseZInterArguments(parser, keys, options); diff --git a/packages/client/lib/commands/ZINTERCARD.ts b/packages/client/lib/commands/ZINTERCARD.ts index 8c2e98d12cb..6bc593b0b39 100644 --- a/packages/client/lib/commands/ZINTERCARD.ts +++ b/packages/client/lib/commands/ZINTERCARD.ts @@ -7,7 +7,6 @@ export interface ZInterCardOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, keys: RedisVariadicArgument, diff --git a/packages/client/lib/commands/ZINTERSTORE.ts b/packages/client/lib/commands/ZINTERSTORE.ts index dcbe153cfc7..7bf03ae409d 100644 --- a/packages/client/lib/commands/ZINTERSTORE.ts +++ b/packages/client/lib/commands/ZINTERSTORE.ts @@ -5,7 +5,6 @@ import { ZKeys } from './generic-transformers'; import { parseZInterArguments, ZInterOptions } from './ZINTER'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/client/lib/commands/ZINTER_WITHSCORES.ts b/packages/client/lib/commands/ZINTER_WITHSCORES.ts index d3a6614b3c2..355286ce885 100644 --- a/packages/client/lib/commands/ZINTER_WITHSCORES.ts +++ b/packages/client/lib/commands/ZINTER_WITHSCORES.ts @@ -4,7 +4,6 @@ import ZINTER from './ZINTER'; export default { - IS_READ_ONLY: ZINTER.IS_READ_ONLY, parseCommand(...args: Parameters) { ZINTER.parseCommand(...args); args[0].push('WITHSCORES'); diff --git a/packages/client/lib/commands/ZLEXCOUNT.ts b/packages/client/lib/commands/ZLEXCOUNT.ts index 7536590c168..d389db43cb4 100644 --- a/packages/client/lib/commands/ZLEXCOUNT.ts +++ b/packages/client/lib/commands/ZLEXCOUNT.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZMPOP.ts b/packages/client/lib/commands/ZMPOP.ts index dde2ee8a262..d6946fcb15e 100644 --- a/packages/client/lib/commands/ZMPOP.ts +++ b/packages/client/lib/commands/ZMPOP.ts @@ -32,7 +32,6 @@ export function parseZMPopArguments( export type ZMPopArguments = Tail>; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, keys: RedisVariadicArgument, diff --git a/packages/client/lib/commands/ZMSCORE.ts b/packages/client/lib/commands/ZMSCORE.ts index 053cfa91e15..d77890fdf63 100644 --- a/packages/client/lib/commands/ZMSCORE.ts +++ b/packages/client/lib/commands/ZMSCORE.ts @@ -3,8 +3,6 @@ import { RedisArgument, ArrayReply, NullReply, BlobStringReply, DoubleReply, Unw import { createTransformNullableDoubleReplyResp2Func, RedisVariadicArgument } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisVariadicArgument) { parser.push('ZMSCORE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZPOPMAX.ts b/packages/client/lib/commands/ZPOPMAX.ts index 856d6f10908..142c1cbb1b7 100644 --- a/packages/client/lib/commands/ZPOPMAX.ts +++ b/packages/client/lib/commands/ZPOPMAX.ts @@ -3,7 +3,6 @@ import { RedisArgument, TuplesReply, BlobStringReply, DoubleReply, UnwrapReply, import { transformDoubleReply } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ZPOPMAX'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZPOPMAX_COUNT.ts b/packages/client/lib/commands/ZPOPMAX_COUNT.ts index 888ce039fbe..68ebd63b14b 100644 --- a/packages/client/lib/commands/ZPOPMAX_COUNT.ts +++ b/packages/client/lib/commands/ZPOPMAX_COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '../RESP/types'; import { transformSortedSetReply } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { parser.push('ZPOPMAX'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZPOPMIN.ts b/packages/client/lib/commands/ZPOPMIN.ts index 6295925aef1..29253f2a1d9 100644 --- a/packages/client/lib/commands/ZPOPMIN.ts +++ b/packages/client/lib/commands/ZPOPMIN.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '../RESP/types'; import ZPOPMAX from './ZPOPMAX'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ZPOPMIN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZPOPMIN_COUNT.ts b/packages/client/lib/commands/ZPOPMIN_COUNT.ts index 2b6abf580b9..1bfa660bc36 100644 --- a/packages/client/lib/commands/ZPOPMIN_COUNT.ts +++ b/packages/client/lib/commands/ZPOPMIN_COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '../RESP/types'; import { transformSortedSetReply } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { parser.push('ZPOPMIN'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZRANDMEMBER.ts b/packages/client/lib/commands/ZRANDMEMBER.ts index 2abd9d3684c..f560bad990a 100644 --- a/packages/client/lib/commands/ZRANDMEMBER.ts +++ b/packages/client/lib/commands/ZRANDMEMBER.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('ZRANDMEMBER'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZRANDMEMBER_COUNT.ts b/packages/client/lib/commands/ZRANDMEMBER_COUNT.ts index 42ef8110639..fe98783a92d 100644 --- a/packages/client/lib/commands/ZRANDMEMBER_COUNT.ts +++ b/packages/client/lib/commands/ZRANDMEMBER_COUNT.ts @@ -3,7 +3,6 @@ import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/typ import ZRANDMEMBER from './ZRANDMEMBER'; export default { - IS_READ_ONLY: ZRANDMEMBER.IS_READ_ONLY, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { ZRANDMEMBER.parseCommand(parser, key); parser.push(count.toString()); diff --git a/packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts b/packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts index f096e9d807d..90a632e3248 100644 --- a/packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts +++ b/packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts @@ -4,7 +4,6 @@ import { transformSortedSetReply } from './generic-transformers'; import ZRANDMEMBER_COUNT from './ZRANDMEMBER_COUNT'; export default { - IS_READ_ONLY: ZRANDMEMBER_COUNT.IS_READ_ONLY, parseCommand(parser: CommandParser, key: RedisArgument, count: number) { ZRANDMEMBER_COUNT.parseCommand(parser, key, count); parser.push('WITHSCORES'); diff --git a/packages/client/lib/commands/ZRANGE.ts b/packages/client/lib/commands/ZRANGE.ts index d1bc3433a50..0f29a3ed4fa 100644 --- a/packages/client/lib/commands/ZRANGE.ts +++ b/packages/client/lib/commands/ZRANGE.ts @@ -47,8 +47,6 @@ export function zRangeArgument( } export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZRANGEBYLEX.ts b/packages/client/lib/commands/ZRANGEBYLEX.ts index 316d9745c7e..4a9d32dfcc9 100644 --- a/packages/client/lib/commands/ZRANGEBYLEX.ts +++ b/packages/client/lib/commands/ZRANGEBYLEX.ts @@ -10,8 +10,6 @@ export interface ZRangeByLexOptions { } export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZRANGEBYSCORE.ts b/packages/client/lib/commands/ZRANGEBYSCORE.ts index 4d5471fdc0b..2ecb8351942 100644 --- a/packages/client/lib/commands/ZRANGEBYSCORE.ts +++ b/packages/client/lib/commands/ZRANGEBYSCORE.ts @@ -12,8 +12,6 @@ export interface ZRangeByScoreOptions { export declare function transformReply(): Array; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts b/packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts index 1a759b23dce..b2b43951dac 100644 --- a/packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts +++ b/packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts @@ -3,8 +3,6 @@ import { transformSortedSetReply } from './generic-transformers'; import ZRANGEBYSCORE from './ZRANGEBYSCORE'; export default { - CACHEABLE: ZRANGEBYSCORE.CACHEABLE, - IS_READ_ONLY: ZRANGEBYSCORE.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/ZRANGESTORE.ts b/packages/client/lib/commands/ZRANGESTORE.ts index f73e93a506f..098695c6208 100644 --- a/packages/client/lib/commands/ZRANGESTORE.ts +++ b/packages/client/lib/commands/ZRANGESTORE.ts @@ -12,7 +12,6 @@ export interface ZRangeStoreOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/client/lib/commands/ZRANGE_WITHSCORES.ts b/packages/client/lib/commands/ZRANGE_WITHSCORES.ts index 7e6cf00cf2e..8e6eee68369 100644 --- a/packages/client/lib/commands/ZRANGE_WITHSCORES.ts +++ b/packages/client/lib/commands/ZRANGE_WITHSCORES.ts @@ -3,8 +3,6 @@ import { transformSortedSetReply } from './generic-transformers'; import ZRANGE from './ZRANGE'; export default { - CACHEABLE: ZRANGE.CACHEABLE, - IS_READ_ONLY: ZRANGE.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/ZRANK.ts b/packages/client/lib/commands/ZRANK.ts index 045e9ef8c25..14e517a0245 100644 --- a/packages/client/lib/commands/ZRANK.ts +++ b/packages/client/lib/commands/ZRANK.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, NullReply, Command } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisArgument) { parser.push('ZRANK'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZRANK_WITHSCORE.ts b/packages/client/lib/commands/ZRANK_WITHSCORE.ts index dc2e48b362d..18e30413b6b 100644 --- a/packages/client/lib/commands/ZRANK_WITHSCORE.ts +++ b/packages/client/lib/commands/ZRANK_WITHSCORE.ts @@ -2,8 +2,6 @@ import { NullReply, TuplesReply, NumberReply, BlobStringReply, DoubleReply, Unwr import ZRANK from './ZRANK'; export default { - CACHEABLE: ZRANK.CACHEABLE, - IS_READ_ONLY: ZRANK.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/ZREM.ts b/packages/client/lib/commands/ZREM.ts index c8ba0ec02a6..987353bb097 100644 --- a/packages/client/lib/commands/ZREM.ts +++ b/packages/client/lib/commands/ZREM.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZREMRANGEBYLEX.ts b/packages/client/lib/commands/ZREMRANGEBYLEX.ts index 5d7e1a21bb0..8c76c7ed9da 100644 --- a/packages/client/lib/commands/ZREMRANGEBYLEX.ts +++ b/packages/client/lib/commands/ZREMRANGEBYLEX.ts @@ -3,7 +3,6 @@ import { NumberReply, Command, RedisArgument } from '../RESP/types'; import { transformStringDoubleArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZREMRANGEBYRANK.ts b/packages/client/lib/commands/ZREMRANGEBYRANK.ts index 0a2eb3fadf3..e24ab9b9f05 100644 --- a/packages/client/lib/commands/ZREMRANGEBYRANK.ts +++ b/packages/client/lib/commands/ZREMRANGEBYRANK.ts @@ -2,7 +2,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZREMRANGEBYSCORE.ts b/packages/client/lib/commands/ZREMRANGEBYSCORE.ts index 3d23d875948..c78342ce59d 100644 --- a/packages/client/lib/commands/ZREMRANGEBYSCORE.ts +++ b/packages/client/lib/commands/ZREMRANGEBYSCORE.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, Command } from '../RESP/types'; import { transformStringDoubleArgument } from './generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZREVRANK.ts b/packages/client/lib/commands/ZREVRANK.ts index d48dc68adc2..f27879951e1 100644 --- a/packages/client/lib/commands/ZREVRANK.ts +++ b/packages/client/lib/commands/ZREVRANK.ts @@ -2,8 +2,6 @@ import { CommandParser } from '../client/parser'; import { NumberReply, NullReply, Command, RedisArgument } from '../RESP/types'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisArgument) { parser.push('ZREVRANK'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZREVRANK_WITHSCORE.ts b/packages/client/lib/commands/ZREVRANK_WITHSCORE.ts index 8a1701b5cc9..2a4c05c26e9 100644 --- a/packages/client/lib/commands/ZREVRANK_WITHSCORE.ts +++ b/packages/client/lib/commands/ZREVRANK_WITHSCORE.ts @@ -3,8 +3,6 @@ import ZREVRANK from './ZREVRANK'; import { Command } from '../RESP/types'; export default { - CACHEABLE: ZREVRANK.CACHEABLE, - IS_READ_ONLY: ZREVRANK.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/ZSCAN.ts b/packages/client/lib/commands/ZSCAN.ts index 051235033eb..47513de1b37 100644 --- a/packages/client/lib/commands/ZSCAN.ts +++ b/packages/client/lib/commands/ZSCAN.ts @@ -9,7 +9,6 @@ export interface HScanEntry { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/client/lib/commands/ZSCORE.ts b/packages/client/lib/commands/ZSCORE.ts index 23b52901078..bfa2a8b20ce 100644 --- a/packages/client/lib/commands/ZSCORE.ts +++ b/packages/client/lib/commands/ZSCORE.ts @@ -4,8 +4,6 @@ import { RedisArgument, Command } from '../RESP/types'; import { transformNullableDoubleReply } from './generic-transformers'; export default { - CACHEABLE: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, member: RedisArgument) { parser.push('ZSCORE'); parser.pushKey(key); diff --git a/packages/client/lib/commands/ZUNION.ts b/packages/client/lib/commands/ZUNION.ts index 716ee508674..98b95322bdd 100644 --- a/packages/client/lib/commands/ZUNION.ts +++ b/packages/client/lib/commands/ZUNION.ts @@ -8,7 +8,6 @@ export interface ZUnionOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, keys: ZKeys, options?: ZUnionOptions) { parser.push('ZUNION'); parseZKeysArguments(parser, keys); diff --git a/packages/client/lib/commands/ZUNIONSTORE.ts b/packages/client/lib/commands/ZUNIONSTORE.ts index d3738d4f0bb..e51397fba4d 100644 --- a/packages/client/lib/commands/ZUNIONSTORE.ts +++ b/packages/client/lib/commands/ZUNIONSTORE.ts @@ -4,7 +4,6 @@ import { ZKeys, parseZKeysArguments } from './generic-transformers'; import { ZUnionOptions } from './ZUNION'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, destination: RedisArgument, diff --git a/packages/client/lib/commands/ZUNION_WITHSCORES.ts b/packages/client/lib/commands/ZUNION_WITHSCORES.ts index c62df55518f..5945526e6f6 100644 --- a/packages/client/lib/commands/ZUNION_WITHSCORES.ts +++ b/packages/client/lib/commands/ZUNION_WITHSCORES.ts @@ -4,7 +4,6 @@ import ZUNION from './ZUNION'; export default { - IS_READ_ONLY: ZUNION.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/client/lib/commands/generic-transformers.spec.ts b/packages/client/lib/commands/generic-transformers.spec.ts index 328140c3422..c9cb0e706e1 100644 --- a/packages/client/lib/commands/generic-transformers.spec.ts +++ b/packages/client/lib/commands/generic-transformers.spec.ts @@ -706,7 +706,10 @@ describe('Generic Transformers', () => { 0, 0, 0, - [CommandCategories.FAST, CommandCategories.CONNECTION] + [CommandCategories.FAST, CommandCategories.CONNECTION], + [], + [], + [] ]), { name: 'ping', @@ -715,11 +718,35 @@ describe('Generic Transformers', () => { firstKeyIndex: 0, lastKeyIndex: 0, step: 0, - categories: new Set([CommandCategories.FAST, CommandCategories.CONNECTION]) + categories: new Set([CommandCategories.FAST, CommandCategories.CONNECTION]), + policies: { request: undefined, response: undefined }, + isKeyless: true, + nondeterministicOutput: false, + tips: [], + keySpecs: [], + subcommands: [] } ); }); + it('transformCommandReply captures non-policy tips (dont_cache) and separates policy tips', () => { + const reply = transformCommandReply([ + 'touch', + -2, + [CommandFlags.READONLY, CommandFlags.FAST], + 1, + -1, + 1, + [], + ['request_policy:multi_shard', 'dont_cache', 'nondeterministic_output'], + [], + [] + ]); + assert.deepEqual(reply.tips, ['dont_cache', 'nondeterministic_output']); + assert.equal(reply.nondeterministicOutput, true); + assert.equal(reply.policies.request, 'multi_shard'); + }); + describe('pushSlotRangesArguments', () => { it('single range', () => { assert.deepEqual( diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index b4dfe9e9ed5..2e772327c30 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -1,5 +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 { REQUEST_POLICIES_WITH_DEFAULTS, RequestPolicyWithDefaults, RESPONSE_POLICIES_WITH_DEFAULTS, ResponsePolicyWithDefaults } from '../command-metadata/policies-constants'; import { RESP_TYPES } from '../RESP/decoder'; import { UnwrapReply, ArrayReply, BlobStringReply, BooleanReply, CommandArguments, DoubleReply, NullReply, NumberReply, RedisArgument, ReplyUnion, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; @@ -370,6 +370,19 @@ export type CommandReply = { categories: Set, policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } isKeyless: boolean, + /** + * True when the server tags the command with the `nondeterministic_output` + * tip (e.g. XPENDING). Distinct from `nondeterministic_output_order`, which + * is NOT captured here. Consumed to precompute `cacheable`. + */ + nondeterministicOutput: boolean, + /** + * Raw command tips, minus the `request_policy:` / `response_policy:` tips + * (captured separately in `policies`). Carries `nondeterministic_output`, + * `dont_cache`, etc. — mirrored into `CommandMetadata.tips` so CSC + * eligibility is derived from the raw server signal. + */ + tips: Array, keySpecs: Array, subcommands: Array }; @@ -476,6 +489,12 @@ export function transformCommandReply( // whole array instead of relying on positions. let requestPolicy: RequestPolicyWithDefaults | undefined; let responsePolicy: ResponsePolicyWithDefaults | undefined; + // Exact match — must NOT catch 'nondeterministic_output_order', which does + // not disqualify caching (HGETALL/SMEMBERS keep unordered but cacheable). + let nondeterministicOutput = false; + // Non-policy tips, mirrored verbatim into CommandMetadata.tips (dont_cache, + // nondeterministic_output, ...). The policy tips are captured separately. + const otherTips: Array = []; for (const tip of tips) { if (tip.startsWith('request_policy:')) { @@ -488,6 +507,11 @@ export function transformCommandReply( if ((Object.values(RESPONSE_POLICIES_WITH_DEFAULTS) as string[]).includes(raw)) { responsePolicy = raw as ResponsePolicyWithDefaults; } + } else { + otherTips.push(tip); + if (tip === 'nondeterministic_output') { + nondeterministicOutput = true; + } } } @@ -506,6 +530,8 @@ export function transformCommandReply( response: responsePolicy }, isKeyless: keySpecifications.length === 0, + nondeterministicOutput, + tips: otherTips, keySpecs: keySpecifications.map(transformKeySpec), subcommands }; diff --git a/packages/client/lib/sentinel/utils.ts b/packages/client/lib/sentinel/utils.ts index c04b9df8e9e..a378ce547d5 100644 --- a/packages/client/lib/sentinel/utils.ts +++ b/packages/client/lib/sentinel/utils.ts @@ -2,6 +2,7 @@ import { ArrayReply, Command, RedisFunction, RedisScript, RespVersions, UnwrapRe import { BasicCommandParser } from '../client/parser'; import { RedisSocketOptions, RedisTcpSocketOptions } from '../client/socket'; import { functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; +import { defaultCommandMetadata, isReplicaSafe } from '../command-metadata'; import { NamespaceProxySentinel, NamespaceProxySentinelClient, NodeAddressMap, ProxySentinel, ProxySentinelClient, RedisNode } from './types'; /* TODO: should use map interface, would need a transform reply probably? as resp2 is list form, which this depends on */ @@ -65,13 +66,18 @@ export function clientSocketToNode(socket: RedisSocketOptions): RedisNode { export function createCommand(command: Command, resp: RespVersions) { const transformReply = getTransformReply(command, resp); + // Resolved once from the wire identifier (known only after the first parse) + // and reused — the command function is a shared prototype method. + let replicaSafe: boolean | undefined; return async function (this: T, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); + replicaSafe ??= isReplicaSafe(defaultCommandMetadata.lookup(parser.commandIdentifier), command.IS_READ_ONLY); + return this._self._execute( - command.IS_READ_ONLY, + replicaSafe, client => client._executeCommand(command, parser, this.commandOptions, transformReply) ); }; @@ -95,13 +101,16 @@ export function createFunctionCommand(command: Command, resp: RespVersions) { const transformReply = getTransformReply(command, resp); + let replicaSafe: boolean | undefined; return async function (this: T, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); + replicaSafe ??= isReplicaSafe(defaultCommandMetadata.lookup(parser.commandIdentifier), command.IS_READ_ONLY); + return this._self._execute( - command.IS_READ_ONLY, + replicaSafe, client => client._executeCommand(command, parser, this._self.commandOptions, transformReply) ); } diff --git a/packages/client/package.json b/packages/client/package.json index b3a5de301bf..b58355bad01 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,7 +11,7 @@ "scripts": { "test": "npm run test:types && nyc -r text-summary -r lcov mocha -r tsx --reporter mocha-multi-reporters --reporter-options configFile=mocha-multi-reporter-config.json --exit './lib/**/*.spec.ts'", "test:types": "tsc -p tsconfig.types-test.json", - "generate:policies": "tsx ./scripts/generate-static-policies-data.ts", + "generate:metadata": "tsx ./scripts/generate-command-metadata-data.ts", "release": "release-it" }, "dependencies": { diff --git a/packages/client/scripts/static-policies-overrides.ts b/packages/client/scripts/command-metadata-overrides.ts similarity index 67% rename from packages/client/scripts/static-policies-overrides.ts rename to packages/client/scripts/command-metadata-overrides.ts index c0b3fd5252c..c8c0fb9fe71 100644 --- a/packages/client/scripts/static-policies-overrides.ts +++ b/packages/client/scripts/command-metadata-overrides.ts @@ -1,6 +1,6 @@ /** * Curation applied on top of the raw COMMAND dump when regenerating - * `static-policies-data.ts`, keeping the static data aligned with the HLD + * `command-metadata-data.ts`, keeping the static data aligned with the HLD * "Command Routing Policy Table" (see ft-policies.spec.ts). * * Rationale: the server reports internal, debug, deprecated and cluster-admin @@ -8,7 +8,7 @@ * excluded here so the static phase refuses to resolve them (they fall through * to the fallback resolver instead). */ -import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; +import type { CommandMetadata } from '../lib/command-metadata/policies-constants'; /** Entire modules to drop (internal / cluster-admin command namespaces). */ export const EXCLUDED_MODULES: ReadonlySet = new Set(['_ft', 'search']); @@ -39,7 +39,8 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ ]); /** - * Full-entry replacements, keyed by `module.command`. + * Partial-entry overrides, keyed by `module.command`, shallow-merged onto the + * generated entry (override keys win; unspecified keys keep the server value). * * `ft.cursor` is pinned to the HLD `special` request policy (sticky cursor): * FT.CURSOR READ/DEL must reach the node that served the FT.AGGREGATE that @@ -49,8 +50,16 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ * `default-keyless` (single-node pass-through). Pinned as an override so the * static table is deterministic regardless of what a given server reports for * the container command's subcommands. + * + * `dont_cache` override: CSC eligibility (`isCacheable`) excludes commands + * tipped `dont_cache`. Redis 8.10 tags the read-only script commands with the + * `script_runner` flag (handled directly by the predicate) and TS.READ with a + * native `dont_cache` tip, but TOUCH is still not tagged — its raw metadata + * (readonly + keyed) makes it look cacheable even though it only bumps LRU/LFU + * and generates no invalidation. Inject the negative tip until the server tags + * it; remove once the server metadata is fixed. */ -export const COMMAND_OVERRIDES: Readonly> = { +export const COMMAND_OVERRIDES: Readonly>> = { 'ft.cursor': { request: 'special', response: 'default-keyless', @@ -59,5 +68,6 @@ export const COMMAND_OVERRIDES: Readonly> = { read: { request: 'special', response: 'default-keyless', isKeyless: true }, del: { request: 'special', response: 'default-keyless', isKeyless: true } } - } + }, + 'std.touch': { tips: ['dont_cache'] } }; diff --git a/packages/client/scripts/generate-static-policies-data.ts b/packages/client/scripts/generate-command-metadata-data.ts similarity index 56% rename from packages/client/scripts/generate-static-policies-data.ts rename to packages/client/scripts/generate-command-metadata-data.ts index f1cea473905..d52c18a3583 100644 --- a/packages/client/scripts/generate-static-policies-data.ts +++ b/packages/client/scripts/generate-command-metadata-data.ts @@ -1,14 +1,14 @@ /** - * Regenerates `lib/cluster/request-response-policies/static-policies-data.ts` + * Regenerates `lib/command-metadata/command-metadata-data.ts` * from a live Redis server's COMMAND reply. * - * The policy derivation is shared with `DynamicPolicyResolverFactory`, so the + * The metadata derivation is shared with `DynamicPolicyResolverFactory`, so the * generated static data is exactly what the dynamic resolver would build at * runtime against the same server, minus the HLD curation defined in - * `static-policies-overrides.ts` (internal/deprecated/cluster-admin commands). + * `command-metadata-overrides.ts` (internal/deprecated/cluster-admin commands). * * Usage: - * npm run generate:policies --workspace=packages/client -- redis://localhost:6379 + * npm run generate:metadata --workspace=packages/client -- redis://localhost:6379 * * The Redis URL is taken from the first CLI argument, then the REDIS_URL * environment variable, and defaults to redis://localhost:6379. @@ -20,43 +20,43 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { createClient } from '../index'; import { transformCommandReply, type CommandRawReply } from '../lib/commands/generic-transformers'; -import { DynamicPolicyResolverFactory } from '../lib/cluster/request-response-policies/dynamic-policy-resolver-factory'; -import type { CommandPolicyRecords, ModulePolicyRecords } from '../lib/cluster/request-response-policies/types'; -import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; -import { EXCLUDED_MODULES, EXCLUDED_COMMANDS, COMMAND_OVERRIDES } from './static-policies-overrides'; +import { DynamicPolicyResolverFactory } from '../lib/command-metadata/dynamic-policy-resolver-factory'; +import type { CommandMetadataRecords, ModuleMetadataRecords } from '../lib/command-metadata/types'; +import type { CommandMetadata } from '../lib/command-metadata/policies-constants'; +import { EXCLUDED_MODULES, EXCLUDED_COMMANDS, COMMAND_OVERRIDES } from './command-metadata-overrides'; -const OUTPUT_PATH = resolve(__dirname, '../lib/cluster/request-response-policies/static-policies-data.ts'); +const OUTPUT_PATH = resolve(__dirname, '../lib/command-metadata/command-metadata-data.ts'); function sortedByKey(record: Record, mapValue: (value: T) => T): Record { const sorted: Record = {}; - // Lowercased to match StaticPolicyResolver's lookup normalization. + // Lowercased to match StaticMetadataResolver's lookup normalization. for (const key of Object.keys(record).sort()) { sorted[key.toLowerCase()] = mapValue(record[key]); } return sorted; } -function sortCommandPolicies(policies: CommandPolicies): CommandPolicies { +function sortCommandMetadata(policies: CommandMetadata): CommandMetadata { return { ...policies, subcommands: policies.subcommands - ? sortedByKey(policies.subcommands, sortCommandPolicies) + ? sortedByKey(policies.subcommands, sortCommandMetadata) : undefined }; } // Sort modules, commands and subcommands alphabetically so regeneration // produces stable diffs regardless of the order the server lists commands in. -function sortModulePolicyRecords(records: ModulePolicyRecords): ModulePolicyRecords { - return sortedByKey(records, (commands: CommandPolicyRecords) => - sortedByKey(commands, sortCommandPolicies) +function sortModuleMetadataRecords(records: ModuleMetadataRecords): ModuleMetadataRecords { + return sortedByKey(records, (commands: CommandMetadataRecords) => + sortedByKey(commands, sortCommandMetadata) ); } -// Applies the HLD curation from static-policies-overrides.ts. Expects -// lowercased records (i.e. run after sortModulePolicyRecords). -function curate(records: ModulePolicyRecords): ModulePolicyRecords { - const curated: ModulePolicyRecords = {}; +// Applies the HLD curation from command-metadata-overrides.ts. Expects +// lowercased records (i.e. run after sortModuleMetadataRecords). +function curate(records: ModuleMetadataRecords): ModuleMetadataRecords { + const curated: ModuleMetadataRecords = {}; for (const [moduleName, commands] of Object.entries(records)) { if (EXCLUDED_MODULES.has(moduleName)) continue; @@ -66,7 +66,9 @@ function curate(records: ModulePolicyRecords): ModulePolicyRecords { const fullName = `${moduleName}.${commandName}`; if (EXCLUDED_COMMANDS.has(fullName)) continue; - curated[moduleName][commandName] = COMMAND_OVERRIDES[fullName] ?? policies; + // Shallow-merge: override keys win, unspecified keys keep the server value. + const override = COMMAND_OVERRIDES[fullName]; + curated[moduleName][commandName] = override ? { ...policies, ...override } : policies; } } @@ -81,19 +83,19 @@ async function main() { try { const rawCommands = await client.sendCommand>(['COMMAND']); const commands = rawCommands.map(transformCommandReply); - const policies = curate(sortModulePolicyRecords( - DynamicPolicyResolverFactory.buildModulePolicyRecords(commands) + const policies = curate(sortModuleMetadataRecords( + DynamicPolicyResolverFactory.buildModuleMetadataRecords(commands) )); const info = await client.sendCommand(['INFO', 'server']); const version = /redis_version:(\S+)/.exec(info)?.[1] ?? 'unknown'; const content = [ - '// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually.', + '// This file is auto-generated by scripts/generate-command-metadata-data.ts — do not edit manually.', `// Source: Redis ${version}, ${Object.values(policies).reduce((sum, commands) => sum + Object.keys(commands).length, 0)} commands.`, - 'import { ModulePolicyRecords } from "./types";', + 'import { ModuleMetadataRecords } from "./types";', '', - `export const POLICIES: ModulePolicyRecords = ${JSON.stringify(policies, null, 2)} as const;`, + `export const COMMAND_METADATA: ModuleMetadataRecords = ${JSON.stringify(policies, null, 2)} as const;`, '' ].join('\n'); diff --git a/packages/json/lib/commands/ARRAPPEND.ts b/packages/json/lib/commands/ARRAPPEND.ts index f54c0749cd6..2075b0eb4fb 100644 --- a/packages/json/lib/commands/ARRAPPEND.ts +++ b/packages/json/lib/commands/ARRAPPEND.ts @@ -3,7 +3,6 @@ import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/co import { RedisArgument, NumberReply, ArrayReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/json/lib/commands/ARRINDEX.ts b/packages/json/lib/commands/ARRINDEX.ts index 20236df8472..7da9ece07d0 100644 --- a/packages/json/lib/commands/ARRINDEX.ts +++ b/packages/json/lib/commands/ARRINDEX.ts @@ -10,7 +10,6 @@ export interface JsonArrIndexOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/json/lib/commands/ARRINSERT.ts b/packages/json/lib/commands/ARRINSERT.ts index 6b96e2ccf71..79785011135 100644 --- a/packages/json/lib/commands/ARRINSERT.ts +++ b/packages/json/lib/commands/ARRINSERT.ts @@ -3,7 +3,6 @@ import { RedisArgument, NumberReply, ArrayReply, NullReply, Command } from '@red import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/json/lib/commands/ARRLEN.ts b/packages/json/lib/commands/ARRLEN.ts index f49166c218d..c4adae04181 100644 --- a/packages/json/lib/commands/ARRLEN.ts +++ b/packages/json/lib/commands/ARRLEN.ts @@ -6,7 +6,6 @@ export interface JsonArrLenOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonArrLenOptions) { parser.push('JSON.ARRLEN'); parser.pushKey(key); diff --git a/packages/json/lib/commands/ARRPOP.ts b/packages/json/lib/commands/ARRPOP.ts index 2d35d50504e..9a358ca56aa 100644 --- a/packages/json/lib/commands/ARRPOP.ts +++ b/packages/json/lib/commands/ARRPOP.ts @@ -10,7 +10,6 @@ export type RedisArrPopOptions = { ); export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: RedisArrPopOptions) { parser.push('JSON.ARRPOP'); parser.pushKey(key); diff --git a/packages/json/lib/commands/ARRTRIM.ts b/packages/json/lib/commands/ARRTRIM.ts index 573fa787507..8ed0533f7cd 100644 --- a/packages/json/lib/commands/ARRTRIM.ts +++ b/packages/json/lib/commands/ARRTRIM.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, NumberReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, path: RedisArgument, start: number, stop: number) { parser.push('JSON.ARRTRIM'); parser.pushKey(key); diff --git a/packages/json/lib/commands/CLEAR.ts b/packages/json/lib/commands/CLEAR.ts index b86513cc219..9774496d546 100644 --- a/packages/json/lib/commands/CLEAR.ts +++ b/packages/json/lib/commands/CLEAR.ts @@ -6,7 +6,6 @@ export interface JsonClearOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonClearOptions) { parser.push('JSON.CLEAR'); parser.pushKey(key); diff --git a/packages/json/lib/commands/DEBUG_MEMORY.ts b/packages/json/lib/commands/DEBUG_MEMORY.ts index aa36d74c077..a9efee9f068 100644 --- a/packages/json/lib/commands/DEBUG_MEMORY.ts +++ b/packages/json/lib/commands/DEBUG_MEMORY.ts @@ -6,7 +6,6 @@ export interface JsonDebugMemoryOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonDebugMemoryOptions) { parser.push('JSON.DEBUG', 'MEMORY'); parser.pushKey(key); diff --git a/packages/json/lib/commands/DEL.ts b/packages/json/lib/commands/DEL.ts index e86366bebe6..70425aa8001 100644 --- a/packages/json/lib/commands/DEL.ts +++ b/packages/json/lib/commands/DEL.ts @@ -6,7 +6,6 @@ export interface JsonDelOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonDelOptions) { parser.push('JSON.DEL'); parser.pushKey(key); diff --git a/packages/json/lib/commands/FORGET.ts b/packages/json/lib/commands/FORGET.ts index 0a8ed3d91c4..cd24794bcd3 100644 --- a/packages/json/lib/commands/FORGET.ts +++ b/packages/json/lib/commands/FORGET.ts @@ -6,7 +6,6 @@ export interface JsonForgetOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonForgetOptions) { parser.push('JSON.FORGET'); parser.pushKey(key); diff --git a/packages/json/lib/commands/GET.ts b/packages/json/lib/commands/GET.ts index 81d512be234..d75bf069d02 100644 --- a/packages/json/lib/commands/GET.ts +++ b/packages/json/lib/commands/GET.ts @@ -8,7 +8,6 @@ export interface JsonGetOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/json/lib/commands/MERGE.ts b/packages/json/lib/commands/MERGE.ts index 78f713c2223..ea5caf69c95 100644 --- a/packages/json/lib/commands/MERGE.ts +++ b/packages/json/lib/commands/MERGE.ts @@ -3,7 +3,6 @@ import { SimpleStringReply, Command, RedisArgument } from '@redis/client/dist/li import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, path: RedisArgument, value: RedisJSON) { parser.push('JSON.MERGE'); parser.pushKey(key); diff --git a/packages/json/lib/commands/MGET.ts b/packages/json/lib/commands/MGET.ts index a8b26513a99..413266dc372 100644 --- a/packages/json/lib/commands/MGET.ts +++ b/packages/json/lib/commands/MGET.ts @@ -13,7 +13,6 @@ import { } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, keys: Array, diff --git a/packages/json/lib/commands/MSET.ts b/packages/json/lib/commands/MSET.ts index 0cf819eea5d..92aeac7877a 100644 --- a/packages/json/lib/commands/MSET.ts +++ b/packages/json/lib/commands/MSET.ts @@ -9,7 +9,6 @@ export interface JsonMSetItem { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, items: Array) { parser.push('JSON.MSET'); diff --git a/packages/json/lib/commands/NUMINCRBY.ts b/packages/json/lib/commands/NUMINCRBY.ts index 02c1c17dbc9..1f883a857f9 100644 --- a/packages/json/lib/commands/NUMINCRBY.ts +++ b/packages/json/lib/commands/NUMINCRBY.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, NumberReply, DoubleReply, NullReply, BlobStringReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, path: RedisArgument, by: number) { parser.push('JSON.NUMINCRBY'); parser.pushKey(key); diff --git a/packages/json/lib/commands/NUMMULTBY.ts b/packages/json/lib/commands/NUMMULTBY.ts index c3621908a4c..80376ffe42d 100644 --- a/packages/json/lib/commands/NUMMULTBY.ts +++ b/packages/json/lib/commands/NUMMULTBY.ts @@ -3,7 +3,6 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import NUMINCRBY from './NUMINCRBY'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, path: RedisArgument, by: number) { parser.push('JSON.NUMMULTBY'); parser.pushKey(key); diff --git a/packages/json/lib/commands/OBJKEYS.ts b/packages/json/lib/commands/OBJKEYS.ts index f7e94dd4dfc..eb822cd4e2b 100644 --- a/packages/json/lib/commands/OBJKEYS.ts +++ b/packages/json/lib/commands/OBJKEYS.ts @@ -6,7 +6,6 @@ export interface JsonObjKeysOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonObjKeysOptions) { parser.push('JSON.OBJKEYS'); parser.pushKey(key); diff --git a/packages/json/lib/commands/OBJLEN.ts b/packages/json/lib/commands/OBJLEN.ts index d1286a89b8c..cc630e2d693 100644 --- a/packages/json/lib/commands/OBJLEN.ts +++ b/packages/json/lib/commands/OBJLEN.ts @@ -6,7 +6,6 @@ export interface JsonObjLenOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonObjLenOptions) { parser.push('JSON.OBJLEN'); parser.pushKey(key); diff --git a/packages/json/lib/commands/RESP.ts b/packages/json/lib/commands/RESP.ts index 15442d1872d..56d32c6b598 100644 --- a/packages/json/lib/commands/RESP.ts +++ b/packages/json/lib/commands/RESP.ts @@ -4,7 +4,6 @@ import { Command, RedisArgument } from "@redis/client/dist/lib/RESP/types"; type RESPReply = Array; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, path?: string) { parser.push('JSON.RESP'); parser.pushKey(key); diff --git a/packages/json/lib/commands/SET.ts b/packages/json/lib/commands/SET.ts index ff50aa67445..29c22cde891 100644 --- a/packages/json/lib/commands/SET.ts +++ b/packages/json/lib/commands/SET.ts @@ -20,7 +20,6 @@ export interface JsonSetOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/json/lib/commands/STRAPPEND.ts b/packages/json/lib/commands/STRAPPEND.ts index ccb3d6f5870..11f81fb5c18 100644 --- a/packages/json/lib/commands/STRAPPEND.ts +++ b/packages/json/lib/commands/STRAPPEND.ts @@ -7,7 +7,6 @@ export interface JsonStrAppendOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, append: string, options?: JsonStrAppendOptions) { parser.push('JSON.STRAPPEND'); parser.pushKey(key); diff --git a/packages/json/lib/commands/STRLEN.ts b/packages/json/lib/commands/STRLEN.ts index 644cdf27ef7..38e1f560daf 100644 --- a/packages/json/lib/commands/STRLEN.ts +++ b/packages/json/lib/commands/STRLEN.ts @@ -6,7 +6,6 @@ export interface JsonStrLenOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonStrLenOptions) { parser.push('JSON.STRLEN'); parser.pushKey(key); diff --git a/packages/json/lib/commands/TOGGLE.ts b/packages/json/lib/commands/TOGGLE.ts index 85c769729c7..9a60c31c007 100644 --- a/packages/json/lib/commands/TOGGLE.ts +++ b/packages/json/lib/commands/TOGGLE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, NumberReply, NullReply, Command, } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, path: RedisArgument) { parser.push('JSON.TOGGLE'); parser.pushKey(key); diff --git a/packages/json/lib/commands/TYPE.ts b/packages/json/lib/commands/TYPE.ts index 1146043b2c2..d7290461164 100644 --- a/packages/json/lib/commands/TYPE.ts +++ b/packages/json/lib/commands/TYPE.ts @@ -6,7 +6,6 @@ export interface JsonTypeOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: JsonTypeOptions) { parser.push('JSON.TYPE'); parser.pushKey(key); diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 84629328b24..2a98c9dc58e 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -211,8 +211,6 @@ function transformAggregateReplyResp3( } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: false, parseCommand(parser: CommandParser, index: RedisArgument, query: RedisArgument, options?: FtAggregateOptions) { parser.push('FT.AGGREGATE', index, query); diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts index e8c85c6f16b..ae86025eaa4 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts @@ -41,7 +41,6 @@ function transformAggregateWithCursorReplyResp3( } export default { - IS_READ_ONLY: AGGREGATE.IS_READ_ONLY, parseCommand(parser: CommandParser, index: RedisArgument, query: RedisArgument, options?: FtAggregateWithCursorOptions) { AGGREGATE.parseCommand(parser, index, query, options); parser.push('WITHCURSOR'); diff --git a/packages/search/lib/commands/ALIASADD.ts b/packages/search/lib/commands/ALIASADD.ts index c35e60bed4f..e0c71cedd89 100644 --- a/packages/search/lib/commands/ALIASADD.ts +++ b/packages/search/lib/commands/ALIASADD.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, alias: RedisArgument, index: RedisArgument) { parser.push('FT.ALIASADD', alias, index); }, diff --git a/packages/search/lib/commands/ALIASDEL.ts b/packages/search/lib/commands/ALIASDEL.ts index 9a2dbda4b9e..bcfd289a3c9 100644 --- a/packages/search/lib/commands/ALIASDEL.ts +++ b/packages/search/lib/commands/ALIASDEL.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, alias: RedisArgument) { parser.push('FT.ALIASDEL', alias); }, diff --git a/packages/search/lib/commands/ALIASUPDATE.ts b/packages/search/lib/commands/ALIASUPDATE.ts index 3bd5ea92ba3..40dd6efe34f 100644 --- a/packages/search/lib/commands/ALIASUPDATE.ts +++ b/packages/search/lib/commands/ALIASUPDATE.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { SimpleStringReply, Command, RedisArgument } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, alias: RedisArgument, index: RedisArgument) { parser.push('FT.ALIASUPDATE', alias, index); }, diff --git a/packages/search/lib/commands/ALTER.ts b/packages/search/lib/commands/ALTER.ts index 4a68817bd2c..ffe8d5e5ad0 100644 --- a/packages/search/lib/commands/ALTER.ts +++ b/packages/search/lib/commands/ALTER.ts @@ -3,8 +3,6 @@ import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/li import { RediSearchSchema, parseSchema } from './CREATE'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, schema: RediSearchSchema) { parser.push('FT.ALTER', index, 'SCHEMA', 'ADD'); parseSchema(parser, schema); diff --git a/packages/search/lib/commands/CREATE.ts b/packages/search/lib/commands/CREATE.ts index 5cad7bc6237..bbdc31ac740 100644 --- a/packages/search/lib/commands/CREATE.ts +++ b/packages/search/lib/commands/CREATE.ts @@ -409,8 +409,6 @@ export interface CreateOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, schema: RediSearchSchema, options?: CreateOptions) { parser.push('FT.CREATE', index); diff --git a/packages/search/lib/commands/CURSOR_DEL.ts b/packages/search/lib/commands/CURSOR_DEL.ts index 5f638ebb0ee..bf48db7565d 100644 --- a/packages/search/lib/commands/CURSOR_DEL.ts +++ b/packages/search/lib/commands/CURSOR_DEL.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { SimpleStringReply, Command, RedisArgument, NumberReply, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, cursorId: UnwrapReply) { parser.push('FT.CURSOR', 'DEL', index, cursorId.toString()); }, diff --git a/packages/search/lib/commands/CURSOR_READ.ts b/packages/search/lib/commands/CURSOR_READ.ts index 6df0c56f8d8..bfa8bea9f0a 100644 --- a/packages/search/lib/commands/CURSOR_READ.ts +++ b/packages/search/lib/commands/CURSOR_READ.ts @@ -7,8 +7,6 @@ export interface FtCursorReadOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, cursor: UnwrapReply, options?: FtCursorReadOptions) { parser.push('FT.CURSOR', 'READ', index, cursor.toString()); diff --git a/packages/search/lib/commands/DICTADD.ts b/packages/search/lib/commands/DICTADD.ts index 2106775f854..7498e0d7dae 100644 --- a/packages/search/lib/commands/DICTADD.ts +++ b/packages/search/lib/commands/DICTADD.ts @@ -3,8 +3,6 @@ import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, dictionary: RedisArgument, term: RedisVariadicArgument) { parser.push('FT.DICTADD', dictionary); parser.pushVariadic(term); diff --git a/packages/search/lib/commands/DICTDEL.ts b/packages/search/lib/commands/DICTDEL.ts index 988af1139e9..2cc6cf14405 100644 --- a/packages/search/lib/commands/DICTDEL.ts +++ b/packages/search/lib/commands/DICTDEL.ts @@ -3,8 +3,6 @@ import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, dictionary: RedisArgument, term: RedisVariadicArgument) { parser.push('FT.DICTDEL', dictionary); parser.pushVariadic(term); diff --git a/packages/search/lib/commands/DICTDUMP.ts b/packages/search/lib/commands/DICTDUMP.ts index 3c223442ecb..3c34df566a9 100644 --- a/packages/search/lib/commands/DICTDUMP.ts +++ b/packages/search/lib/commands/DICTDUMP.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, SetReply, BlobStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, dictionary: RedisArgument) { parser.push('FT.DICTDUMP', dictionary); }, diff --git a/packages/search/lib/commands/DROPINDEX.ts b/packages/search/lib/commands/DROPINDEX.ts index 407bdd031aa..93f61116fd0 100644 --- a/packages/search/lib/commands/DROPINDEX.ts +++ b/packages/search/lib/commands/DROPINDEX.ts @@ -6,8 +6,6 @@ export interface FtDropIndexOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, options?: FtDropIndexOptions) { parser.push('FT.DROPINDEX', index); diff --git a/packages/search/lib/commands/EXPLAIN.ts b/packages/search/lib/commands/EXPLAIN.ts index 39a430f4371..a3727a75366 100644 --- a/packages/search/lib/commands/EXPLAIN.ts +++ b/packages/search/lib/commands/EXPLAIN.ts @@ -9,8 +9,6 @@ export interface FtExplainOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, index: RedisArgument, diff --git a/packages/search/lib/commands/EXPLAINCLI.ts b/packages/search/lib/commands/EXPLAINCLI.ts index 4ef5fba88d6..7a740a36aca 100644 --- a/packages/search/lib/commands/EXPLAINCLI.ts +++ b/packages/search/lib/commands/EXPLAINCLI.ts @@ -7,8 +7,6 @@ export interface FtExplainCLIOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, index: RedisArgument, diff --git a/packages/search/lib/commands/INFO.ts b/packages/search/lib/commands/INFO.ts index 4e16c071898..1409b9b9c8a 100644 --- a/packages/search/lib/commands/INFO.ts +++ b/packages/search/lib/commands/INFO.ts @@ -5,8 +5,6 @@ import { createTransformTuplesReplyFunc, transformDoubleReply } from "@redis/cli import { TuplesReply } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument) { parser.push('FT.INFO', index); }, diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index 9646df2b6e9..9ba060c1d37 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.ts @@ -10,8 +10,6 @@ import { } from './PROFILE_SEARCH'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, index: string, diff --git a/packages/search/lib/commands/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index 3fcb5ec3968..812f7c40379 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.ts @@ -95,8 +95,6 @@ function transformProfileSearchReplyResp3( } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, index: RedisArgument, diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index 8f8da9d9bcb..8ddc008e61e 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -215,8 +215,6 @@ function transformSearchReplyResp3( } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, query: RedisArgument, options?: FtSearchOptions) { parser.push('FT.SEARCH', index, query); diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index 635bf3b0530..2a97dd8cef4 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -2,8 +2,6 @@ import { Command, ReplyUnion, TypeMapping } from '@redis/client/dist/lib/RESP/ty import SEARCH, { SearchRawReply } from './SEARCH'; export default { - NOT_KEYED_COMMAND: SEARCH.NOT_KEYED_COMMAND, - IS_READ_ONLY: SEARCH.IS_READ_ONLY, parseCommand(...args: Parameters) { SEARCH.parseCommand(...args); args[0].push('NOCONTENT'); diff --git a/packages/search/lib/commands/SPELLCHECK.ts b/packages/search/lib/commands/SPELLCHECK.ts index 42e88fe538c..2b94dea6de4 100644 --- a/packages/search/lib/commands/SPELLCHECK.ts +++ b/packages/search/lib/commands/SPELLCHECK.ts @@ -65,8 +65,6 @@ function transformSpellCheckReplyResp3(rawReply: ReplyUnion): SpellCheckReply { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, query: RedisArgument, options?: FtSpellCheckOptions) { parser.push('FT.SPELLCHECK', index, query); diff --git a/packages/search/lib/commands/SUGADD.ts b/packages/search/lib/commands/SUGADD.ts index 34e5bccb7f1..7e0ec20c40b 100644 --- a/packages/search/lib/commands/SUGADD.ts +++ b/packages/search/lib/commands/SUGADD.ts @@ -7,7 +7,6 @@ export interface FtSugAddOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, string: RedisArgument, score: number, options?: FtSugAddOptions) { parser.push('FT.SUGADD'); parser.pushKey(key); diff --git a/packages/search/lib/commands/SUGDEL.ts b/packages/search/lib/commands/SUGDEL.ts index 6bc99456d2e..d258688ea46 100644 --- a/packages/search/lib/commands/SUGDEL.ts +++ b/packages/search/lib/commands/SUGDEL.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, string: RedisArgument) { parser.push('FT.SUGDEL'); parser.pushKey(key); diff --git a/packages/search/lib/commands/SUGGET.ts b/packages/search/lib/commands/SUGGET.ts index e8a3aecdab0..24857f1e251 100644 --- a/packages/search/lib/commands/SUGGET.ts +++ b/packages/search/lib/commands/SUGGET.ts @@ -7,7 +7,6 @@ export interface FtSugGetOptions { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, prefix: RedisArgument, options?: FtSugGetOptions) { parser.push('FT.SUGGET'); parser.pushKey(key); diff --git a/packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts b/packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts index 60bf5ee86d9..667cb726e12 100644 --- a/packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts +++ b/packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts @@ -3,7 +3,6 @@ import { isNullReply } from '@redis/client/dist/lib/commands/generic-transformer import SUGGET from './SUGGET'; export default { - IS_READ_ONLY: SUGGET.IS_READ_ONLY, parseCommand(...args: Parameters) { SUGGET.parseCommand(...args); args[0].push('WITHPAYLOADS'); diff --git a/packages/search/lib/commands/SUGGET_WITHSCORES.ts b/packages/search/lib/commands/SUGGET_WITHSCORES.ts index 94021033b38..4c2dc1d69b0 100644 --- a/packages/search/lib/commands/SUGGET_WITHSCORES.ts +++ b/packages/search/lib/commands/SUGGET_WITHSCORES.ts @@ -8,7 +8,6 @@ type SuggestScore = { } export default { - IS_READ_ONLY: SUGGET.IS_READ_ONLY, parseCommand(...args: Parameters) { SUGGET.parseCommand(...args); args[0].push('WITHSCORES'); diff --git a/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts b/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts index de829aebb22..932fd608b44 100644 --- a/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts +++ b/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts @@ -9,7 +9,6 @@ type SuggestScoreWithPayload = { } export default { - IS_READ_ONLY: SUGGET.IS_READ_ONLY, parseCommand(...args: Parameters) { SUGGET.parseCommand(...args); args[0].push( diff --git a/packages/search/lib/commands/SUGLEN.ts b/packages/search/lib/commands/SUGLEN.ts index a3f0fbe45ed..787be1581f8 100644 --- a/packages/search/lib/commands/SUGLEN.ts +++ b/packages/search/lib/commands/SUGLEN.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('FT.SUGLEN', key); }, diff --git a/packages/search/lib/commands/SYNDUMP.ts b/packages/search/lib/commands/SYNDUMP.ts index 5f454f96fe0..3ce4171f3f6 100644 --- a/packages/search/lib/commands/SYNDUMP.ts +++ b/packages/search/lib/commands/SYNDUMP.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, MapReply, BlobStringReply, ArrayReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument) { parser.push('FT.SYNDUMP', index); }, diff --git a/packages/search/lib/commands/SYNUPDATE.ts b/packages/search/lib/commands/SYNUPDATE.ts index 3af735412ae..41f690a3fa5 100644 --- a/packages/search/lib/commands/SYNUPDATE.ts +++ b/packages/search/lib/commands/SYNUPDATE.ts @@ -7,8 +7,6 @@ export interface FtSynUpdateOptions { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand( parser: CommandParser, index: RedisArgument, diff --git a/packages/search/lib/commands/TAGVALS.ts b/packages/search/lib/commands/TAGVALS.ts index 0afddb247fd..f9805967a70 100644 --- a/packages/search/lib/commands/TAGVALS.ts +++ b/packages/search/lib/commands/TAGVALS.ts @@ -2,8 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, SetReply, BlobStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, index: RedisArgument, fieldName: RedisArgument) { parser.push('FT.TAGVALS', index, fieldName); }, diff --git a/packages/time-series/lib/commands/ADD.ts b/packages/time-series/lib/commands/ADD.ts index 0f254339ff9..a6e0c5b2953 100644 --- a/packages/time-series/lib/commands/ADD.ts +++ b/packages/time-series/lib/commands/ADD.ts @@ -28,7 +28,6 @@ export interface TsAddOptions { } export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/time-series/lib/commands/ALTER.ts b/packages/time-series/lib/commands/ALTER.ts index 29f99290a52..3d60ae32ab1 100644 --- a/packages/time-series/lib/commands/ALTER.ts +++ b/packages/time-series/lib/commands/ALTER.ts @@ -7,7 +7,6 @@ import { parseRetentionArgument, parseChunkSizeArgument, parseDuplicatePolicy, p export type TsAlterOptions = Pick; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: TsAlterOptions) { parser.push('TS.ALTER'); parser.pushKey(key); diff --git a/packages/time-series/lib/commands/CREATE.ts b/packages/time-series/lib/commands/CREATE.ts index c499a752f23..40dd8abbcbf 100644 --- a/packages/time-series/lib/commands/CREATE.ts +++ b/packages/time-series/lib/commands/CREATE.ts @@ -23,7 +23,6 @@ export interface TsCreateOptions { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, options?: TsCreateOptions) { parser.push('TS.CREATE'); parser.pushKey(key); diff --git a/packages/time-series/lib/commands/CREATERULE.ts b/packages/time-series/lib/commands/CREATERULE.ts index a4ee28dd17b..14298c2449d 100644 --- a/packages/time-series/lib/commands/CREATERULE.ts +++ b/packages/time-series/lib/commands/CREATERULE.ts @@ -28,7 +28,6 @@ export const TIME_SERIES_AGGREGATION_TYPE = { export type TimeSeriesAggregationType = typeof TIME_SERIES_AGGREGATION_TYPE[keyof typeof TIME_SERIES_AGGREGATION_TYPE]; export default { - IS_READ_ONLY: false, parseCommand( parser: CommandParser, sourceKey: RedisArgument, diff --git a/packages/time-series/lib/commands/DECRBY.ts b/packages/time-series/lib/commands/DECRBY.ts index 8ff09d926c0..98c533eb8ab 100644 --- a/packages/time-series/lib/commands/DECRBY.ts +++ b/packages/time-series/lib/commands/DECRBY.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import INCRBY, { parseIncrByArguments } from './INCRBY'; export default { - IS_READ_ONLY: INCRBY.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/time-series/lib/commands/DEL.ts b/packages/time-series/lib/commands/DEL.ts index de9cadf88c9..73a57dc928c 100644 --- a/packages/time-series/lib/commands/DEL.ts +++ b/packages/time-series/lib/commands/DEL.ts @@ -3,7 +3,6 @@ import { Timestamp, transformTimestampArgument } from './helpers'; import { RedisArgument, NumberReply, Command, } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, key: RedisArgument, fromTimestamp: Timestamp, toTimestamp: Timestamp) { parser.push('TS.DEL'); parser.pushKey(key); diff --git a/packages/time-series/lib/commands/DELETERULE.ts b/packages/time-series/lib/commands/DELETERULE.ts index b4e47a0fba6..29f548d5f8d 100644 --- a/packages/time-series/lib/commands/DELETERULE.ts +++ b/packages/time-series/lib/commands/DELETERULE.ts @@ -2,7 +2,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, sourceKey: RedisArgument, destinationKey: RedisArgument) { parser.push('TS.DELETERULE'); parser.pushKeys([sourceKey, destinationKey]); diff --git a/packages/time-series/lib/commands/GET.ts b/packages/time-series/lib/commands/GET.ts index c1bb2c1c749..ca1330f8b0e 100644 --- a/packages/time-series/lib/commands/GET.ts +++ b/packages/time-series/lib/commands/GET.ts @@ -8,7 +8,6 @@ export interface TsGetOptions { export type TsGetReply = TuplesReply<[]> | TuplesReply<[NumberReply, DoubleReply]>; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument, options?: TsGetOptions) { parser.push('TS.GET'); parser.pushKey(key); diff --git a/packages/time-series/lib/commands/INCRBY.ts b/packages/time-series/lib/commands/INCRBY.ts index 2e40e9e5714..13e9a8973d3 100644 --- a/packages/time-series/lib/commands/INCRBY.ts +++ b/packages/time-series/lib/commands/INCRBY.ts @@ -46,7 +46,6 @@ export function parseIncrByArguments( } export default { - IS_READ_ONLY: false, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts index 07279168d9e..f0befe5abdd 100644 --- a/packages/time-series/lib/commands/INFO.ts +++ b/packages/time-series/lib/commands/INFO.ts @@ -250,7 +250,6 @@ function transformInfoReplyResp3(reply: ReplyUnion, preserve?: unknown, typeMapp } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: string) { parser.push('TS.INFO'); parser.pushKey(key); diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index 13344976a38..9b3685aff12 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -71,7 +71,6 @@ function normalizeChunks(chunks: unknown): InfoDebugReply['chunks'] { } export default { - IS_READ_ONLY: INFO.IS_READ_ONLY, parseCommand(parser: CommandParser, key: string) { INFO.parseCommand(parser, key); parser.push('DEBUG'); diff --git a/packages/time-series/lib/commands/MADD.ts b/packages/time-series/lib/commands/MADD.ts index b4c91a98384..11d943c893e 100644 --- a/packages/time-series/lib/commands/MADD.ts +++ b/packages/time-series/lib/commands/MADD.ts @@ -9,7 +9,6 @@ export interface TsMAddSample { } export default { - IS_READ_ONLY: false, parseCommand(parser: CommandParser, toAdd: Array) { parser.push('TS.MADD'); diff --git a/packages/time-series/lib/commands/MGET.ts b/packages/time-series/lib/commands/MGET.ts index 1b8a9a55a90..4c2740c25e4 100644 --- a/packages/time-series/lib/commands/MGET.ts +++ b/packages/time-series/lib/commands/MGET.ts @@ -45,8 +45,6 @@ export type MGetRawReply3 = MapReply< >; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter: RedisVariadicArgument, options?: TsMGetOptions) { parser.push('TS.MGET'); parseLatestArgument(parser, options?.LATEST); diff --git a/packages/time-series/lib/commands/MGET_SELECTED_LABELS.ts b/packages/time-series/lib/commands/MGET_SELECTED_LABELS.ts index d74d073c174..dd8c37aa957 100644 --- a/packages/time-series/lib/commands/MGET_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MGET_SELECTED_LABELS.ts @@ -6,7 +6,6 @@ import { parseSelectedLabelsArguments } from './helpers'; import { createTransformMGetLabelsReply } from './MGET_WITHLABELS'; export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter: RedisVariadicArgument, selectedLabels: RedisVariadicArgument, options?: TsMGetOptions) { parser.push('TS.MGET'); parseLatestArgument(parser, options?.LATEST); diff --git a/packages/time-series/lib/commands/MGET_WITHLABELS.ts b/packages/time-series/lib/commands/MGET_WITHLABELS.ts index 737e7236130..cda2e7bd5a2 100644 --- a/packages/time-series/lib/commands/MGET_WITHLABELS.ts +++ b/packages/time-series/lib/commands/MGET_WITHLABELS.ts @@ -51,7 +51,6 @@ export function createTransformMGetLabelsReply() { } export default { - IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter: RedisVariadicArgument, options?: TsMGetWithLabelsOptions) { parser.push('TS.MGET'); parseLatestArgument(parser, options?.LATEST); diff --git a/packages/time-series/lib/commands/MRANGE.ts b/packages/time-series/lib/commands/MRANGE.ts index fd99fc0969d..92bec374dbd 100644 --- a/packages/time-series/lib/commands/MRANGE.ts +++ b/packages/time-series/lib/commands/MRANGE.ts @@ -47,8 +47,6 @@ export function createTransformMRangeArguments(command: RedisArgument) { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand: createTransformMRangeArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts index 5c4d41c850c..ed0ae62dbd9 100644 --- a/packages/time-series/lib/commands/MRANGE_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts @@ -102,7 +102,6 @@ export function extractResp3MRangeSources(raw: TsMRangeGroupByRawMetadataReply3) } export default { - IS_READ_ONLY: true, parseCommand: createTransformMRangeGroupByArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeGroupByRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_MULTIAGGR.ts b/packages/time-series/lib/commands/MRANGE_MULTIAGGR.ts index 470816030d7..0ead923c7f1 100644 --- a/packages/time-series/lib/commands/MRANGE_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MRANGE_MULTIAGGR.ts @@ -53,8 +53,6 @@ export function createTransformMRangeMultiArguments(command: RedisArgument) { } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand: createTransformMRangeMultiArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeMultiRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts index 353d23b5b7e..5c3c9113f9f 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts @@ -55,7 +55,6 @@ export function createTransformMRangeSelectedLabelsArguments(command: RedisArgum } export default { - IS_READ_ONLY: true, parseCommand: createTransformMRangeSelectedLabelsArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeSelectedLabelsRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts index 38e3a4a60d3..80923604b2a 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts @@ -50,7 +50,6 @@ export function createMRangeSelectedLabelsGroupByTransformArguments( } export default { - IS_READ_ONLY: true, parseCommand: createMRangeSelectedLabelsGroupByTransformArguments('TS.MRANGE'), transformReply: { 2: MRANGE_SELECTED_LABELS.transformReply[2], diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.ts index 78676db5119..f66cfdc2b97 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.ts @@ -61,8 +61,6 @@ export function createTransformMRangeSelectedLabelsMultiArguments(command: Redis } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand: createTransformMRangeSelectedLabelsMultiArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeSelectedLabelsMultiRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts index 925b7c4fb73..5cac099c1d0 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts @@ -52,8 +52,6 @@ export function createTransformMRangeWithLabelsArguments(command: RedisArgument) } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand: createTransformMRangeWithLabelsArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeWithLabelsRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.ts index 134eac1ef54..c32c6fe0163 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.ts @@ -53,7 +53,6 @@ export function createMRangeWithLabelsGroupByTransformArguments(command: RedisAr } export default { - IS_READ_ONLY: true, parseCommand: createMRangeWithLabelsGroupByTransformArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeWithLabelsGroupByRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts index 0192e9c97c8..fd43c612cc2 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts @@ -58,8 +58,6 @@ export function createTransformMRangeWithLabelsMultiArguments(command: RedisArgu } export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand: createTransformMRangeWithLabelsMultiArguments('TS.MRANGE'), transformReply: { 2(reply: TsMRangeWithLabelsMultiRawReply2, _?: unknown, typeMapping?: TypeMapping) { diff --git a/packages/time-series/lib/commands/MREVRANGE.ts b/packages/time-series/lib/commands/MREVRANGE.ts index 99d3123dd27..e8ab6ad6b6d 100644 --- a/packages/time-series/lib/commands/MREVRANGE.ts +++ b/packages/time-series/lib/commands/MREVRANGE.ts @@ -2,8 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE, { createTransformMRangeArguments } from './MRANGE'; export default { - NOT_KEYED_COMMAND: MRANGE.NOT_KEYED_COMMAND, - IS_READ_ONLY: MRANGE.IS_READ_ONLY, parseCommand: createTransformMRangeArguments('TS.MREVRANGE'), transformReply: MRANGE.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts b/packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts index 4afcd113505..dce0ba66a13 100644 --- a/packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts +++ b/packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_GROUPBY, { createTransformMRangeGroupByArguments } from './MRANGE_GROUPBY'; export default { - IS_READ_ONLY: MRANGE_GROUPBY.IS_READ_ONLY, parseCommand: createTransformMRangeGroupByArguments('TS.MREVRANGE'), transformReply: MRANGE_GROUPBY.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts b/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts index f0a3fde7255..4e1bccc2674 100644 --- a/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts @@ -2,8 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_MULTIAGGR, { createTransformMRangeMultiArguments } from './MRANGE_MULTIAGGR'; export default { - NOT_KEYED_COMMAND: MRANGE_MULTIAGGR.NOT_KEYED_COMMAND, - IS_READ_ONLY: MRANGE_MULTIAGGR.IS_READ_ONLY, parseCommand: createTransformMRangeMultiArguments('TS.MREVRANGE'), transformReply: MRANGE_MULTIAGGR.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts index 10e00fc7a29..14a263e5146 100644 --- a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_SELECTED_LABELS, { createTransformMRangeSelectedLabelsArguments } from './MRANGE_SELECTED_LABELS'; export default { - IS_READ_ONLY: MRANGE_SELECTED_LABELS.IS_READ_ONLY, parseCommand: createTransformMRangeSelectedLabelsArguments('TS.MREVRANGE'), transformReply: MRANGE_SELECTED_LABELS.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts index b000c04c183..1670f50a732 100644 --- a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts +++ b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_SELECTED_LABELS_GROUPBY, { createMRangeSelectedLabelsGroupByTransformArguments } from './MRANGE_SELECTED_LABELS_GROUPBY'; export default { - IS_READ_ONLY: MRANGE_SELECTED_LABELS_GROUPBY.IS_READ_ONLY, parseCommand: createMRangeSelectedLabelsGroupByTransformArguments('TS.MREVRANGE'), transformReply: MRANGE_SELECTED_LABELS_GROUPBY.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts index b50adaf7828..a204130b138 100644 --- a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts @@ -2,8 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_SELECTED_LABELS_MULTIAGGR, { createTransformMRangeSelectedLabelsMultiArguments } from './MRANGE_SELECTED_LABELS_MULTIAGGR'; export default { - NOT_KEYED_COMMAND: MRANGE_SELECTED_LABELS_MULTIAGGR.NOT_KEYED_COMMAND, - IS_READ_ONLY: MRANGE_SELECTED_LABELS_MULTIAGGR.IS_READ_ONLY, parseCommand: createTransformMRangeSelectedLabelsMultiArguments('TS.MREVRANGE'), transformReply: MRANGE_SELECTED_LABELS_MULTIAGGR.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts index 6cde143c422..b05f062a2fd 100644 --- a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts +++ b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts @@ -2,8 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_WITHLABELS, { createTransformMRangeWithLabelsArguments } from './MRANGE_WITHLABELS'; export default { - NOT_KEYED_COMMAND: MRANGE_WITHLABELS.NOT_KEYED_COMMAND, - IS_READ_ONLY: MRANGE_WITHLABELS.IS_READ_ONLY, parseCommand: createTransformMRangeWithLabelsArguments('TS.MREVRANGE'), transformReply: MRANGE_WITHLABELS.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts index 4727112b974..47dd35547f4 100644 --- a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts +++ b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_WITHLABELS_GROUPBY, { createMRangeWithLabelsGroupByTransformArguments } from './MRANGE_WITHLABELS_GROUPBY'; export default { - IS_READ_ONLY: MRANGE_WITHLABELS_GROUPBY.IS_READ_ONLY, parseCommand: createMRangeWithLabelsGroupByTransformArguments('TS.MREVRANGE'), transformReply: MRANGE_WITHLABELS_GROUPBY.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts index 3e0efb10599..5192d3474de 100644 --- a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts @@ -2,8 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import MRANGE_WITHLABELS_MULTIAGGR, { createTransformMRangeWithLabelsMultiArguments } from './MRANGE_WITHLABELS_MULTIAGGR'; export default { - NOT_KEYED_COMMAND: MRANGE_WITHLABELS_MULTIAGGR.NOT_KEYED_COMMAND, - IS_READ_ONLY: MRANGE_WITHLABELS_MULTIAGGR.IS_READ_ONLY, parseCommand: createTransformMRangeWithLabelsMultiArguments('TS.MREVRANGE'), transformReply: MRANGE_WITHLABELS_MULTIAGGR.transformReply, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/QUERYINDEX.ts b/packages/time-series/lib/commands/QUERYINDEX.ts index 1b53e84b7a3..9d208a9ede8 100644 --- a/packages/time-series/lib/commands/QUERYINDEX.ts +++ b/packages/time-series/lib/commands/QUERYINDEX.ts @@ -3,8 +3,6 @@ import { ArrayReply, BlobStringReply, SetReply, Command } from '@redis/client/di import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { - NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter: RedisVariadicArgument) { parser.push('TS.QUERYINDEX'); parser.pushVariadic(filter); diff --git a/packages/time-series/lib/commands/RANGE.ts b/packages/time-series/lib/commands/RANGE.ts index 43c1357f88f..e8758c22038 100644 --- a/packages/time-series/lib/commands/RANGE.ts +++ b/packages/time-series/lib/commands/RANGE.ts @@ -67,7 +67,6 @@ export function transformRangeArguments( } export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts b/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts index dc32ff94684..858c14dd80b 100644 --- a/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts @@ -66,7 +66,6 @@ export function transformRangeMultiArguments( } export default { - IS_READ_ONLY: true, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/time-series/lib/commands/READ.ts b/packages/time-series/lib/commands/READ.ts index 9d60eae4ba3..816b93fa228 100644 --- a/packages/time-series/lib/commands/READ.ts +++ b/packages/time-series/lib/commands/READ.ts @@ -41,7 +41,6 @@ export interface TsReadOptions { } export default { - IS_READ_ONLY: true, parseCommand( parser: CommandParser, key: RedisArgument, diff --git a/packages/time-series/lib/commands/REVRANGE.ts b/packages/time-series/lib/commands/REVRANGE.ts index 238b2ce9fe7..9a71a220a30 100644 --- a/packages/time-series/lib/commands/REVRANGE.ts +++ b/packages/time-series/lib/commands/REVRANGE.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import RANGE, { transformRangeArguments } from './RANGE'; export default { - IS_READ_ONLY: RANGE.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; diff --git a/packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts b/packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts index e8b71806ddd..edb26cc8ebc 100644 --- a/packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts @@ -2,7 +2,6 @@ import { Command } from '@redis/client/dist/lib/RESP/types'; import RANGE_MULTIAGGR, { transformRangeMultiArguments } from './RANGE_MULTIAGGR'; export default { - IS_READ_ONLY: RANGE_MULTIAGGR.IS_READ_ONLY, parseCommand(...args: Parameters) { const parser = args[0]; From e607c15f9eb1fe600e58db7048178c9c17642c5b Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 16 Jul 2026 16:30:35 +0300 Subject: [PATCH 30/54] feat(client): implement cluster-wide SCAN and RANDOMKEY special policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Of the commands the server tips `special`, only three have a meaningful client-side interpretation; implement the remaining two and revert the rest to master's behavior: - SCAN: cluster-wide iteration behind a client-minted virtual cursor. The router pins the chain's current master (substituting the real per-node cursor) and a post-reply hook advances the chain to the next unvisited master, so the usual `cursor \!== "0"` loop now walks the whole cluster instead of hitting a random node per call. Chain state lives on cluster-slots with the same idle sweep as FT.CURSOR bindings; visited nodes are tracked by address so topology changes mid-scan neither rescan nor wedge. Unknown/expired cursors throw with a "restart from 0" hint. - RANDOMKEY: all_shards fan-out reduced to one non-nil reply at random — never a false nil while any shard holds keys. - INFO, MEMORY DOCTOR/MALLOC-STATS/STATS, LATENCY diagnostics, FUNCTION STATS and HOTKEYS have no reply merge that produces a single honest value, so they are pinned back to default-keyless (single random node, sole reply passed through — master parity, no warn) via generator overrides. The override merge now deep-merges subcommands so siblings (memory purge/usage, latency reset, hotkeys help) keep their server-derived policies. The SPECIAL_REQUEST_ROUTERS registry moves from ft-cursor.ts to dispatch.ts (routers stay leaf modules, no import cycle), and special router/reducer lookup falls back to the bare command name because SCAN's second argument is a cursor, not a subcommand. The dead per-command special response reducers (reduceFirstReply and the INFO/MEMORY/LATENCY/FUNCTION entries) are removed. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/cluster-slots.ts | 63 ++++++ packages/client/lib/cluster/index.spec.ts | 37 ++++ packages/client/lib/cluster/index.ts | 16 +- .../dispatch.spec.ts | 35 +++- .../request-response-policies/dispatch.ts | 65 +++--- .../request-response-policies/ft-cursor.ts | 6 - .../scan-cursor.spec.ts | 194 ++++++++++++++++++ .../request-response-policies/scan-cursor.ts | 158 ++++++++++++++ .../command-metadata/command-metadata-data.ts | 84 +++++--- .../static-metadata-resolver.spec.ts | 91 ++++++++ .../scripts/command-metadata-overrides.ts | 25 ++- .../scripts/generate-command-metadata-data.ts | 23 ++- 12 files changed, 720 insertions(+), 77 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/scan-cursor.ts diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 26c015c5250..367d17b2ded 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -42,6 +42,21 @@ export interface CursorBinding { */ const DEFAULT_CURSOR_MAX_IDLE_MS = 300_000; +/** + * One in-flight cluster-wide SCAN chain (see + * `request-response-policies/scan-cursor.ts`). SCAN cursors are per-node + * state, so a cluster-wide iteration walks the masters one at a time: the + * entry pins the node currently being scanned, the real server cursor to + * resume it with, and the masters already exhausted (tracked by address so a + * topology refresh mid-scan doesn't rescan or skip nodes that survived). + */ +export interface ScanCursorEntry { + address: string; + cursor: string; + visited: Set; + createdAt: number; +} + export interface Node< M extends RedisModules, F extends RedisFunctions, @@ -143,6 +158,9 @@ export default class RedisClusterSlots< smigratedSeqIdsSeen = new Set; /** Per-instance sticky-cursor bindings, keyed `${index}:${cursorId}`. */ readonly cursorBindings = new Map(); + /** Per-instance cluster-wide SCAN chains, keyed by the virtual cursor token. */ + readonly scanCursors = new Map(); + #scanCursorSeq = 0; #topologyRefreshPromise?: Promise; #isOpen = false; @@ -1014,6 +1032,51 @@ export default class RedisClusterSlots< this.cursorBindings.delete(this.#cursorKey(index, cursorId)); } + /** + * Mint a fresh virtual SCAN cursor token. Tokens are what cluster-wide SCAN + * hands back to the caller in place of the per-node server cursor: opaque, + * non-"0", never colliding with each other. A plain counter keeps them + * valid-looking cursor strings for callers that treat the cursor as an + * opaque number. + */ + mintScanCursorToken(): string { + return String(++this.#scanCursorSeq); + } + + /** + * Same opportunistic, timer-free sweep as `#sweepStaleCursors`: an abandoned + * scan (caller stopped iterating mid-way) would otherwise leak its entry. + */ + #sweepStaleScanCursors(now: number) { + for (const [token, entry] of this.scanCursors) { + if (now - entry.createdAt > DEFAULT_CURSOR_MAX_IDLE_MS) { + this.scanCursors.delete(token); + } + } + } + + bindScanCursor(token: string, address: string, cursor: string, visited: Set) { + const now = Date.now(); + this.#sweepStaleScanCursors(now); + this.scanCursors.set(token, { address, cursor, visited, createdAt: now }); + } + + lookupScanCursor(token: string): ScanCursorEntry | undefined { + return this.scanCursors.get(token); + } + + evictScanCursor(token: string) { + this.scanCursors.delete(token); + } + + /** + * First master (in current topology order) whose address is not in + * `visited` — the next node a cluster-wide SCAN chain should walk. + */ + nextScanTarget(visited: ReadonlySet): string | undefined { + return this.masters.find(master => !visited.has(master.address))?.address; + } + getPubSubClient(): Promise> { this.#assertReady(); diff --git a/packages/client/lib/cluster/index.spec.ts b/packages/client/lib/cluster/index.spec.ts index 6de2a8a623e..a9d16bd9fbf 100644 --- a/packages/client/lib/cluster/index.spec.ts +++ b/packages/client/lib/cluster/index.spec.ts @@ -318,6 +318,43 @@ describe('Cluster', () => { await assert.rejects(cluster.mGet(['a', 'b'])); }, GLOBAL.CLUSTERS.OPEN); + testUtils.testWithCluster('cluster-wide SCAN iterates every master', async cluster => { + const expected = new Set(); + const writes: Array> = []; + for (let i = 0; i < 100; i++) { + const key = `scan-all:${i}`; + expected.add(key); + writes.push(cluster.set(key, 'v')); + } + await Promise.all(writes); + + // Low COUNT forces several iterations per node, exercising both the + // virtual-token continuation on one node and the advance between nodes. + const found = new Set(); + let cursor = '0'; + do { + const reply = await cluster.scan(cursor, { MATCH: 'scan-all:*', COUNT: 29 }); + cursor = reply.cursor; + for (const key of reply.keys) found.add(key); + } while (cursor !== '0'); + + assert.deepEqual(found, expected); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('cluster-wide SCAN rejects a foreign cursor', async cluster => { + await assert.rejects(cluster.scan('123456'), /unknown cursor/); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('RANDOMKEY finds the key whichever shard holds it', async cluster => { + // Single key in the whole cluster: a single-node RANDOMKEY would return + // nil whenever the randomly-picked node is one of the empty masters; the + // all_shards fan-out + non-nil reduction must always find it. + await cluster.set('the-only-key', 'v'); + for (let i = 0; i < 5; i++) { + assert.equal(await cluster.randomKey(), 'the-only-key'); + } + }, GLOBAL.CLUSTERS.OPEN); + describe('minimizeConnections', () => { testUtils.testWithCluster('false', async cluster => { for (const master of cluster.masters) { diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 3f3fc8922f1..39ff972a788 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -19,6 +19,7 @@ import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; import { captureCursorBinding } from './request-response-policies/ft-cursor'; +import { finalizeScanCursor } from './request-response-policies/scan-cursor'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -573,7 +574,7 @@ export default class RedisCluster< throw new Error(`Unknown response policy ${responsePolicy}`); } const positionHints = plan.map(entry => entry.groupIndices); - const reply = await (reducer(responsePromises, parser, positionHints) as Promise); + let reply = await (reducer(responsePromises, parser, positionHints) as Promise); // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the // serving node from the resolved reply. Command-name gated and best-effort @@ -588,6 +589,19 @@ export default class RedisCluster< ); } catch { /* binding capture is best-effort */ } + // Cluster-wide SCAN: advance the scan chain and swap the per-node server + // cursor for the chain's virtual token. Command-name gated; best-effort — + // on failure the caller gets the raw server cursor, which MISSes (with a + // clear error) on the next call instead of silently iterating wrong. + try { + reply = finalizeScanCursor( + this._slots as unknown as Parameters[0], + parser, + plan, + reply + ) as typeof reply; + } catch { /* scan finalization is best-effort */ } + return reply; } diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts index 70b8e1fb2f3..654f16dd76a 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts @@ -1,10 +1,43 @@ import { strict as assert } from 'node:assert'; import type { CommandParser } from '../../client/parser'; -import { reduceDefaultKeyed } from './dispatch'; +import { reduceDefaultKeyed, reduceRandomKey, reduceSpecial } from './dispatch'; // The reducer ignores the parser; a stub keeps the calls readable. const PARSER = {} as CommandParser; +describe('reduceRandomKey', () => { + it('returns the sole non-nil reply when other shards are empty', async () => { + const reply = await reduceRandomKey([ + Promise.resolve(null), + Promise.resolve('the-key'), + Promise.resolve(null) + ]); + assert.equal(reply, 'the-key'); + }); + + it('returns one of the non-nil replies', async () => { + const reply = await reduceRandomKey([ + Promise.resolve('a'), + Promise.resolve(null), + Promise.resolve('b') + ]); + assert.ok(reply === 'a' || reply === 'b'); + }); + + it('returns nil only when every shard is empty', async () => { + const reply = await reduceRandomKey([Promise.resolve(null), Promise.resolve(null)]); + assert.equal(reply, null); + }); + + it('is dispatched for RANDOMKEY through the special response policy', async () => { + const parser = { + commandIdentifier: { command: 'randomkey', subcommand: undefined } + } as unknown as CommandParser; + const reply = await reduceSpecial([Promise.resolve(null), Promise.resolve('k')], parser); + assert.equal(reply, 'k'); + }); +}); + describe('reduceDefaultKeyed', () => { it('passes the sole reply through when not split (no hints)', async () => { const reply = await reduceDefaultKeyed([Promise.resolve(['v1', 'v2'])], PARSER); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 8274ee1cef9..b1f44635a49 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -20,7 +20,8 @@ import { type RequestPolicyWithDefaults, type ResponsePolicyWithDefaults } from '../../command-metadata/policies-constants'; -import { SPECIAL_REQUEST_ROUTERS } from './ft-cursor'; +import { routeFtCursor } from './ft-cursor'; +import { routeScan } from './scan-cursor'; // Routing runs *below* the typed command surface: routers never inspect the // command's M/F/S/RESP/TM parameters, they just shuffle opaque clients from @@ -121,16 +122,29 @@ function specialKey(parser: CommandParser): string { return subcommand ? `${c} ${subcommand.toUpperCase()}` : c; } +/** + * Special-request routers. Looked up by `COMMAND SUBCOMMAND` first, then bare + * `COMMAND` — SCAN registers bare because its second argument is a cursor, + * which the naive `commandIdentifier` mistakes for a subcommand. + */ +export const SPECIAL_REQUEST_ROUTERS: Record = { + 'FT.CURSOR READ': routeFtCursor, + 'FT.CURSOR DEL': routeFtCursor, + SCAN: routeScan +}; + /** * Router for the `special` request policy. Commands with a dedicated handler - * (e.g. FT.CURSOR sticky routing) short-circuit into `SPECIAL_REQUEST_ROUTERS` - * first. Everything else has non-trivial routing no generic rule captures and - * no handler yet: route to a single (random) node like a keyless command so it - * still works, but warn — the reply reflects only that one node. + * (e.g. FT.CURSOR sticky routing, cluster-wide SCAN) short-circuit into + * `SPECIAL_REQUEST_ROUTERS` first. Everything else has non-trivial routing no + * generic rule captures and no handler yet: route to a single (random) node + * like a keyless command so it still works, but warn — the reply reflects + * only that one node. */ export const routeSpecial: RequestRouter = async (slots, parser, isReadonly, keySpecs) => { - const handler = SPECIAL_REQUEST_ROUTERS[specialKey(parser)]; + const handler = SPECIAL_REQUEST_ROUTERS[specialKey(parser)] + ?? SPECIAL_REQUEST_ROUTERS[parser.commandIdentifier.command.toUpperCase()]; if (handler) return handler(slots, parser, isReadonly, keySpecs); console.warn( @@ -190,36 +204,16 @@ export const reduceRandomKey = async (promises: Promise[]): Promise => }; /** - * Reducer for fan-out diagnostic commands (INFO, ...) whose per-node replies - * can't be merged into one meaningful value and whose reply type is a single - * node's shape. We still fan out per the `all_shards`/`all_nodes` request tip, - * wait for every node to succeed, then return one node's reply. This keeps the - * reply type honest (it matches the single-node command type) at the cost of - * discarding the other nodes' replies. - */ -export const reduceFirstReply = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return responses[0]; -}; - -/** - * Per-command reducers for the `special` response policy, keyed by uppercased - * command identifier. A `special` response needs command-specific merging that - * no generic rule captures. Commands absent here hit `reduceSpecial`'s generic - * fallback. + * Per-command reducers for the `special` response policy, keyed like + * `SPECIAL_REQUEST_ROUTERS` (`COMMAND SUBCOMMAND`, bare-command fallback). A + * `special` response needs command-specific merging that no generic rule + * captures; commands absent here hit `reduceSpecial`'s generic fallback. SCAN + * (response also tipped `special`) needs no entry: its plan is single-node, so + * the fallback passes the sole reply through and the cursor rewrite happens in + * `finalizeScanCursor`. */ export const SPECIAL_RESPONSE_REDUCERS: Record> = { - RANDOMKEY: reduceRandomKey, - INFO: reduceFirstReply, - 'MEMORY DOCTOR': reduceFirstReply, - 'MEMORY MALLOC-STATS': reduceFirstReply, - 'MEMORY STATS': reduceFirstReply, - 'FUNCTION STATS': reduceFirstReply, - 'LATENCY DOCTOR': reduceFirstReply, - 'LATENCY GRAPH': reduceFirstReply, - 'LATENCY HISTOGRAM': reduceFirstReply, - 'LATENCY HISTORY': reduceFirstReply, - 'LATENCY LATEST': reduceFirstReply + RANDOMKEY: reduceRandomKey }; /** @@ -230,7 +224,8 @@ export const SPECIAL_RESPONSE_REDUCERS: Record> * what the command really wants. */ export const reduceSpecial = async (promises: Promise[], parser: CommandParser): Promise => { - const reducer = SPECIAL_RESPONSE_REDUCERS[specialKey(parser)]; + const reducer = SPECIAL_RESPONSE_REDUCERS[specialKey(parser)] + ?? SPECIAL_RESPONSE_REDUCERS[parser.commandIdentifier.command.toUpperCase()]; if (reducer) return reducer(promises, parser) as Promise; if (promises.length > 1) { diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts index 4a46aa668a3..73614accc83 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -80,12 +80,6 @@ export const routeFtCursor: RequestRouter = async (slots, parser) => { ); }; -/** Special-request routers, keyed like `SPECIAL_RESPONSE_REDUCERS` (see dispatch.ts). */ -export const SPECIAL_REQUEST_ROUTERS: Record = { - 'FT.CURSOR READ': routeFtCursor, - 'FT.CURSOR DEL': routeFtCursor -}; - /** * Command-name-gated hook run after an FT.AGGREGATE / FT.CURSOR reply resolves * (HLD "hardcoded by command name"). Captures, rebinds, or evicts the sticky diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts new file mode 100644 index 00000000000..6210f4eb328 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts @@ -0,0 +1,194 @@ +import { strict as assert } from 'node:assert'; +import type { CommandParser } from '../../client/parser'; +import { routeScan, finalizeScanCursor } from './scan-cursor'; + +/** + * Minimal stand-in for the scan-chain surface of `RedisClusterSlots` (see + * ft-cursor.spec.ts for the same pattern), with an ordered master list so the + * node-advance logic is exercised without spinning a cluster. + */ +class FakeSlots { + scanCursors = new Map; createdAt: number }>(); + clientsByAddress = new Map(); + masterOrder: Array = []; + #seq = 0; + + mintScanCursorToken() { return String(++this.#seq); } + bindScanCursor(token: string, address: string, cursor: string, visited: Set) { + this.scanCursors.set(token, { address, cursor, visited, createdAt: 0 }); + } + lookupScanCursor(token: string) { return this.scanCursors.get(token); } + evictScanCursor(token: string) { this.scanCursors.delete(token); } + nextScanTarget(visited: ReadonlySet) { + return this.masterOrder.find(address => !visited.has(address)); + } + async getMasterByAddress(address: string) { return this.clientsByAddress.get(address); } + nodeAddressByClient(client: object) { + for (const [address, c] of this.clientsByAddress) if (c === client) return address; + return undefined; + } + + addMaster(address: string) { + const client = { id: address }; + this.masterOrder.push(address); + this.clientsByAddress.set(address, client); + return client; + } +} + +const parserOf = (...args: Array) => + ({ redisArgs: args, commandIdentifier: { command: args[0], subcommand: args[1] } }) as unknown as CommandParser; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- routers/finalizers run below the typed surface +const asSlots = (s: FakeSlots) => s as any; + +describe('routeScan', () => { + it('SCAN 0 starts on the first master', async () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + slots.addMaster('b:1'); + + const plan = await routeScan(asSlots(slots), parserOf('SCAN', '0'), undefined, undefined); + assert.deepEqual(plan, [{ client: a }]); + }); + + it('throws when there are no masters', async () => { + const slots = new FakeSlots(); + await assert.rejects( + routeScan(asSlots(slots), parserOf('SCAN', '0'), undefined, undefined), + /no master nodes available/ + ); + }); + + it('routes a known token to its bound node with the real cursor substituted', async () => { + const slots = new FakeSlots(); + slots.addMaster('a:1'); + const b = slots.addMaster('b:1'); + slots.bindScanCursor('7', 'b:1', '42', new Set(['a:1'])); + + const plan = await routeScan( + asSlots(slots), parserOf('SCAN', '7', 'MATCH', 'user:*', 'COUNT', '100'), undefined, undefined + ); + assert.equal(plan.length, 1); + assert.equal(plan[0].client, b); + assert.deepEqual(plan[0].parser!.redisArgs, ['SCAN', '42', 'MATCH', 'user:*', 'COUNT', '100']); + }); + + it('throws on an unknown cursor token', async () => { + const slots = new FakeSlots(); + slots.addMaster('a:1'); + await assert.rejects( + routeScan(asSlots(slots), parserOf('SCAN', '999'), undefined, undefined), + /unknown cursor "999".*restart the scan from 0/s + ); + }); + + it('throws when the bound node has left the cluster', async () => { + const slots = new FakeSlots(); + slots.addMaster('a:1'); + slots.bindScanCursor('7', 'gone:1', '42', new Set()); + await assert.rejects( + routeScan(asSlots(slots), parserOf('SCAN', '7'), undefined, undefined), + /left the cluster/ + ); + }); +}); + +describe('finalizeScanCursor', () => { + it('walks a full two-master chain end to end (typed `{ cursor, keys }` reply)', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + const b = slots.addMaster('b:1'); + + // SCAN 0 on node a → mid-node cursor: token minted, reply cursor swapped. + let reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], { cursor: '5', keys: ['k1'] } + ) as { cursor: string; keys: Array }; + assert.equal(reply.cursor, '1'); + assert.deepEqual(reply.keys, ['k1']); + assert.deepEqual(slots.lookupScanCursor('1'), { address: 'a:1', cursor: '5', visited: new Set(), createdAt: 0 }); + + // node a exhausts → chain advances to node b with a fresh cursor 0. + reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '1'), [{ client: a }], { cursor: '0', keys: ['k2'] } + ) as { cursor: string; keys: Array }; + assert.equal(reply.cursor, '1'); + assert.deepEqual(slots.lookupScanCursor('1'), { address: 'b:1', cursor: '0', visited: new Set(['a:1']), createdAt: 0 }); + + // node b exhausts, no unvisited masters left → caller sees "0", entry evicted. + reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '1'), [{ client: b }], { cursor: '0', keys: ['k3'] } + ) as { cursor: string; keys: Array }; + assert.equal(reply.cursor, '0'); + assert.equal(slots.scanCursors.size, 0); + }); + + it('finishes immediately on a single-master cluster (no token minted)', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + + const reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], { cursor: '0', keys: [] } + ) as { cursor: string }; + assert.equal(reply.cursor, '0'); + assert.equal(slots.scanCursors.size, 0); + }); + + it('rewrites the raw `[cursor, keys]` reply shape at index 0', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + slots.addMaster('b:1'); + + const reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], ['17', ['k1', 'k2']] + ) as [string, Array]; + assert.deepEqual(reply, ['1', ['k1', 'k2']]); + assert.equal(slots.lookupScanCursor('1')!.cursor, '17'); + }); + + it('keeps the cursor a Buffer under a Buffer type mapping', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + slots.addMaster('b:1'); + + const reply = finalizeScanCursor( + asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], { cursor: Buffer.from('17'), keys: [] } + ) as { cursor: Buffer }; + assert.ok(reply.cursor instanceof Buffer); + assert.equal(reply.cursor.toString(), '1'); + }); + + it('ignores non-SCAN commands and unknown reply shapes', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + + const getReply = 'value'; + assert.equal(finalizeScanCursor(asSlots(slots), parserOf('GET', 'k'), [{ client: a }], getReply), getReply); + + const weird = { notACursor: true }; + assert.equal(finalizeScanCursor(asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], weird), weird); + assert.equal(slots.scanCursors.size, 0); + }); + + it('leaves the reply untouched when the serving node cannot be resolved', () => { + const slots = new FakeSlots(); + slots.addMaster('a:1'); + const stranger = {}; + + const reply = { cursor: '5', keys: [] }; + assert.equal(finalizeScanCursor(asSlots(slots), parserOf('SCAN', '0'), [{ client: stranger }], reply), reply); + assert.equal(slots.scanCursors.size, 0); + }); + + it('two interleaved scans keep independent chains', () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + slots.addMaster('b:1'); + + const r1 = finalizeScanCursor(asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], { cursor: '5', keys: [] }) as { cursor: string }; + const r2 = finalizeScanCursor(asSlots(slots), parserOf('SCAN', '0'), [{ client: a }], { cursor: '9', keys: [] }) as { cursor: string }; + assert.notEqual(r1.cursor, r2.cursor); + assert.equal(slots.lookupScanCursor(r1.cursor)!.cursor, '5'); + assert.equal(slots.lookupScanCursor(r2.cursor)!.cursor, '9'); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts new file mode 100644 index 00000000000..e2b4d0f07e9 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts @@ -0,0 +1,158 @@ +import { BasicCommandParser, type CommandParser } from '../../client/parser'; +import type { RedisArgument } from '../../RESP/types'; +import type { RequestRouter, RoutedCommand } from './dispatch'; +import { argToString } from './ft-cursor'; + +// Routing/finalization runs below the typed command surface (see dispatch.ts), +// so the slots handle is the erased base instantiation. `_executeWithPolicies` +// bridges its own typed slots in at the call boundary. +type ClusterSlots = Parameters[0]; + +/** + * Cluster-wide SCAN (server `request_policy: special` + `response_policy: + * special`). SCAN cursors are per-node state, so the cluster iteration walks + * the masters one at a time behind a *virtual* cursor: + * + * - `SCAN 0` starts a chain on the first master. + * - Each reply's server cursor is swapped for a client-minted token that maps + * back to (serving node, real cursor, masters already exhausted). The + * caller keeps its usual `cursor !== "0"` loop; the token is opaque. + * - When a node's cursor returns 0 the chain advances to the next unvisited + * master (fresh cursor 0); when every master is exhausted the caller + * finally sees "0". + * + * The visited set is tracked by node address, so a topology change mid-scan + * neither rescans a surviving node nor gets stuck on a departed one. The usual + * SCAN guarantees apply per node; keys migrating between nodes mid-iteration + * may be missed or duplicated — same caveat as every cluster-wide scan. + */ +export const routeScan: RequestRouter = async (slots, parser) => { + const cursorArg = argToString(parser.redisArgs[1]); + + if (cursorArg === '0') { + const address = slots.nextScanTarget(EMPTY_VISITED); + if (!address) throw new Error('SCAN: no master nodes available'); + return [{ client: await pinnedMaster(slots, address) }]; + } + + const entry = slots.lookupScanCursor(cursorArg); + if (!entry) { + throw new Error( + `SCAN: unknown cursor "${cursorArg}". Cluster-wide SCAN cursors are ` + + `minted per client instance and expire when idle — restart the scan from 0.` + ); + } + return [{ + client: await pinnedMaster(slots, entry.address), + parser: withCursor(parser, entry.cursor) + }]; +}; + +const EMPTY_VISITED: ReadonlySet = new Set(); + +async function pinnedMaster(slots: ClusterSlots, address: string) { + const client = await slots.getMasterByAddress(address); + if (!client) { + throw new Error( + `SCAN: node ${address} serving this cursor has left the cluster — ` + + `restart the scan from 0.` + ); + } + return client; +} + +/** Copy of the SCAN parser with the cursor argument (index 1) replaced. */ +function withCursor(parser: CommandParser, cursor: string): CommandParser { + const sub = new BasicCommandParser(); + const { redisArgs } = parser; + for (let i = 0; i < redisArgs.length; i++) { + sub.push(i === 1 ? cursor : redisArgs[i] as RedisArgument); + } + return sub; +} + +/** + * Post-reply hook for SCAN (invoked from `_executeWithPolicies` after the + * reducer, like `captureCursorBinding`): advances the chain state and swaps + * the server cursor in the reply for the chain's virtual token. No-op for any + * other command or a non-single-target plan. Returns the (possibly rewritten) + * reply. + */ +export function finalizeScanCursor( + slots: ClusterSlots, + parser: CommandParser, + plan: ReadonlyArray, + reply: unknown +): unknown { + if (parser.commandIdentifier.command.toUpperCase() !== 'SCAN') return reply; + if (plan.length !== 1 || !plan[0].client) return reply; + + const serverCursor = extractScanCursor(reply); + if (serverCursor === undefined) return reply; + + const address = slots.nodeAddressByClient(plan[0].client); + if (!address) return reply; + + const callerCursor = argToString(parser.redisArgs[1]); + const chain = callerCursor === '0' ? undefined : slots.lookupScanCursor(callerCursor); + const visited = chain?.visited ?? new Set(); + + if (argToString(serverCursor) !== '0') { + // Node not exhausted: resume it next call with the real cursor. + const token = callerCursor === '0' ? slots.mintScanCursorToken() : callerCursor; + slots.bindScanCursor(token, address, argToString(serverCursor), visited); + return withReplyCursor(reply, token, serverCursor); + } + + // Node exhausted: advance to the next unvisited master, or finish. + visited.add(address); + const next = slots.nextScanTarget(visited); + if (!next) { + if (callerCursor !== '0') slots.evictScanCursor(callerCursor); + return reply; // server cursor is already "0" — the chain is done + } + const token = callerCursor === '0' ? slots.mintScanCursorToken() : callerCursor; + slots.bindScanCursor(token, next, '0', visited); + return withReplyCursor(reply, token, serverCursor); +} + +/** + * Pull the cursor out of a SCAN reply across both reply paths: + * - transformed command path → `{ cursor, keys }`, + * - raw `sendCommand` → `[cursor, keys]` (cursor at index 0). + * Returns `undefined` for anything else (unknown shape → leave the reply be). + */ +function extractScanCursor(reply: unknown): RedisArgument | undefined { + if (reply == null) return undefined; + + if (Array.isArray(reply)) { + return isCursorValue(reply[0]) ? reply[0] : undefined; + } + + if (typeof reply === 'object' && 'cursor' in (reply as Record)) { + const cursor = (reply as Record).cursor; + return isCursorValue(cursor) ? cursor : undefined; + } + + return undefined; +} + +function isCursorValue(value: unknown): value is RedisArgument { + return typeof value === 'string' || value instanceof Buffer; +} + +/** + * Rebuild the reply with the virtual token in place of the server cursor, + * preserving the reply shape and the cursor's wire type (Buffer stays Buffer + * under a Buffer type mapping). + */ +function withReplyCursor(reply: unknown, token: string, original: RedisArgument): unknown { + const cursor = original instanceof Buffer ? Buffer.from(token) : token; + + if (Array.isArray(reply)) { + const copy = reply.slice(); + copy[0] = cursor; + return copy; + } + return { ...(reply as Record), cursor }; +} diff --git a/packages/client/lib/command-metadata/command-metadata-data.ts b/packages/client/lib/command-metadata/command-metadata-data.ts index 4af83d43d0c..65241c7f1a4 100644 --- a/packages/client/lib/command-metadata/command-metadata-data.ts +++ b/packages/client/lib/command-metadata/command-metadata-data.ts @@ -101,15 +101,41 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "module" ], "subcommands": { - "read": { + "del": { "request": "special", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] }, - "del": { + "gc": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, + "read": { "request": "special", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] } } }, @@ -2559,8 +2585,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "stats": { - "request": "all_shards", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "noscript", @@ -2874,8 +2900,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "flags": [], "subcommands": { "get": { - "request": "special", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -2895,7 +2921,7 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "reset": { - "request": "special", + "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "flags": [ @@ -2904,7 +2930,7 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "start": { - "request": "special", + "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "flags": [ @@ -2913,7 +2939,7 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "stop": { - "request": "special", + "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "flags": [ @@ -3096,8 +3122,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "info": { - "request": "all_shards", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "loading", @@ -3138,8 +3164,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "flags": [], "subcommands": { "doctor": { - "request": "all_nodes", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -3152,8 +3178,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "graph": { - "request": "all_nodes", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -3175,8 +3201,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "histogram": { - "request": "all_nodes", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -3189,8 +3215,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "history": { - "request": "all_nodes", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -3203,8 +3229,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "latest": { - "request": "all_nodes", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [ "admin", @@ -3367,8 +3393,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "flags": [], "subcommands": { "doctor": { - "request": "all_shards", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [], "tips": [ @@ -3385,8 +3411,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "malloc-stats": { - "request": "all_shards", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [], "tips": [ @@ -3400,8 +3426,8 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "flags": [] }, "stats": { - "request": "all_shards", - "response": "special", + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true, "flags": [], "tips": [ diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts index b468c5a7d01..92229ad99cf 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts @@ -83,6 +83,97 @@ describe('StaticMetadataResolver', () => { }); }); + describe('server-`special` commands reverted to default-keyless (no HLD client recipe)', () => { + const REVERTED: Array<{ command: string; subcommand?: string }> = [ + { command: 'INFO' }, + { command: 'MEMORY', subcommand: 'DOCTOR' }, + { command: 'MEMORY', subcommand: 'MALLOC-STATS' }, + { command: 'MEMORY', subcommand: 'STATS' }, + { command: 'LATENCY', subcommand: 'DOCTOR' }, + { command: 'LATENCY', subcommand: 'GRAPH' }, + { command: 'LATENCY', subcommand: 'HISTOGRAM' }, + { command: 'LATENCY', subcommand: 'HISTORY' }, + { command: 'LATENCY', subcommand: 'LATEST' }, + { command: 'FUNCTION', subcommand: 'STATS' }, + { command: 'HOTKEYS', subcommand: 'GET' }, + { command: 'HOTKEYS', subcommand: 'RESET' }, + { command: 'HOTKEYS', subcommand: 'START' }, + { command: 'HOTKEYS', subcommand: 'STOP' } + ]; + + for (const { command, subcommand } of REVERTED) { + const label = subcommand ? `${command} ${subcommand}` : command; + it(`${label} → default-keyless/default-keyless`, () => { + const result = resolver.resolvePolicy({ command, subcommand }); + assert.equal(result.ok, true, `expected ${label} to resolve`); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.isKeyless, true); + } + }); + } + + it('sibling subcommands keep their server-derived policies (deep-merge)', () => { + const purge = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'PURGE' }); + assert.equal(purge.ok, true); + if (purge.ok) { + assert.equal(purge.value.request, 'all_shards'); + assert.equal(purge.value.response, 'all_succeeded'); + } + + const usage = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'USAGE' }); + assert.equal(usage.ok, true); + if (usage.ok) { + assert.equal(usage.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(usage.value.isKeyless, false); + } + + const latencyReset = resolver.resolvePolicy({ command: 'LATENCY', subcommand: 'RESET' }); + assert.equal(latencyReset.ok, true); + if (latencyReset.ok) { + assert.equal(latencyReset.value.request, 'all_nodes'); + assert.equal(latencyReset.value.response, 'agg_sum'); + } + + const hotkeysHelp = resolver.resolvePolicy({ command: 'HOTKEYS', subcommand: 'HELP' }); + assert.equal(hotkeysHelp.ok, true); + if (hotkeysHelp.ok) { + assert.equal(hotkeysHelp.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(hotkeysHelp.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('FT.CURSOR READ/DEL stay special (the sole HLD client recipe)', () => { + for (const subcommand of ['READ', 'DEL']) { + const result = resolver.resolvePolicy({ command: 'FT.CURSOR', subcommand }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + } + }); + + it('SCAN keeps the server special/special (cluster-wide scan chain)', () => { + const result = resolver.resolvePolicy({ command: 'SCAN', subcommand: '0' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL); + } + }); + + it('RANDOMKEY keeps the server all_shards/special (random non-nil reply)', () => { + const result = resolver.resolvePolicy({ command: 'RANDOMKEY', subcommand: undefined }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL); + } + }); + }); + describe('errors', () => { it('unknown command in std module', () => { const r = resolver.resolvePolicy({ command: 'definitelynotacommand', subcommand: undefined }); diff --git a/packages/client/scripts/command-metadata-overrides.ts b/packages/client/scripts/command-metadata-overrides.ts index c8c0fb9fe71..37a73e74f8f 100644 --- a/packages/client/scripts/command-metadata-overrides.ts +++ b/packages/client/scripts/command-metadata-overrides.ts @@ -39,8 +39,10 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ ]); /** - * Partial-entry overrides, keyed by `module.command`, shallow-merged onto the + * Partial-entry overrides, keyed by `module.command`, merged onto the * generated entry (override keys win; unspecified keys keep the server value). + * `subcommands` is deep-merged per subcommand, so an override touching one + * subcommand keeps its siblings' server-derived policies. * * `ft.cursor` is pinned to the HLD `special` request policy (sticky cursor): * FT.CURSOR READ/DEL must reach the node that served the FT.AGGREGATE that @@ -58,7 +60,21 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ * (readonly + keyed) makes it look cacheable even though it only bumps LRU/LFU * and generates no invalidation. Inject the negative tip until the server tags * it; remove once the server metadata is fixed. + * + * `KEYLESS` reverts: the server tips these commands `special` (request and/or + * response), but there is no meaningful client-side interpretation for them + * (the HLD gives a recipe only for FT.CURSOR; SCAN and RANDOMKEY have obvious + * client semantics and are implemented — see + * `lib/cluster/request-response-policies/scan-cursor.ts` and + * `reduceRandomKey` in `dispatch.ts` — so they keep their server-reported + * policies). Without an interpretation they are pinned back to master's + * behavior — `default-keyless` routes to a single random node and passes the + * sole reply through. `memory purge`/`usage`, `latency reset` and + * `hotkeys help` are untouched: the per-subcommand deep-merge keeps their + * server-derived policies. */ +const KEYLESS = { request: 'default-keyless', response: 'default-keyless', isKeyless: true } as const; + export const COMMAND_OVERRIDES: Readonly>> = { 'ft.cursor': { request: 'special', @@ -69,5 +85,10 @@ export const COMMAND_OVERRIDES: Readonly del: { request: 'special', response: 'default-keyless', isKeyless: true } } }, - 'std.touch': { tips: ['dont_cache'] } + 'std.touch': { tips: ['dont_cache'] }, + 'std.info': KEYLESS, + 'std.memory': { subcommands: { doctor: KEYLESS, 'malloc-stats': KEYLESS, stats: KEYLESS } }, + 'std.latency': { subcommands: { doctor: KEYLESS, graph: KEYLESS, histogram: KEYLESS, history: KEYLESS, latest: KEYLESS } }, + 'std.function': { subcommands: { stats: KEYLESS } }, + 'std.hotkeys': { subcommands: { get: KEYLESS, reset: KEYLESS, start: KEYLESS, stop: KEYLESS } } }; diff --git a/packages/client/scripts/generate-command-metadata-data.ts b/packages/client/scripts/generate-command-metadata-data.ts index d52c18a3583..8df9057ceaa 100644 --- a/packages/client/scripts/generate-command-metadata-data.ts +++ b/packages/client/scripts/generate-command-metadata-data.ts @@ -53,6 +53,25 @@ function sortModuleMetadataRecords(records: ModuleMetadataRecords): ModuleMetada ); } +// Merges an override onto a generated entry. Top-level keys are shallow-merged +// (override keys win), except `subcommands`, which is deep-merged per +// subcommand so an override touching one subcommand doesn't drop its siblings. +function applyOverride( + policies: CommandMetadata, + override: Partial | undefined +): CommandMetadata { + if (!override) return policies; + const merged = { ...policies, ...override }; + if (override.subcommands && policies.subcommands) { + const subcommands = { ...policies.subcommands }; + for (const [name, sub] of Object.entries(override.subcommands)) { + subcommands[name] = { ...policies.subcommands[name], ...sub }; + } + merged.subcommands = subcommands; + } + return merged; +} + // Applies the HLD curation from command-metadata-overrides.ts. Expects // lowercased records (i.e. run after sortModuleMetadataRecords). function curate(records: ModuleMetadataRecords): ModuleMetadataRecords { @@ -66,9 +85,7 @@ function curate(records: ModuleMetadataRecords): ModuleMetadataRecords { const fullName = `${moduleName}.${commandName}`; if (EXCLUDED_COMMANDS.has(fullName)) continue; - // Shallow-merge: override keys win, unspecified keys keep the server value. - const override = COMMAND_OVERRIDES[fullName]; - curated[moduleName][commandName] = override ? { ...policies, ...override } : policies; + curated[moduleName][commandName] = applyOverride(policies, COMMAND_OVERRIDES[fullName]); } } From 1787c1b6abbecdcb009558cb302b1fd9282256f2 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 10:13:49 +0300 Subject: [PATCH 31/54] fix(client): let IS_READ_ONLY/CACHEABLE overrides win over derived metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table hits discarded declared intent: defineScript({ IS_READ_ONLY: false }) write scripts routed to replicas (EVALSHA carries no write flag), sentinel flipped SHUTDOWN/FAILOVER/HELLO to replica routing, and the explicit master pin in sendCommand(key, false, ...) was ignored. Defined Command.IS_READ_ONLY/CACHEABLE (or the raw sendCommand isReadonly argument) always wins; undeclared intent derives from the table: no write flag, no script_runner flag, and keyed. Keyless flagless commands (admin/connection/pub-sub) default to master — the flag space cannot separate ACL GETUSER from ACL SETUSER — so read-ish ones opt back in via restored IS_READ_ONLY: true (66 audit-confirmed files + EVAL_RO/EVALSHA_RO/FCALL_RO + FUNCTION LIST). TOUCH declares CACHEABLE: false directly; the generator override file is now table-shape/policy curation only. Co-Authored-By: Claude Fable 5 --- packages/client/lib/client/index.ts | 5 +- packages/client/lib/cluster/index.ts | 8 +- .../lib/command-metadata/predicates.spec.ts | 95 +++++++++++++++++++ .../client/lib/command-metadata/predicates.ts | 66 ++++++++----- .../resolve-then-fallback.spec.ts | 59 ------------ packages/client/lib/commands/ACL_CAT.ts | 1 + packages/client/lib/commands/ACL_DRYRUN.ts | 1 + packages/client/lib/commands/ACL_GENPASS.ts | 1 + packages/client/lib/commands/ACL_GETUSER.ts | 1 + packages/client/lib/commands/ACL_LIST.ts | 1 + packages/client/lib/commands/ACL_LOG.ts | 1 + packages/client/lib/commands/ACL_USERS.ts | 1 + packages/client/lib/commands/ACL_WHOAMI.ts | 1 + packages/client/lib/commands/ASKING.ts | 1 + packages/client/lib/commands/AUTH.ts | 1 + .../client/lib/commands/CLIENT_CACHING.ts | 1 + .../client/lib/commands/CLIENT_GETNAME.ts | 1 + .../client/lib/commands/CLIENT_GETREDIR.ts | 1 + packages/client/lib/commands/CLIENT_ID.ts | 1 + packages/client/lib/commands/CLIENT_INFO.ts | 1 + packages/client/lib/commands/CLIENT_LIST.ts | 1 + .../client/lib/commands/CLIENT_NO-EVICT.ts | 1 + .../client/lib/commands/CLIENT_NO-TOUCH.ts | 1 + .../client/lib/commands/CLIENT_SETNAME.ts | 1 + .../client/lib/commands/CLIENT_TRACKING.ts | 1 + .../lib/commands/CLIENT_TRACKINGINFO.ts | 1 + .../commands/CLUSTER_COUNT-FAILURE-REPORTS.ts | 1 + .../lib/commands/CLUSTER_COUNTKEYSINSLOT.ts | 1 + .../lib/commands/CLUSTER_GETKEYSINSLOT.ts | 1 + packages/client/lib/commands/CLUSTER_INFO.ts | 1 + .../client/lib/commands/CLUSTER_KEYSLOT.ts | 1 + packages/client/lib/commands/CLUSTER_LINKS.ts | 1 + packages/client/lib/commands/CLUSTER_MYID.ts | 1 + .../client/lib/commands/CLUSTER_MYSHARDID.ts | 1 + packages/client/lib/commands/CLUSTER_NODES.ts | 1 + .../client/lib/commands/CLUSTER_REPLICAS.ts | 1 + packages/client/lib/commands/CLUSTER_SLOTS.ts | 1 + packages/client/lib/commands/COMMAND.ts | 1 + packages/client/lib/commands/COMMAND_COUNT.ts | 1 + .../client/lib/commands/COMMAND_GETKEYS.ts | 1 + .../lib/commands/COMMAND_GETKEYSANDFLAGS.ts | 1 + packages/client/lib/commands/COMMAND_INFO.ts | 1 + packages/client/lib/commands/COMMAND_LIST.ts | 1 + packages/client/lib/commands/CONFIG_GET.ts | 1 + packages/client/lib/commands/ECHO.ts | 1 + packages/client/lib/commands/EVALSHA_RO.ts | 1 + packages/client/lib/commands/EVAL_RO.ts | 1 + packages/client/lib/commands/FCALL_RO.ts | 1 + packages/client/lib/commands/FUNCTION_DUMP.ts | 1 + packages/client/lib/commands/FUNCTION_LIST.ts | 1 + .../lib/commands/FUNCTION_LIST_WITHCODE.ts | 1 + .../client/lib/commands/FUNCTION_STATS.ts | 1 + packages/client/lib/commands/HOTKEYS_GET.ts | 1 + packages/client/lib/commands/INFO.ts | 1 + packages/client/lib/commands/LASTSAVE.ts | 1 + .../client/lib/commands/LATENCY_DOCTOR.ts | 1 + packages/client/lib/commands/LATENCY_GRAPH.ts | 1 + .../client/lib/commands/LATENCY_HISTOGRAM.ts | 1 + .../client/lib/commands/LATENCY_HISTORY.ts | 1 + .../client/lib/commands/LATENCY_LATEST.ts | 1 + packages/client/lib/commands/MEMORY_DOCTOR.ts | 1 + .../lib/commands/MEMORY_MALLOC-STATS.ts | 1 + packages/client/lib/commands/MEMORY_STATS.ts | 1 + packages/client/lib/commands/MODULE_LIST.ts | 1 + packages/client/lib/commands/PING.ts | 1 + packages/client/lib/commands/PUBLISH.ts | 1 + .../client/lib/commands/PUBSUB_CHANNELS.ts | 1 + packages/client/lib/commands/PUBSUB_NUMPAT.ts | 1 + packages/client/lib/commands/PUBSUB_NUMSUB.ts | 1 + .../lib/commands/PUBSUB_SHARDCHANNELS.ts | 1 + .../client/lib/commands/PUBSUB_SHARDNUMSUB.ts | 1 + packages/client/lib/commands/READONLY.ts | 1 + packages/client/lib/commands/READWRITE.ts | 1 + packages/client/lib/commands/ROLE.ts | 1 + packages/client/lib/commands/SCRIPT_DEBUG.ts | 1 + packages/client/lib/commands/SCRIPT_EXISTS.ts | 1 + packages/client/lib/commands/TIME.ts | 1 + packages/client/lib/commands/TOUCH.ts | 1 + packages/client/lib/sentinel/utils.ts | 3 +- .../scripts/command-metadata-overrides.ts | 15 ++- 80 files changed, 223 insertions(+), 101 deletions(-) create mode 100644 packages/client/lib/command-metadata/predicates.spec.ts delete mode 100644 packages/client/lib/command-metadata/resolve-then-fallback.spec.ts diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 6c73b5e6bda..43b1303b86e 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -1232,9 +1232,8 @@ export default class RedisClient< const fn = () => { return this.sendCommand(parser.redisArgs, commandOptions) }; - // Resolve-then-fallback: CSC eligibility derives from the server flags/tips - // (see `isCacheable`); user scripts/functions/unknown modules miss the table - // and fall back to the hardcoded `Command.CACHEABLE`. + // Override-first: a defined `Command.CACHEABLE` wins; otherwise CSC + // eligibility derives from the server flags/tips (see `isCacheable`). const cacheable = isCacheable(defaultCommandMetadata.lookup(parser.commandIdentifier), command.CACHEABLE); if (csc && cacheable && defaultTypeMapping) { diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 39ff972a788..88aeeddaf01 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -535,10 +535,10 @@ export default class RedisCluster< isKeyless: !hasKeys }; - // Resolve-then-fallback: replica-safety derives from the server `write` - // flag (see `isReplicaSafe`). On a table miss the synthesized `policy` has - // no `flags`, so the predicate falls back to the hardcoded `IS_READ_ONLY` - // threaded in as `isReadonly`. + // Override-first: a defined `IS_READ_ONLY` (command definition, script, or + // the raw `sendCommand` caller argument threaded in as `isReadonly`) wins; + // otherwise the table's write/script_runner flags and keyed-ness decide + // (see `isReplicaSafe`). const readonly = isReplicaSafe(policy, isReadonly); const requestPolicy = policy.request diff --git a/packages/client/lib/command-metadata/predicates.spec.ts b/packages/client/lib/command-metadata/predicates.spec.ts new file mode 100644 index 00000000000..b92a133e81d --- /dev/null +++ b/packages/client/lib/command-metadata/predicates.spec.ts @@ -0,0 +1,95 @@ +import { strict as assert } from 'node:assert'; +import { defaultCommandMetadata, isReplicaSafe, isCacheable } from '.'; +import type { CommandIdentifier } from '../client/parser'; + +// Mirrors the readers in cluster/index.ts, sentinel/utils.ts and +// client/index.ts: a defined override (`Command.IS_READ_ONLY` / +// `Command.CACHEABLE`, or the raw `sendCommand` isReadonly argument) always +// wins; only undeclared intent derives from the server flags/tips. +const id = (command: string, subcommand?: string): CommandIdentifier => ({ command, subcommand }); +const replicaSafe = (i: CommandIdentifier, override?: boolean) => + isReplicaSafe(defaultCommandMetadata.lookup(i), override); +const cacheable = (i: CommandIdentifier, override?: boolean) => + isCacheable(defaultCommandMetadata.lookup(i), override); + +describe('predicates (override-first)', () => { + describe('isReplicaSafe', () => { + it('keyed non-write derives replica-safe', () => { + assert.equal(replicaSafe(id('get')), true); + assert.equal(replicaSafe(id('json.get')), true); + }); + + it('write flag derives master-only', () => { + assert.equal(replicaSafe(id('mset')), false); + }); + + it('explicit override wins in both directions (read-your-writes pin, deliberate replica opt-in)', () => { + assert.equal(replicaSafe(id('get'), false), false); + assert.equal(replicaSafe(id('shutdown'), true), true); + }); + + it('script_runner defaults to master; declared intent routes the script', () => { + // The server cannot know whether a script writes, so EVAL/EVALSHA/FCALL + // carry no write flag — routing must come from the declared intent + // (defineScript IS_READ_ONLY, or the hand-set value on the _RO variants). + assert.equal(replicaSafe(id('eval')), false); + assert.equal(replicaSafe(id('evalsha')), false); + assert.equal(replicaSafe(id('fcall')), false); + assert.equal(replicaSafe(id('evalsha'), true), true); + assert.equal(replicaSafe(id('evalsha'), false), false); + assert.equal(replicaSafe(id('eval_ro'), true), true); + }); + + it('keyless flagless (admin/connection) defaults to master', () => { + assert.equal(replicaSafe(id('shutdown')), false); + assert.equal(replicaSafe(id('failover')), false); + assert.equal(replicaSafe(id('hello')), false); + }); + + it('keyless read-ish commands opt back in via their command-definition override', () => { + // PING.ts et al. hand-set IS_READ_ONLY: true (restored per the + // readonly-discrepancies audit); the predicate itself derives false. + assert.equal(replicaSafe(id('ping')), false); + assert.equal(replicaSafe(id('ping'), true), true); + }); + + it('keyless readonly-flagged is still not replica-routed by derivation (fan-out policies route it)', () => { + assert.equal(replicaSafe(id('keys')), false); + }); + + it('unknown command (user script/function): declared intent or master', () => { + assert.equal(defaultCommandMetadata.lookup(id('definitelynotacommand')), undefined); + assert.equal(replicaSafe(id('definitelynotacommand')), false); + assert.equal(replicaSafe(id('definitelynotacommand'), true), true); + assert.equal(replicaSafe(id('definitelynotacommand'), false), false); + }); + }); + + describe('isCacheable', () => { + it('keyed readonly deterministic: cacheable', () => { + assert.equal(cacheable(id('get')), true); + }); + + it('explicit override wins (TOUCH: readonly + keyed but only bumps LRU/LFU, no invalidation)', () => { + assert.equal(cacheable(id('touch'), false), false); + assert.equal(cacheable(id('xpending'), true), true); + }); + + it('nondeterministic_output derives not cacheable (XPENDING)', () => { + assert.equal(cacheable(id('xpending')), false); + }); + + it('keyless readonly: not cacheable (KEYS/RANDOMKEY are key-invalidation-unsafe)', () => { + assert.equal(cacheable(id('keys')), false); + }); + + it('script_runner: not cacheable (EVAL_RO family)', () => { + assert.equal(cacheable(id('eval_ro')), false); + }); + + it('unknown command: declared intent or not cacheable', () => { + assert.equal(cacheable(id('definitelynotacommand')), false); + assert.equal(cacheable(id('definitelynotacommand'), true), true); + }); + }); +}); diff --git a/packages/client/lib/command-metadata/predicates.ts b/packages/client/lib/command-metadata/predicates.ts index 05b0677d2f2..26d15f96777 100644 --- a/packages/client/lib/command-metadata/predicates.ts +++ b/packages/client/lib/command-metadata/predicates.ts @@ -5,39 +5,55 @@ import type { CommandMetadata } from './policies-constants'; * * In node-redis `Command.IS_READ_ONLY` means, for all intents and purposes, * "safe to send to a replica" — it is consumed only by the cluster and sentinel - * routers to choose replica vs master. The server's `readonly` command flag is - * NOT the right signal: its definition is broader (it also drives ACL `@read`, - * key-spec RO/RW, etc.) and is not 1:1 with replica-safety. + * routers to choose replica vs master. * - * The authoritative signal is the `write` command flag. The server itself - * rejects a command on a read-only replica iff the command carries `CMD_WRITE` - * — see `processCommand` in redis/src/server.c: + * Override-first: a defined `IS_READ_ONLY` (or an explicit `isReadonly` + * argument on the raw `sendCommand` path) is deliberate intent and always + * wins — per-command corrections live in the command definitions, not in the + * generated table. Only when no intent is declared does the table decide: * - * int is_write_command = (cmd_flags & CMD_WRITE) || ... - * if (server.masterhost && server.repl_slave_ro && !obey_client && is_write_command) - * rejectCommand(c, shared.roslaveerr); // -READONLY You can't write against a read only replica. + * - `write` flag → never replica-safe. This is the server's own rejection + * signal: a read-only replica rejects a command iff it carries `CMD_WRITE` + * (`processCommand`, redis/src/server.c — "-READONLY You can't write + * against a read only replica."). The `readonly` flag is deliberately NOT + * consulted: its definition is broader (ACL `@read`, key-spec RO/RW, ...) + * and not 1:1 with replica-safety. + * - `script_runner` flag (EVAL/EVALSHA/FCALL family) → not replica-safe by + * default: the server cannot statically know whether a script writes, so + * routing is master unless the script/command declares `IS_READ_ONLY` + * (`defineScript`, or the hand-set value on the `_RO` variants). + * - otherwise, keyed → replica-safe. Every keyed non-write command is a data + * read the replica can serve. + * - keyless → not replica-safe. The flagless-keyless bucket is admin, + * connection-state and pub/sub commands (SHUTDOWN, FAILOVER, HELLO, AUTH, + * MULTI, ...) where "no write flag" does not mean "sensible on a replica" + * — SHUTDOWN is accepted by a replica and shuts it down. Read-ish members + * (PING, INFO, TIME, ...) opt back in via `IS_READ_ONLY: true` overrides + * in their command definitions. * - * So a command is replica-safe iff it does NOT carry the `write` flag. A command - * that carries neither `write` nor `readonly` (PING/INFO/admin/pubsub) is - * replica-safe under this rule. - * - * Resolve-then-fallback: built-ins / known modules hit the generated table - * (`meta.flags` present) → derived value wins. User scripts / functions / - * unknown modules miss the table (`meta`/`meta.flags` absent) → fall back to the - * hand-set `Command.IS_READ_ONLY`. No breaking change. + * Unknown commands (user scripts/functions/unknown modules miss the table) + * with no declared intent default to master — the safe choice. */ export function isReplicaSafe( meta: CommandMetadata | undefined, - fallback: boolean | undefined + override: boolean | undefined ): boolean { - return meta?.flags ? !meta.flags.includes('write') : !!fallback; + if (override !== undefined) return override; + if (!meta?.flags) return false; + if (meta.flags.includes('write')) return false; + if (meta.flags.includes('script_runner')) return false; + return !meta.isKeyless; } /** * Whether a command's reply is eligible for client-side caching (CSC). * - * Implements the cross-client CSC "Command Eligibility" algorithm: a command is - * cacheable if all of the following hold — + * Override-first, like `isReplicaSafe`: a defined `Command.CACHEABLE` always + * wins (e.g. `CACHEABLE: false` on commands whose server metadata makes them + * look cacheable when they are not — TOUCH only bumps LRU/LFU and generates no + * invalidation). With no declared intent, the cross-client CSC "Command + * Eligibility" algorithm decides: a command is cacheable if all of the + * following hold — * - no `dont_cache` tip (explicit negative override), * - has the `readonly` flag, * - takes at least one key-name argument (`!isKeyless`; CSC invalidation is @@ -46,14 +62,14 @@ export function isReplicaSafe( * is fine — HGETALL/SMEMBERS stay cacheable), * - no `script` / `script_runner` flag (EVAL_RO/EVALSHA_RO/FCALL_RO). * - * Resolve-then-fallback: table miss (no `meta.flags`) falls back to the hand-set - * `Command.CACHEABLE`. + * Unknown commands with no declared intent are not cacheable. */ export function isCacheable( meta: CommandMetadata | undefined, - fallback: boolean | undefined + override: boolean | undefined ): boolean { - if (!meta?.flags) return !!fallback; + if (override !== undefined) return override; + if (!meta?.flags) return false; const tips = meta.tips ?? []; return !tips.includes('dont_cache') && meta.flags.includes('readonly') diff --git a/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts b/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts deleted file mode 100644 index a50bc7ce6e5..00000000000 --- a/packages/client/lib/command-metadata/resolve-then-fallback.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { defaultCommandMetadata, isReplicaSafe, isCacheable } from '.'; -import type { CommandIdentifier } from '../client/parser'; - -// Mirrors the readers in cluster/index.ts, sentinel/utils.ts and -// client/index.ts: the predicates derive from the server flags/tips for known -// commands; anything absent from the table (user scripts/functions/unknown -// modules) falls back to the command's hardcoded field. -const id = (command: string, subcommand?: string): CommandIdentifier => ({ command, subcommand }); -const replicaSafe = (i: CommandIdentifier, hardcoded?: boolean) => - isReplicaSafe(defaultCommandMetadata.lookup(i), hardcoded); -const cacheable = (i: CommandIdentifier, hardcoded?: boolean) => - isCacheable(defaultCommandMetadata.lookup(i), hardcoded); - -describe('resolve-then-fallback', () => { - describe('isReplicaSafe (derived from the write flag)', () => { - it('keyed read (no write flag): replica-safe, derived true', () => { - assert.equal(replicaSafe(id('get'), false), true); - }); - - it('keyed write (write flag): not replica-safe, derived false wins over a wrong hardcoded true', () => { - assert.equal(replicaSafe(id('mset'), true), false); - }); - - it('non-data command (neither write nor readonly): replica-safe regardless of hardcoded', () => { - // PING carries no write flag, so it is replica-safe under the !write rule. - assert.equal(replicaSafe(id('ping'), false), true); - }); - - it('unknown command (user script/function): miss -> falls back to hardcoded', () => { - assert.equal(defaultCommandMetadata.lookup(id('definitelynotacommand')), undefined); - assert.equal(replicaSafe(id('definitelynotacommand'), true), true); - assert.equal(replicaSafe(id('definitelynotacommand'), false), false); - }); - }); - - describe('isCacheable (full CSC eligibility)', () => { - it('keyed readonly deterministic: cacheable', () => { - assert.equal(cacheable(id('get')), true); - }); - - it('nondeterministic_output: not cacheable, derived false wins over hardcoded true (XPENDING)', () => { - assert.equal(cacheable(id('xpending'), true), false); - }); - - it('keyless readonly: not cacheable (KEYS/RANDOMKEY are key-invalidation-unsafe)', () => { - assert.equal(cacheable(id('keys')), false); - }); - - it('dont_cache override: not cacheable (read-only script commands, TOUCH)', () => { - assert.equal(cacheable(id('eval_ro'), true), false); - assert.equal(cacheable(id('touch'), true), false); - }); - - it('unknown command: miss -> falls back to hardcoded', () => { - assert.equal(cacheable(id('definitelynotacommand'), true), true); - }); - }); -}); diff --git a/packages/client/lib/commands/ACL_CAT.ts b/packages/client/lib/commands/ACL_CAT.ts index c2f04985abd..50cbbb37995 100644 --- a/packages/client/lib/commands/ACL_CAT.ts +++ b/packages/client/lib/commands/ACL_CAT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, categoryName?: RedisArgument) { parser.push('ACL', 'CAT'); if (categoryName) { diff --git a/packages/client/lib/commands/ACL_DRYRUN.ts b/packages/client/lib/commands/ACL_DRYRUN.ts index 4fab5bdc652..542aa9e196b 100644 --- a/packages/client/lib/commands/ACL_DRYRUN.ts +++ b/packages/client/lib/commands/ACL_DRYRUN.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisArgument, command: Array) { parser.push('ACL', 'DRYRUN', username, ...command); }, diff --git a/packages/client/lib/commands/ACL_GENPASS.ts b/packages/client/lib/commands/ACL_GENPASS.ts index f9363691259..77aa54a7abf 100644 --- a/packages/client/lib/commands/ACL_GENPASS.ts +++ b/packages/client/lib/commands/ACL_GENPASS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, bits?: number) { parser.push('ACL', 'GENPASS'); if (bits) { diff --git a/packages/client/lib/commands/ACL_GETUSER.ts b/packages/client/lib/commands/ACL_GETUSER.ts index 7572190cdb3..d0c2572ee98 100644 --- a/packages/client/lib/commands/ACL_GETUSER.ts +++ b/packages/client/lib/commands/ACL_GETUSER.ts @@ -18,6 +18,7 @@ type AclUser = TuplesToMapReply<[ ]>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, username: RedisArgument) { parser.push('ACL', 'GETUSER', username); }, diff --git a/packages/client/lib/commands/ACL_LIST.ts b/packages/client/lib/commands/ACL_LIST.ts index 8ee1ed51335..da9c76c166f 100644 --- a/packages/client/lib/commands/ACL_LIST.ts +++ b/packages/client/lib/commands/ACL_LIST.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'LIST'); }, diff --git a/packages/client/lib/commands/ACL_LOG.ts b/packages/client/lib/commands/ACL_LOG.ts index 224a7e73281..bb60f712640 100644 --- a/packages/client/lib/commands/ACL_LOG.ts +++ b/packages/client/lib/commands/ACL_LOG.ts @@ -19,6 +19,7 @@ export type AclLogReply = ArrayReply>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, count?: number) { parser.push('ACL', 'LOG'); if (count != undefined) { diff --git a/packages/client/lib/commands/ACL_USERS.ts b/packages/client/lib/commands/ACL_USERS.ts index 04e45c84cdc..86ece6e9eaa 100644 --- a/packages/client/lib/commands/ACL_USERS.ts +++ b/packages/client/lib/commands/ACL_USERS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'USERS'); }, diff --git a/packages/client/lib/commands/ACL_WHOAMI.ts b/packages/client/lib/commands/ACL_WHOAMI.ts index 4dc5b5a1240..057cabbbc1f 100644 --- a/packages/client/lib/commands/ACL_WHOAMI.ts +++ b/packages/client/lib/commands/ACL_WHOAMI.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ACL', 'WHOAMI'); }, diff --git a/packages/client/lib/commands/ASKING.ts b/packages/client/lib/commands/ASKING.ts index 01080e9c45d..13efce9dd8f 100644 --- a/packages/client/lib/commands/ASKING.ts +++ b/packages/client/lib/commands/ASKING.ts @@ -4,6 +4,7 @@ import { SimpleStringReply, Command } from '../RESP/types'; export const ASKING_CMD = 'ASKING'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push(ASKING_CMD); }, diff --git a/packages/client/lib/commands/AUTH.ts b/packages/client/lib/commands/AUTH.ts index e3cda7bb274..3f2ee99af08 100644 --- a/packages/client/lib/commands/AUTH.ts +++ b/packages/client/lib/commands/AUTH.ts @@ -7,6 +7,7 @@ export interface AuthOptions { } export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, { username, password }: AuthOptions) { parser.push('AUTH'); if (username !== undefined) { diff --git a/packages/client/lib/commands/CLIENT_CACHING.ts b/packages/client/lib/commands/CLIENT_CACHING.ts index f52c2f1e2c3..0a9592e8bf9 100644 --- a/packages/client/lib/commands/CLIENT_CACHING.ts +++ b/packages/client/lib/commands/CLIENT_CACHING.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_GETNAME.ts b/packages/client/lib/commands/CLIENT_GETNAME.ts index 49b15109538..208ee763839 100644 --- a/packages/client/lib/commands/CLIENT_GETNAME.ts +++ b/packages/client/lib/commands/CLIENT_GETNAME.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, NullReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'GETNAME'); }, diff --git a/packages/client/lib/commands/CLIENT_GETREDIR.ts b/packages/client/lib/commands/CLIENT_GETREDIR.ts index c4a4272c010..0d2d29bb5e4 100644 --- a/packages/client/lib/commands/CLIENT_GETREDIR.ts +++ b/packages/client/lib/commands/CLIENT_GETREDIR.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'GETREDIR'); }, diff --git a/packages/client/lib/commands/CLIENT_ID.ts b/packages/client/lib/commands/CLIENT_ID.ts index 8a03b528970..950d05ad0a8 100644 --- a/packages/client/lib/commands/CLIENT_ID.ts +++ b/packages/client/lib/commands/CLIENT_ID.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'ID'); }, diff --git a/packages/client/lib/commands/CLIENT_INFO.ts b/packages/client/lib/commands/CLIENT_INFO.ts index 9f9002aff13..5e0dbd25008 100644 --- a/packages/client/lib/commands/CLIENT_INFO.ts +++ b/packages/client/lib/commands/CLIENT_INFO.ts @@ -65,6 +65,7 @@ export interface ClientInfoReply { const CLIENT_INFO_REGEX = /([^\s=]+)=([^\s]*)/g; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'INFO'); }, diff --git a/packages/client/lib/commands/CLIENT_LIST.ts b/packages/client/lib/commands/CLIENT_LIST.ts index 46ad746de48..2b774109ebf 100644 --- a/packages/client/lib/commands/CLIENT_LIST.ts +++ b/packages/client/lib/commands/CLIENT_LIST.ts @@ -15,6 +15,7 @@ export interface ListFilterId { export type ListFilter = ListFilterType | ListFilterId; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, filter?: ListFilter) { parser.push('CLIENT', 'LIST'); if (filter) { diff --git a/packages/client/lib/commands/CLIENT_NO-EVICT.ts b/packages/client/lib/commands/CLIENT_NO-EVICT.ts index 8248146fb61..55d298e5d53 100644 --- a/packages/client/lib/commands/CLIENT_NO-EVICT.ts +++ b/packages/client/lib/commands/CLIENT_NO-EVICT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_NO-TOUCH.ts b/packages/client/lib/commands/CLIENT_NO-TOUCH.ts index 4c1269a908c..77b053c9a1b 100644 --- a/packages/client/lib/commands/CLIENT_NO-TOUCH.ts +++ b/packages/client/lib/commands/CLIENT_NO-TOUCH.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, value: boolean) { parser.push( 'CLIENT', diff --git a/packages/client/lib/commands/CLIENT_SETNAME.ts b/packages/client/lib/commands/CLIENT_SETNAME.ts index da26b39ce43..bad3c02e490 100644 --- a/packages/client/lib/commands/CLIENT_SETNAME.ts +++ b/packages/client/lib/commands/CLIENT_SETNAME.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, name: RedisArgument) { parser.push('CLIENT', 'SETNAME', name); }, diff --git a/packages/client/lib/commands/CLIENT_TRACKING.ts b/packages/client/lib/commands/CLIENT_TRACKING.ts index 30e4debbf2e..89798fe8d6e 100644 --- a/packages/client/lib/commands/CLIENT_TRACKING.ts +++ b/packages/client/lib/commands/CLIENT_TRACKING.ts @@ -27,6 +27,7 @@ export type ClientTrackingOptions = CommonOptions & ( ); export default { + IS_READ_ONLY: true, parseCommand( parser: CommandParser, mode: M, diff --git a/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts b/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts index f19c960d26c..4ec42be4b49 100644 --- a/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts +++ b/packages/client/lib/commands/CLIENT_TRACKINGINFO.ts @@ -8,6 +8,7 @@ type TrackingInfo = TuplesToMapReply<[ ]>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLIENT', 'TRACKINGINFO'); }, diff --git a/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts b/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts index d6885bee6c0..2e069c07939 100644 --- a/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts +++ b/packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'COUNT-FAILURE-REPORTS', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts b/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts index 3a06c0cafe1..38b39c8922a 100644 --- a/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, slot: number) { parser.push('CLUSTER', 'COUNTKEYSINSLOT', slot.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts b/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts index 271f17d838f..16982e6ea4e 100644 --- a/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, slot: number, count: number) { parser.push('CLUSTER', 'GETKEYSINSLOT', slot.toString(), count.toString()); }, diff --git a/packages/client/lib/commands/CLUSTER_INFO.ts b/packages/client/lib/commands/CLUSTER_INFO.ts index f3eac2bd06b..a0d0576563e 100644 --- a/packages/client/lib/commands/CLUSTER_INFO.ts +++ b/packages/client/lib/commands/CLUSTER_INFO.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { VerbatimStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'INFO'); }, diff --git a/packages/client/lib/commands/CLUSTER_KEYSLOT.ts b/packages/client/lib/commands/CLUSTER_KEYSLOT.ts index d7d64ddb49c..774a031a280 100644 --- a/packages/client/lib/commands/CLUSTER_KEYSLOT.ts +++ b/packages/client/lib/commands/CLUSTER_KEYSLOT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { Command, NumberReply, RedisArgument } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('CLUSTER', 'KEYSLOT'); // Use pushKey so a configured `keyPrefix` is applied to the reported key: the returned diff --git a/packages/client/lib/commands/CLUSTER_LINKS.ts b/packages/client/lib/commands/CLUSTER_LINKS.ts index 98495a23c52..1e3c0e46546 100644 --- a/packages/client/lib/commands/CLUSTER_LINKS.ts +++ b/packages/client/lib/commands/CLUSTER_LINKS.ts @@ -11,6 +11,7 @@ type ClusterLinksReply = ArrayReply>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'LINKS'); }, diff --git a/packages/client/lib/commands/CLUSTER_MYID.ts b/packages/client/lib/commands/CLUSTER_MYID.ts index 24ae955e0de..e517f1ba3d8 100644 --- a/packages/client/lib/commands/CLUSTER_MYID.ts +++ b/packages/client/lib/commands/CLUSTER_MYID.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'MYID'); }, diff --git a/packages/client/lib/commands/CLUSTER_MYSHARDID.ts b/packages/client/lib/commands/CLUSTER_MYSHARDID.ts index 6539adb86e8..005d3e634d1 100644 --- a/packages/client/lib/commands/CLUSTER_MYSHARDID.ts +++ b/packages/client/lib/commands/CLUSTER_MYSHARDID.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'MYSHARDID'); }, diff --git a/packages/client/lib/commands/CLUSTER_NODES.ts b/packages/client/lib/commands/CLUSTER_NODES.ts index 115e85e329f..9e202233a31 100644 --- a/packages/client/lib/commands/CLUSTER_NODES.ts +++ b/packages/client/lib/commands/CLUSTER_NODES.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { VerbatimStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'NODES'); }, diff --git a/packages/client/lib/commands/CLUSTER_REPLICAS.ts b/packages/client/lib/commands/CLUSTER_REPLICAS.ts index ed395a5f479..f6d86b1175f 100644 --- a/packages/client/lib/commands/CLUSTER_REPLICAS.ts +++ b/packages/client/lib/commands/CLUSTER_REPLICAS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, nodeId: RedisArgument) { parser.push('CLUSTER', 'REPLICAS', nodeId); }, diff --git a/packages/client/lib/commands/CLUSTER_SLOTS.ts b/packages/client/lib/commands/CLUSTER_SLOTS.ts index 9725161e9fc..60d9d2f28a3 100644 --- a/packages/client/lib/commands/CLUSTER_SLOTS.ts +++ b/packages/client/lib/commands/CLUSTER_SLOTS.ts @@ -17,6 +17,7 @@ type ClusterSlotsRawReply = ArrayReply<[ export type ClusterSlotsNode = ReturnType; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('CLUSTER', 'SLOTS'); }, diff --git a/packages/client/lib/commands/COMMAND.ts b/packages/client/lib/commands/COMMAND.ts index 7fb7d84e885..2148b584160 100644 --- a/packages/client/lib/commands/COMMAND.ts +++ b/packages/client/lib/commands/COMMAND.ts @@ -3,6 +3,7 @@ import { ArrayReply, Command, UnwrapReply } from '../RESP/types'; import { CommandRawReply, CommandReply, transformCommandReply } from './generic-transformers'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('COMMAND'); }, diff --git a/packages/client/lib/commands/COMMAND_COUNT.ts b/packages/client/lib/commands/COMMAND_COUNT.ts index 27ab786490f..6c2ff8a59b9 100644 --- a/packages/client/lib/commands/COMMAND_COUNT.ts +++ b/packages/client/lib/commands/COMMAND_COUNT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('COMMAND', 'COUNT'); }, diff --git a/packages/client/lib/commands/COMMAND_GETKEYS.ts b/packages/client/lib/commands/COMMAND_GETKEYS.ts index 2573ed5b7b9..09ac4682489 100644 --- a/packages/client/lib/commands/COMMAND_GETKEYS.ts +++ b/packages/client/lib/commands/COMMAND_GETKEYS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, args: Array) { parser.push('COMMAND', 'GETKEYS'); parser.push(...args); diff --git a/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts b/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts index 3a210b0ac82..92d72f03813 100644 --- a/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts +++ b/packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts @@ -7,6 +7,7 @@ export type CommandGetKeysAndFlagsRawReply = ArrayReply>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, args: Array) { parser.push('COMMAND', 'GETKEYSANDFLAGS'); parser.push(...args); diff --git a/packages/client/lib/commands/COMMAND_INFO.ts b/packages/client/lib/commands/COMMAND_INFO.ts index a4396e43271..ef6dd0b7f94 100644 --- a/packages/client/lib/commands/COMMAND_INFO.ts +++ b/packages/client/lib/commands/COMMAND_INFO.ts @@ -3,6 +3,7 @@ import { ArrayReply, Command, UnwrapReply } from '../RESP/types'; import { CommandRawReply, CommandReply, transformCommandReply } from './generic-transformers'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, commands: Array) { parser.push('COMMAND', 'INFO', ...commands); }, diff --git a/packages/client/lib/commands/COMMAND_LIST.ts b/packages/client/lib/commands/COMMAND_LIST.ts index 4e1770b57e5..a283fde4a58 100644 --- a/packages/client/lib/commands/COMMAND_LIST.ts +++ b/packages/client/lib/commands/COMMAND_LIST.ts @@ -17,6 +17,7 @@ export interface CommandListOptions { } export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, options?: CommandListOptions) { parser.push('COMMAND', 'LIST'); diff --git a/packages/client/lib/commands/CONFIG_GET.ts b/packages/client/lib/commands/CONFIG_GET.ts index 47dff705b30..c8ba899743b 100644 --- a/packages/client/lib/commands/CONFIG_GET.ts +++ b/packages/client/lib/commands/CONFIG_GET.ts @@ -3,6 +3,7 @@ import { MapReply, BlobStringReply, Command } from '../RESP/types'; import { RedisVariadicArgument, transformTuplesReply } from './generic-transformers'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, parameters: RedisVariadicArgument) { parser.push('CONFIG', 'GET'); parser.pushVariadic(parameters); diff --git a/packages/client/lib/commands/ECHO.ts b/packages/client/lib/commands/ECHO.ts index dea50c947ea..bf8847d5cc9 100644 --- a/packages/client/lib/commands/ECHO.ts +++ b/packages/client/lib/commands/ECHO.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, message: RedisArgument) { parser.push('ECHO', message); }, diff --git a/packages/client/lib/commands/EVALSHA_RO.ts b/packages/client/lib/commands/EVALSHA_RO.ts index 0533423f91f..24fadb3f486 100644 --- a/packages/client/lib/commands/EVALSHA_RO.ts +++ b/packages/client/lib/commands/EVALSHA_RO.ts @@ -2,6 +2,7 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { + IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('EVALSHA_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/EVAL_RO.ts b/packages/client/lib/commands/EVAL_RO.ts index b4a50f3c9d6..2438fd9d1dd 100644 --- a/packages/client/lib/commands/EVAL_RO.ts +++ b/packages/client/lib/commands/EVAL_RO.ts @@ -2,6 +2,7 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { + IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('EVAL_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/FCALL_RO.ts b/packages/client/lib/commands/FCALL_RO.ts index cafda712d1c..28da0ebd869 100644 --- a/packages/client/lib/commands/FCALL_RO.ts +++ b/packages/client/lib/commands/FCALL_RO.ts @@ -2,6 +2,7 @@ import { Command } from '../RESP/types'; import EVAL, { parseEvalArguments } from './EVAL'; export default { + IS_READ_ONLY: true, parseCommand(...args: Parameters) { args[0].push('FCALL_RO'); parseEvalArguments(...args); diff --git a/packages/client/lib/commands/FUNCTION_DUMP.ts b/packages/client/lib/commands/FUNCTION_DUMP.ts index 7812e44fe48..030c771a16a 100644 --- a/packages/client/lib/commands/FUNCTION_DUMP.ts +++ b/packages/client/lib/commands/FUNCTION_DUMP.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('FUNCTION', 'DUMP') }, diff --git a/packages/client/lib/commands/FUNCTION_LIST.ts b/packages/client/lib/commands/FUNCTION_LIST.ts index 5516ed62dfa..ae8d8c5050d 100644 --- a/packages/client/lib/commands/FUNCTION_LIST.ts +++ b/packages/client/lib/commands/FUNCTION_LIST.ts @@ -18,6 +18,7 @@ export type FunctionListReplyItem = [ export type FunctionListReply = ArrayReply>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, options?: FunctionListOptions) { parser.push('FUNCTION', 'LIST'); diff --git a/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts b/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts index e7104990819..03a0d42e64a 100644 --- a/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts +++ b/packages/client/lib/commands/FUNCTION_LIST_WITHCODE.ts @@ -7,6 +7,7 @@ export type FunctionListWithCodeReply = ArrayReply>; export default { + IS_READ_ONLY: true, parseCommand(...args: Parameters) { FUNCTION_LIST.parseCommand(...args); args[0].push('WITHCODE'); diff --git a/packages/client/lib/commands/FUNCTION_STATS.ts b/packages/client/lib/commands/FUNCTION_STATS.ts index cca3bb48211..cf89a3d9bf6 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.ts @@ -21,6 +21,7 @@ type FunctionStatsReply = TuplesToMapReply<[ ]>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('FUNCTION', 'STATS'); }, diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index bdb18d4a995..93de87fc7f1 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -175,6 +175,7 @@ function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null * server-side payload is treated as a fixed schema, not a generic map. */ export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('HOTKEYS', 'GET'); }, diff --git a/packages/client/lib/commands/INFO.ts b/packages/client/lib/commands/INFO.ts index e80fc723cd4..075af99d2fd 100644 --- a/packages/client/lib/commands/INFO.ts +++ b/packages/client/lib/commands/INFO.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, VerbatimStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, section?: RedisArgument) { parser.push('INFO'); diff --git a/packages/client/lib/commands/LASTSAVE.ts b/packages/client/lib/commands/LASTSAVE.ts index f2f87970633..297a13a77f2 100644 --- a/packages/client/lib/commands/LASTSAVE.ts +++ b/packages/client/lib/commands/LASTSAVE.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LASTSAVE'); }, diff --git a/packages/client/lib/commands/LATENCY_DOCTOR.ts b/packages/client/lib/commands/LATENCY_DOCTOR.ts index 9c77a88a8b1..b4dc8af65a5 100644 --- a/packages/client/lib/commands/LATENCY_DOCTOR.ts +++ b/packages/client/lib/commands/LATENCY_DOCTOR.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LATENCY', 'DOCTOR'); }, diff --git a/packages/client/lib/commands/LATENCY_GRAPH.ts b/packages/client/lib/commands/LATENCY_GRAPH.ts index 8f022cbd8f2..3e8b7a9418e 100644 --- a/packages/client/lib/commands/LATENCY_GRAPH.ts +++ b/packages/client/lib/commands/LATENCY_GRAPH.ts @@ -23,6 +23,7 @@ export const LATENCY_EVENTS = { export type LatencyEvent = typeof LATENCY_EVENTS[keyof typeof LATENCY_EVENTS]; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, event: LatencyEvent) { parser.push('LATENCY', 'GRAPH', event); }, diff --git a/packages/client/lib/commands/LATENCY_HISTOGRAM.ts b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts index 566f06759d5..49c8f5cd1d5 100644 --- a/packages/client/lib/commands/LATENCY_HISTOGRAM.ts +++ b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts @@ -12,6 +12,7 @@ type Histogram = Record n; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, ...commands: string[]) { const args = ['LATENCY', 'HISTOGRAM']; if (commands.length !== 0) { diff --git a/packages/client/lib/commands/LATENCY_HISTORY.ts b/packages/client/lib/commands/LATENCY_HISTORY.ts index 655f8fa8d80..3bdfcdeac12 100644 --- a/packages/client/lib/commands/LATENCY_HISTORY.ts +++ b/packages/client/lib/commands/LATENCY_HISTORY.ts @@ -21,6 +21,7 @@ export type LatencyEventType = ( ); export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, event: LatencyEventType) { parser.push('LATENCY', 'HISTORY', event); }, diff --git a/packages/client/lib/commands/LATENCY_LATEST.ts b/packages/client/lib/commands/LATENCY_LATEST.ts index b463431375d..fe7fffeadfc 100644 --- a/packages/client/lib/commands/LATENCY_LATEST.ts +++ b/packages/client/lib/commands/LATENCY_LATEST.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { ArrayReply, BlobStringReply, NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('LATENCY', 'LATEST'); }, diff --git a/packages/client/lib/commands/MEMORY_DOCTOR.ts b/packages/client/lib/commands/MEMORY_DOCTOR.ts index 9487333330f..f67a5607d9d 100644 --- a/packages/client/lib/commands/MEMORY_DOCTOR.ts +++ b/packages/client/lib/commands/MEMORY_DOCTOR.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'DOCTOR'); }, diff --git a/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts b/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts index 4d65b8eb860..a46bbcc28f9 100644 --- a/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts +++ b/packages/client/lib/commands/MEMORY_MALLOC-STATS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'MALLOC-STATS'); }, diff --git a/packages/client/lib/commands/MEMORY_STATS.ts b/packages/client/lib/commands/MEMORY_STATS.ts index 6e7cd3186ad..c397d3ac9d0 100644 --- a/packages/client/lib/commands/MEMORY_STATS.ts +++ b/packages/client/lib/commands/MEMORY_STATS.ts @@ -36,6 +36,7 @@ export type MemoryStatsReply = TuplesToMapReply<[ ]>; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MEMORY', 'STATS'); }, diff --git a/packages/client/lib/commands/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index b153257e110..2804fb0b518 100644 --- a/packages/client/lib/commands/MODULE_LIST.ts +++ b/packages/client/lib/commands/MODULE_LIST.ts @@ -46,6 +46,7 @@ function transformModuleListReply(reply: Array) { } export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('MODULE', 'LIST'); }, diff --git a/packages/client/lib/commands/PING.ts b/packages/client/lib/commands/PING.ts index d3a2d723d7e..b9ef51407d3 100644 --- a/packages/client/lib/commands/PING.ts +++ b/packages/client/lib/commands/PING.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, SimpleStringReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, message?: RedisArgument) { parser.push('PING'); if (message) { diff --git a/packages/client/lib/commands/PUBLISH.ts b/packages/client/lib/commands/PUBLISH.ts index cfce0c0cacc..bb5b0a49198 100644 --- a/packages/client/lib/commands/PUBLISH.ts +++ b/packages/client/lib/commands/PUBLISH.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, channel: RedisArgument, message: RedisArgument) { parser.push('PUBLISH', channel, message); }, diff --git a/packages/client/lib/commands/PUBSUB_CHANNELS.ts b/packages/client/lib/commands/PUBSUB_CHANNELS.ts index efea1f2928d..94013f2b452 100644 --- a/packages/client/lib/commands/PUBSUB_CHANNELS.ts +++ b/packages/client/lib/commands/PUBSUB_CHANNELS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, pattern?: RedisArgument) { parser.push('PUBSUB', 'CHANNELS'); diff --git a/packages/client/lib/commands/PUBSUB_NUMPAT.ts b/packages/client/lib/commands/PUBSUB_NUMPAT.ts index 6e7561b3f8a..bec270b2cc1 100644 --- a/packages/client/lib/commands/PUBSUB_NUMPAT.ts +++ b/packages/client/lib/commands/PUBSUB_NUMPAT.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('PUBSUB', 'NUMPAT'); }, diff --git a/packages/client/lib/commands/PUBSUB_NUMSUB.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.ts index 7dfa8222f1d..49717ff3253 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.ts @@ -3,6 +3,7 @@ import { ArrayReply, BlobStringReply, NumberReply, UnwrapReply, Command } from ' import { RedisVariadicArgument } from './generic-transformers'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, channels?: RedisVariadicArgument) { parser.push('PUBSUB', 'NUMSUB'); diff --git a/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts b/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts index 6ddcbfdfa8a..f32bce69c81 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, pattern?: RedisArgument) { parser.push('PUBSUB', 'SHARDCHANNELS'); diff --git a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts index b76bb057f0d..f05822787c0 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts @@ -3,6 +3,7 @@ import { ArrayReply, BlobStringReply, NumberReply, UnwrapReply, Command } from ' import { RedisVariadicArgument } from './generic-transformers'; export default { + IS_READ_ONLY: true, /** * Constructs the PUBSUB SHARDNUMSUB command * diff --git a/packages/client/lib/commands/READONLY.ts b/packages/client/lib/commands/READONLY.ts index 16d1d2f3624..9b7016d2d05 100644 --- a/packages/client/lib/commands/READONLY.ts +++ b/packages/client/lib/commands/READONLY.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('READONLY'); }, diff --git a/packages/client/lib/commands/READWRITE.ts b/packages/client/lib/commands/READWRITE.ts index 8616ef44a82..054b9a52c45 100644 --- a/packages/client/lib/commands/READWRITE.ts +++ b/packages/client/lib/commands/READWRITE.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('READWRITE'); }, diff --git a/packages/client/lib/commands/ROLE.ts b/packages/client/lib/commands/ROLE.ts index 4e8617f4be9..8635680f0b0 100644 --- a/packages/client/lib/commands/ROLE.ts +++ b/packages/client/lib/commands/ROLE.ts @@ -35,6 +35,7 @@ type SentinelRole = [ type Role = TuplesReply; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('ROLE'); }, diff --git a/packages/client/lib/commands/SCRIPT_DEBUG.ts b/packages/client/lib/commands/SCRIPT_DEBUG.ts index 75e75ed0732..ac7be498124 100644 --- a/packages/client/lib/commands/SCRIPT_DEBUG.ts +++ b/packages/client/lib/commands/SCRIPT_DEBUG.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { SimpleStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, mode: 'YES' | 'SYNC' | 'NO') { parser.push('SCRIPT', 'DEBUG', mode); }, diff --git a/packages/client/lib/commands/SCRIPT_EXISTS.ts b/packages/client/lib/commands/SCRIPT_EXISTS.ts index c6b6de083e5..2e414e78741 100644 --- a/packages/client/lib/commands/SCRIPT_EXISTS.ts +++ b/packages/client/lib/commands/SCRIPT_EXISTS.ts @@ -3,6 +3,7 @@ import { ArrayReply, NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser, sha1: RedisVariadicArgument) { parser.push('SCRIPT', 'EXISTS'); parser.pushVariadic(sha1); diff --git a/packages/client/lib/commands/TIME.ts b/packages/client/lib/commands/TIME.ts index 9f04abc69db..2dcc8adadd7 100644 --- a/packages/client/lib/commands/TIME.ts +++ b/packages/client/lib/commands/TIME.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../client/parser'; import { BlobStringReply, Command } from '../RESP/types'; export default { + IS_READ_ONLY: true, parseCommand(parser: CommandParser) { parser.push('TIME'); }, diff --git a/packages/client/lib/commands/TOUCH.ts b/packages/client/lib/commands/TOUCH.ts index 908576f95b5..10f27e33ab9 100644 --- a/packages/client/lib/commands/TOUCH.ts +++ b/packages/client/lib/commands/TOUCH.ts @@ -3,6 +3,7 @@ import { NumberReply, Command } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; export default { + CACHEABLE: false, parseCommand(parser: CommandParser, key: RedisVariadicArgument) { parser.push('TOUCH'); parser.pushKeys(key); diff --git a/packages/client/lib/sentinel/utils.ts b/packages/client/lib/sentinel/utils.ts index a378ce547d5..b22195d7e86 100644 --- a/packages/client/lib/sentinel/utils.ts +++ b/packages/client/lib/sentinel/utils.ts @@ -67,7 +67,8 @@ export function clientSocketToNode(socket: RedisSocketOptions): RedisNode { export function createCommand(command: Command, resp: RespVersions) { const transformReply = getTransformReply(command, resp); // Resolved once from the wire identifier (known only after the first parse) - // and reused — the command function is a shared prototype method. + // and reused — the command function is a shared prototype method. A defined + // `IS_READ_ONLY` wins over the table (see `isReplicaSafe`). let replicaSafe: boolean | undefined; return async function (this: T, ...args: Array) { diff --git a/packages/client/scripts/command-metadata-overrides.ts b/packages/client/scripts/command-metadata-overrides.ts index 37a73e74f8f..316b43e651f 100644 --- a/packages/client/scripts/command-metadata-overrides.ts +++ b/packages/client/scripts/command-metadata-overrides.ts @@ -7,6 +7,12 @@ * commands that the HLD deliberately omits from client routing. They are * excluded here so the static phase refuses to resolve them (they fall through * to the fallback resolver instead). + * + * Scope: this file curates TABLE SHAPE only — which entries exist and their + * routing policies (request/response/isKeyless). Per-command value intent + * (`IS_READ_ONLY`, `CACHEABLE`) does NOT belong here: it lives in the command + * definitions and wins over the table via the override-first predicates + * (`isReplicaSafe` / `isCacheable` in `lib/command-metadata/predicates.ts`). */ import type { CommandMetadata } from '../lib/command-metadata/policies-constants'; @@ -53,14 +59,6 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ * static table is deterministic regardless of what a given server reports for * the container command's subcommands. * - * `dont_cache` override: CSC eligibility (`isCacheable`) excludes commands - * tipped `dont_cache`. Redis 8.10 tags the read-only script commands with the - * `script_runner` flag (handled directly by the predicate) and TS.READ with a - * native `dont_cache` tip, but TOUCH is still not tagged — its raw metadata - * (readonly + keyed) makes it look cacheable even though it only bumps LRU/LFU - * and generates no invalidation. Inject the negative tip until the server tags - * it; remove once the server metadata is fixed. - * * `KEYLESS` reverts: the server tips these commands `special` (request and/or * response), but there is no meaningful client-side interpretation for them * (the HLD gives a recipe only for FT.CURSOR; SCAN and RANDOMKEY have obvious @@ -85,7 +83,6 @@ export const COMMAND_OVERRIDES: Readonly del: { request: 'special', response: 'default-keyless', isKeyless: true } } }, - 'std.touch': { tips: ['dont_cache'] }, 'std.info': KEYLESS, 'std.memory': { subcommands: { doctor: KEYLESS, 'malloc-stats': KEYLESS, stats: KEYLESS } }, 'std.latency': { subcommands: { doctor: KEYLESS, graph: KEYLESS, histogram: KEYLESS, history: KEYLESS, latest: KEYLESS } }, From 862dd29353eb7287379e4930d858b8c5bb1f8784 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 11:06:42 +0300 Subject: [PATCH 32/54] fix(client): aggregate cluster fan-out replies before type mapping Numeric reducers (agg_sum/min/max/logical_*) validate for numbers, but per-node replies arrive already type-mapped, so NUMBER: String broke DEL, EXISTS, TOUCH, UNLINK (agg_sum even single-slot), DBSIZE, WAIT, and SCRIPT EXISTS with 'All replies must be numbers' errors. Strip the caller's type mapping from per-node executions of numeric-agg plans and re-apply it to the aggregated result (remapAggregateReply: scalar + element-wise arrays), matching standalone reply shapes. Pass-through policies keep the per-node mapping. Aggregation runs in JS number space, so NUMBER: String does not preserve >2^53 precision. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.spec.ts | 12 +++++++ packages/client/lib/cluster/index.ts | 18 ++++++++-- .../dispatch.spec.ts | 24 ++++++++++++- .../request-response-policies/dispatch.ts | 34 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/index.spec.ts b/packages/client/lib/cluster/index.spec.ts index a9d16bd9fbf..d2082a862b2 100644 --- a/packages/client/lib/cluster/index.spec.ts +++ b/packages/client/lib/cluster/index.spec.ts @@ -318,6 +318,18 @@ describe('Cluster', () => { await assert.rejects(cluster.mGet(['a', 'b'])); }, GLOBAL.CLUSTERS.OPEN); + testUtils.testWithCluster('numeric aggregates honor a NUMBER type mapping', async cluster => { + const mapped = cluster.withTypeMapping({ [RESP_TYPES.NUMBER]: String }); + await Promise.all([cluster.set('a', '1'), cluster.set('b', '1')]); + + // multi_shard agg_sum: per-node replies aggregate raw, result is re-mapped. + assert.equal(await mapped.del(['a', 'b']), '2'); + // all_shards agg_sum fan-out. + assert.equal(typeof await mapped.dbSize(), 'string'); + // Unmapped client on the same cluster is untouched. + assert.equal(typeof await cluster.dbSize(), 'number'); + }, GLOBAL.CLUSTERS.OPEN); + testUtils.testWithCluster('cluster-wide SCAN iterates every master', async cluster => { const expected = new Set(); const writes: Array> = []; diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 88aeeddaf01..fb0c7011445 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -17,7 +17,7 @@ import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; -import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; +import { REQUEST_ROUTERS, RESPONSE_REDUCERS, NUMERIC_AGG_POLICIES, remapAggregateReply } from './request-response-policies/dispatch'; import { captureCursorBinding } from './request-response-policies/ft-cursor'; import { finalizeScanCursor } from './request-response-policies/scan-cursor'; @@ -562,11 +562,22 @@ export default class RedisCluster< throw new Error(`Request policy ${requestPolicy} produced no target nodes`); } + // Numeric aggregation must see raw numbers: strip the caller's type + // mapping from the per-node executions (a `NUMBER: String` mapping would + // feed strings into the numeric reducers) and re-apply it to the + // aggregated result below. Pass-through policies keep the mapping — their + // replies reach the caller undisturbed. + const numericAgg = NUMERIC_AGG_POLICIES.has(responsePolicy); + const requestedMapping = options?.typeMapping; + const execOptions = numericAgg && requestedMapping + ? { ...options, typeMapping: undefined } + : options; + const responsePromises = plan.map(entry => { const entryParser = entry.parser ?? parser; // Re-narrow the opaque routed client to this cluster's instantiation. const client = entry.client as RedisClientType | undefined; - return this._execute(entryParser, readonly, options, makeFn(entryParser), client); + return this._execute(entryParser, readonly, execOptions, makeFn(entryParser), client); }); const reducer = RESPONSE_REDUCERS[responsePolicy]; @@ -575,6 +586,9 @@ export default class RedisCluster< } const positionHints = plan.map(entry => entry.groupIndices); let reply = await (reducer(responsePromises, parser, positionHints) as Promise); + if (numericAgg) { + reply = remapAggregateReply(reply, requestedMapping); + } // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the // serving node from the resolved reply. Command-name gated and best-effort diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts index 654f16dd76a..b568a87868b 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts @@ -1,6 +1,8 @@ import { strict as assert } from 'node:assert'; import type { CommandParser } from '../../client/parser'; -import { reduceDefaultKeyed, reduceRandomKey, reduceSpecial } from './dispatch'; +import { reduceDefaultKeyed, reduceRandomKey, reduceSpecial, remapAggregateReply } from './dispatch'; +import { RESP_TYPES } from '../../RESP/decoder'; +import type { TypeMapping } from '../../RESP/types'; // The reducer ignores the parser; a stub keeps the calls readable. const PARSER = {} as CommandParser; @@ -107,3 +109,23 @@ describe('reduceDefaultKeyed', () => { ); }); }); + +describe('remapAggregateReply', () => { + const STRING_MAPPING = { [RESP_TYPES.NUMBER]: String } as TypeMapping; + + it('passes through without a mapping', () => { + assert.equal(remapAggregateReply(3, undefined), 3); + }); + + it('passes through under the identity Number mapping', () => { + assert.equal(remapAggregateReply(3, { [RESP_TYPES.NUMBER]: Number } as TypeMapping), 3); + }); + + it('maps a scalar aggregate (DEL/DBSIZE sum, WAIT min)', () => { + assert.equal(remapAggregateReply(3, STRING_MAPPING), '3'); + }); + + it('maps array aggregates element-wise (SCRIPT EXISTS logical AND)', () => { + assert.deepEqual(remapAggregateReply([1, 0, 1], STRING_MAPPING), ['1', '0', '1']); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index b1f44635a49..ea5ad92ee70 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -3,6 +3,7 @@ import type { RedisClientType } from '../../client'; import type { RedisArgument, RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../../RESP/types'; +import { RESP_TYPES } from '../../RESP/decoder'; import type { KeySpec } from '../../commands/generic-transformers'; import type RedisClusterSlots from '../cluster-slots'; import { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; @@ -275,6 +276,39 @@ export const reduceDefaultKeyed = async ( return result as T; }; +/** + * Response policies whose reducers compute over raw numbers (scalars or + * number arrays). Per-node replies for these plans are decoded *without* the + * caller's type mapping — a `NUMBER: String` mapping would otherwise feed + * strings into the numeric aggregators and throw — and the caller's mapping + * is applied to the aggregated result instead (`remapAggregateReply`). + */ +export const NUMERIC_AGG_POLICIES: ReadonlySet = new Set([ + RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM, + RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN, + RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX, + RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND, + RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR +]); + +/** + * Applies the caller's NUMBER type mapping to a numeric aggregate (scalar, or + * an array for the element-wise reducers like SCRIPT EXISTS), so aggregated + * fan-out replies keep the same shape a standalone client would return. + * Aggregation itself runs in JS number space, so — unlike standalone decode — + * a `NUMBER: String` mapping does not preserve integer precision above 2^53 + * (see cluster-policy-caveats.md). + */ +export function remapAggregateReply(reply: T, typeMapping: TypeMapping | undefined): T { + const map = typeMapping?.[RESP_TYPES.NUMBER]; + if (!map || map === Number) return reply; + // NUMBER maps to NumberConstructor | StringConstructor; both are callable. + const apply = map as (value: number) => unknown; + return (Array.isArray(reply) + ? reply.map(value => apply(value as number)) + : apply(reply as number)) as T; +} + // --- registries --- export const REQUEST_ROUTERS = { From f9f971090b118c4edc9d1d72bca1a41b8f87f200 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 12:15:59 +0300 Subject: [PATCH 33/54] fix(client): route MSETEX single-node instead of multi_shard split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting MSETEX broke its atomic contract: NX/XX is all-or-nothing across ALL keys but each shard evaluated the condition against its own subset (partial writes), and all_succeeded returned responses[0], masking another shard's 0 as success. Curate MSETEX out of multi_shard (std.msetex override, default-keyed) — consistent with the server's own exclusion of MSETNX. Single-slot calls keep full server-side atomicity and an honest reply; cross-slot calls surface the server's CROSSSLOT error. Co-Authored-By: Claude Fable 5 --- .../multi-shard-splitter.ts | 7 +++++-- .../command-metadata/command-metadata-data.ts | 18 ++---------------- packages/client/lib/commands/MSETEX.spec.ts | 14 ++++++++++++++ .../scripts/command-metadata-overrides.ts | 11 +++++++++++ 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts index 0e27d06a564..2f3526bf905 100644 --- a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -30,8 +30,11 @@ export type SubCommand = { * * Throws on anything it cannot split deterministically — a wrong split of a * write command means corrupted data, so refusal beats guessing. All current - * multi_shard commands (DEL, UNLINK, EXISTS, TOUCH, MGET, MSET, MSETEX) - * declare exactly one supported spec. + * multi_shard commands (DEL, UNLINK, EXISTS, TOUCH, MGET, MSET) declare + * exactly one supported spec. MSETEX is curated OUT of multi_shard + * (command-metadata-overrides.ts): its NX/XX condition is all-or-nothing + * across all keys and cannot be evaluated per shard — it routes default-keyed + * like MSETNX, so the keynum branch below currently has no live caller. */ export function splitMultiShardCommand( args: ReadonlyArray, diff --git a/packages/client/lib/command-metadata/command-metadata-data.ts b/packages/client/lib/command-metadata/command-metadata-data.ts index 65241c7f1a4..74f9fadc053 100644 --- a/packages/client/lib/command-metadata/command-metadata-data.ts +++ b/packages/client/lib/command-metadata/command-metadata-data.ts @@ -3582,27 +3582,13 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { ] }, "msetex": { - "request": "multi_shard", - "response": "all_succeeded", + "request": "default-keyed", + "response": "default-keyed", "isKeyless": false, "flags": [ "write", "denyoom", "movablekeys" - ], - "keySpecs": [ - { - "beginSearch": { - "type": "index", - "index": 1 - }, - "findKeys": { - "type": "keynum", - "keyNumIdx": 0, - "firstKey": 1, - "keyStep": 2 - } - } ] }, "msetnx": { diff --git a/packages/client/lib/commands/MSETEX.spec.ts b/packages/client/lib/commands/MSETEX.spec.ts index 20eea82136f..4892b423bc2 100644 --- a/packages/client/lib/commands/MSETEX.spec.ts +++ b/packages/client/lib/commands/MSETEX.spec.ts @@ -271,6 +271,20 @@ describe("MSETEX", () => { } ); + // MSETEX is curated out of multi_shard (NX/XX is all-or-nothing across ALL + // keys and cannot be evaluated per shard) — it routes to the first key's + // node like MSETNX, so the server rejects cross-slot key sets itself. + testUtils.testWithCluster( + "mSetEx cross-slot keys are rejected by the server", + async (cluster) => { + await assert.rejects( + cluster.mSetEx(["a", "value1", "b", "value2"]), + /CROSSSLOT/ + ); + }, + { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] } + ); + testUtils.testAll( "mSetEx with NX", async (client) => { diff --git a/packages/client/scripts/command-metadata-overrides.ts b/packages/client/scripts/command-metadata-overrides.ts index 316b43e651f..4c88272a905 100644 --- a/packages/client/scripts/command-metadata-overrides.ts +++ b/packages/client/scripts/command-metadata-overrides.ts @@ -74,6 +74,17 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ const KEYLESS = { request: 'default-keyless', response: 'default-keyless', isKeyless: true } as const; export const COMMAND_OVERRIDES: Readonly>> = { + // MSETEX is pinned back to master's single-node routing (server tips it + // multi_shard). Splitting breaks its documented atomic contract twice over: + // NX/XX is all-or-nothing across ALL keys, but each shard would evaluate the + // condition against its own subset (partial writes), and the 0/1 per-shard + // replies cannot be aggregated honestly. The server itself excludes the + // conditional sibling MSETNX from multi_shard for the same reason. Pinned + // default-keyed: single-slot calls keep full server-side atomicity, + // cross-slot calls get the server's own CROSSSLOT error (use hash tags). + // keySpecs explicitly unset — only the multi_shard splitter consumes them, + // and the shallow merge would otherwise keep the server-derived specs. + 'std.msetex': { request: 'default-keyed', response: 'default-keyed', keySpecs: undefined }, 'ft.cursor': { request: 'special', response: 'default-keyless', From bb743f2cafc638ac08efd0411f73e236352753d2 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 13:24:04 +0300 Subject: [PATCH 34/54] feat(client): virtualize FT cursor ids with client-minted tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server cursor ids are minted per node, so two shards can mint the same id for one index; the '${index}:${cursorId}' binding key let the second bind overwrite the first and route a READ to the wrong node — worst case silently reading another query's result stream. Mirror the cluster-wide SCAN design: swap the cursor id in FT.AGGREGATE WITHCURSOR / FT.CURSOR READ replies for a token from the client's own sequence (collision-free), bind token -> (address, real id), and rewrite the token back to the real id on the wire. Real ids are kept as strings, so uint64 cursors no longer lose precision above 2^53 client-side. READ rebinds now carry MAXIDLE through instead of downgrading to the 300s default, and MAXIDLE 0 maps to the default TTL instead of expiring instantly. Tokens are per client instance and not portable to other clients or redis-cli mid-stream — same property as SCAN's virtual cursors. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/cluster-slots.ts | 45 +++-- packages/client/lib/cluster/index.ts | 18 +- .../ft-cursor.spec.ts | 191 +++++++++++++----- .../request-response-policies/ft-cursor.ts | 186 +++++++++++------ .../scan-cursor.spec.ts | 2 +- .../request-response-policies/scan-cursor.ts | 6 +- .../lib/commands/CURSOR_READ.cluster.spec.ts | 5 +- 7 files changed, 306 insertions(+), 147 deletions(-) diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 367d17b2ded..f4797cf1696 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -30,6 +30,12 @@ export const RESUBSCRIBE_LISTENERS_EVENT = '__resubscribeListeners' */ export interface CursorBinding { address: string; + /** + * The server's real cursor id, kept as a string: FT cursor ids are uint64 + * and `Number` would lose precision above 2^53. The caller only ever sees + * the client-minted token this binding is keyed by. + */ + cursorId: string; createdAt: number; maxIdleMs?: number; } @@ -156,11 +162,16 @@ export default class RedisClusterSlots< pubSubNode?: PubSubNode; clientSideCache?: PooledClientSideCacheProvider; smigratedSeqIdsSeen = new Set; - /** Per-instance sticky-cursor bindings, keyed `${index}:${cursorId}`. */ + /** + * Per-instance sticky FT cursor bindings, keyed by the client-minted virtual + * token (the value the caller holds in place of the server's cursor id). + * Server cursor ids are minted per node and can collide across shards; + * client tokens come from one sequence and cannot. + */ readonly cursorBindings = new Map(); /** Per-instance cluster-wide SCAN chains, keyed by the virtual cursor token. */ readonly scanCursors = new Map(); - #scanCursorSeq = 0; + #cursorTokenSeq = 0; #topologyRefreshPromise?: Promise; #isOpen = false; @@ -1000,10 +1011,6 @@ export default class RedisClusterSlots< return undefined; } - #cursorKey(index: string, cursorId: number) { - return `${index}:${cursorId}`; - } - /** * Drop bindings idle past their MAXIDLE (or the default TTL). Opportunistic — * runs on each `bindCursor` so there's no timer to manage (see @@ -1018,29 +1025,29 @@ export default class RedisClusterSlots< } } - bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) { + bindCursor(token: string, binding: Omit) { const now = Date.now(); this.#sweepStaleCursors(now); - this.cursorBindings.set(this.#cursorKey(index, cursorId), { address, createdAt: now, maxIdleMs }); + this.cursorBindings.set(token, { ...binding, createdAt: now }); } - lookupCursor(index: string, cursorId: number): CursorBinding | undefined { - return this.cursorBindings.get(this.#cursorKey(index, cursorId)); + lookupCursor(token: string): CursorBinding | undefined { + return this.cursorBindings.get(token); } - evictCursor(index: string, cursorId: number) { - this.cursorBindings.delete(this.#cursorKey(index, cursorId)); + evictCursor(token: string) { + this.cursorBindings.delete(token); } /** - * Mint a fresh virtual SCAN cursor token. Tokens are what cluster-wide SCAN - * hands back to the caller in place of the per-node server cursor: opaque, - * non-"0", never colliding with each other. A plain counter keeps them - * valid-looking cursor strings for callers that treat the cursor as an - * opaque number. + * Mint a fresh virtual cursor token (cluster-wide SCAN chains, sticky FT + * cursors). Tokens are what the client hands back to the caller in place of + * the per-node server cursor: opaque, non-"0", never colliding with each + * other. A plain counter keeps them valid-looking cursor values for callers + * that treat the cursor as an opaque number. */ - mintScanCursorToken(): string { - return String(++this.#scanCursorSeq); + mintCursorToken(): string { + return String(++this.#cursorTokenSeq); } /** diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index fb0c7011445..1285da1d736 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -18,7 +18,7 @@ import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/i import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS, NUMERIC_AGG_POLICIES, remapAggregateReply } from './request-response-policies/dispatch'; -import { captureCursorBinding } from './request-response-policies/ft-cursor'; +import { finalizeFtCursor } from './request-response-policies/ft-cursor'; import { finalizeScanCursor } from './request-response-policies/scan-cursor'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = @@ -591,17 +591,19 @@ export default class RedisCluster< } // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the - // serving node from the resolved reply. Command-name gated and best-effort - // (a bad binding only downgrades to a MISS throw on the next READ/DEL), so - // never let it mask the caller's reply. + // serving node and swap the server cursor id in the reply for a + // client-minted token (server ids are per-node and can collide across + // shards). Command-name gated; best-effort — on failure the caller gets + // the raw server cursor, which MISSes (with a clear error) on the next + // READ/DEL instead of silently routing to the wrong node. try { - captureCursorBinding( - this._slots as unknown as Parameters[0], + reply = finalizeFtCursor( + this._slots as unknown as Parameters[0], parser, plan, reply - ); - } catch { /* binding capture is best-effort */ } + ) as typeof reply; + } catch { /* cursor finalization is best-effort */ } // Cluster-wide SCAN: advance the scan chain and swap the per-node server // cursor for the chain's virtual token. Command-name gated; best-effort — diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts index ce37e5879c6..fe8bf216b47 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts @@ -1,22 +1,24 @@ import { strict as assert } from 'node:assert'; import type { CommandParser } from '../../client/parser'; -import { routeFtCursor, captureCursorBinding, extractCursorId } from './ft-cursor'; +import { routeFtCursor, finalizeFtCursor, extractCursorValue } from './ft-cursor'; /** * Minimal stand-in for the cursor-relevant surface of `RedisClusterSlots`, - * mirroring the real `${index}:${cursorId}` keying and address→client map so - * the router/capture logic is exercised without spinning a cluster. + * mirroring the token-keyed binding map, the token mint and the + * address→client map so the router/finalize logic is exercised without + * spinning a cluster. */ class FakeSlots { - cursorBindings = new Map(); + cursorBindings = new Map(); clientsByAddress = new Map(); + #seq = 0; - #key(index: string, cursorId: number) { return `${index}:${cursorId}`; } - bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) { - this.cursorBindings.set(this.#key(index, cursorId), { address, createdAt: 0, maxIdleMs }); + mintCursorToken() { return String(++this.#seq); } + bindCursor(token: string, binding: { address: string; cursorId: string; maxIdleMs?: number }) { + this.cursorBindings.set(token, { ...binding, createdAt: 0 }); } - lookupCursor(index: string, cursorId: number) { return this.cursorBindings.get(this.#key(index, cursorId)); } - evictCursor(index: string, cursorId: number) { this.cursorBindings.delete(this.#key(index, cursorId)); } + lookupCursor(token: string) { return this.cursorBindings.get(token); } + evictCursor(token: string) { this.cursorBindings.delete(token); } async getMasterByAddress(address: string) { return this.clientsByAddress.get(address); } nodeAddressByClient(client: object) { for (const [address, c] of this.clientsByAddress) if (c === client) return address; @@ -27,50 +29,56 @@ class FakeSlots { const parserOf = (...args: Array) => ({ redisArgs: args, commandIdentifier: { command: args[0], subcommand: args[1] } }) as unknown as CommandParser; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- routers/capture run below the typed surface +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- routers/finalize run below the typed surface const asSlots = (s: FakeSlots) => s as any; -describe('extractCursorId', () => { +describe('extractCursorValue', () => { it('reads the transformed-path `{ cursor }` object (RESP2 + RESP3)', () => { - assert.equal(extractCursorId({ total: 1, results: [], cursor: 42 }), 42); + assert.equal(extractCursorValue({ total: 1, results: [], cursor: 42 }), 42); }); it('reads raw RESP2 `[result, cursor]` at index 1', () => { - assert.equal(extractCursorId([['result'], 7]), 7); + assert.equal(extractCursorValue([['result'], 7]), 7); }); it('reads raw RESP3 map key `cursor`', () => { - assert.equal(extractCursorId(new Map([['results', []], ['cursor', 9]])), 9); + assert.equal(extractCursorValue(new Map([['results', []], ['cursor', 9]])), 9); + }); + + it('preserves a string cursor (NUMBER: String type mapping)', () => { + assert.equal(extractCursorValue({ cursor: '12345678901234567890' }), '12345678901234567890'); }); it('returns undefined when there is no cursor (e.g. FT.CURSOR DEL "OK")', () => { - assert.equal(extractCursorId('OK'), undefined); - assert.equal(extractCursorId(null), undefined); + assert.equal(extractCursorValue('OK'), undefined); + assert.equal(extractCursorValue(null), undefined); }); }); describe('routeFtCursor', () => { - it('pins the bound client on HIT', async () => { + it('pins the bound client and rewrites the token to the real cursor id', async () => { const slots = new FakeSlots(); const client = { id: 'node-a' }; slots.clientsByAddress.set('127.0.0.1:7000', client); - slots.bindCursor('idx', 123, '127.0.0.1:7000'); + slots.bindCursor('1', { address: '127.0.0.1:7000', cursorId: '18446744073709551615' }); - const plan = await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '123'), undefined, undefined); - assert.deepEqual(plan, [{ client }]); + const plan = await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '1'), undefined, undefined); + assert.equal(plan.length, 1); + assert.equal(plan[0].client, client); + assert.deepEqual(plan[0].parser!.redisArgs, ['FT.CURSOR', 'READ', 'idx', '18446744073709551615']); }); - it('throws on MISS (cursor never bound)', async () => { + it('throws on MISS (token never minted here)', async () => { const slots = new FakeSlots(); await assert.rejects( routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '404'), undefined, undefined), - /no known node for cursor 404 on index "idx"/ + /unknown cursor 404 on index "idx"/ ); }); it('throws when the bound node is gone (getMasterByAddress → undefined)', async () => { const slots = new FakeSlots(); - slots.bindCursor('idx', 5, '127.0.0.1:9999'); // address not in clientsByAddress + slots.bindCursor('5', { address: '127.0.0.1:9999', cursorId: '5' }); // address not in clientsByAddress await assert.rejects( routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '5'), undefined, undefined), /left the cluster/ @@ -78,86 +86,159 @@ describe('routeFtCursor', () => { }); }); -describe('captureCursorBinding — FT.AGGREGATE', () => { - it('binds (index, cursor) → serving node address (RESP2 array reply)', () => { +describe('finalizeFtCursor — FT.AGGREGATE', () => { + it('mints a token, binds it to (address, real id) and rewrites the reply (RESP2 array)', () => { const slots = new FakeSlots(); const client = {}; slots.clientsByAddress.set('10.0.0.1:6379', client); - captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], [[], 55]); - assert.deepEqual(slots.lookupCursor('idx', 55), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + const reply = finalizeFtCursor( + asSlots(slots), + parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), + [{ client } as never], + [[], 55] + ); + assert.deepEqual(reply, [[], 1]); // token 1 replaces the server id, numeric type kept + assert.deepEqual(slots.lookupCursor('1'), { + address: '10.0.0.1:6379', cursorId: '55', createdAt: 0, maxIdleMs: undefined + }); }); - it('binds from the transformed `{ cursor }` reply and captures MAXIDLE', () => { + it('rewrites the transformed `{ cursor }` reply and captures MAXIDLE', () => { const slots = new FakeSlots(); const client = {}; slots.clientsByAddress.set('10.0.0.1:6379', client); - captureCursorBinding( + const reply = finalizeFtCursor( asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'MAXIDLE', '5000'), - [{ client } as any], + [{ client } as never], { total: 0, results: [], cursor: 88 } ); - assert.deepEqual(slots.lookupCursor('idx', 88), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: 5000 }); + assert.deepEqual(reply, { total: 0, results: [], cursor: 1 }); + assert.deepEqual(slots.lookupCursor('1'), { + address: '10.0.0.1:6379', cursorId: '88', createdAt: 0, maxIdleMs: 5000 + }); + }); + + it('rewrites a raw RESP3 Map reply without mutating the original', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + const original = new Map([['results', []], ['cursor', 66]]); + + const reply = finalizeFtCursor( + asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as never], original + ) as Map; + assert.equal(reply.get('cursor'), 1); + assert.equal(original.get('cursor'), 66); + }); + + it('treats MAXIDLE 0 as "no idle limit" (server clamps it), not a 0ms TTL', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + finalizeFtCursor( + asSlots(slots), + parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'MAXIDLE', '0'), + [{ client } as never], + { cursor: 88 } + ); + assert.equal(slots.lookupCursor('1')!.maxIdleMs, undefined); }); - it('does not bind when the aggregate exhausts in one batch (cursor 0)', () => { + it('does not bind or rewrite when the aggregate exhausts in one batch (cursor 0)', () => { const slots = new FakeSlots(); const client = {}; slots.clientsByAddress.set('10.0.0.1:6379', client); - captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], { cursor: 0 }); + const reply = finalizeFtCursor( + asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as never], { cursor: 0 } + ); + assert.deepEqual(reply, { cursor: 0 }); assert.equal(slots.cursorBindings.size, 0); }); }); -describe('captureCursorBinding — FT.CURSOR lifecycle', () => { - const seed = () => { +describe('finalizeFtCursor — FT.CURSOR lifecycle', () => { + const seed = (maxIdleMs?: number) => { const slots = new FakeSlots(); const client = {}; slots.clientsByAddress.set('10.0.0.1:6379', client); - slots.bindCursor('idx', 100, '10.0.0.1:6379'); + slots.bindCursor('7', { address: '10.0.0.1:6379', cursorId: '100', maxIdleMs }); return { slots, client }; }; - it('rebinds a continuation cursor (evict old, bind new, same address)', () => { + it('keeps the token stable across a READ and rewrites the reply back to it', () => { const { slots, client } = seed(); - captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 200 }); - assert.equal(slots.lookupCursor('idx', 100), undefined); - assert.deepEqual(slots.lookupCursor('idx', 200), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + const reply = finalizeFtCursor( + asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '7'), [{ client } as never], { cursor: 100 } + ); + assert.deepEqual(reply, { cursor: 7 }); + assert.deepEqual(slots.lookupCursor('7'), { + address: '10.0.0.1:6379', cursorId: '100', createdAt: 0, maxIdleMs: undefined + }); }); - it('evicts on READ → cursor 0 (exhausted)', () => { + it('tracks a changed continuation id under the same token', () => { const { slots, client } = seed(); - captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 0 }); - assert.equal(slots.lookupCursor('idx', 100), undefined); + const reply = finalizeFtCursor( + asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '7'), [{ client } as never], { cursor: 200 } + ); + assert.deepEqual(reply, { cursor: 7 }); + assert.equal(slots.lookupCursor('7')!.cursorId, '200'); }); - it('keeps the binding when the continuation id is unchanged', () => { + it('preserves MAXIDLE across READ rebinds', () => { + const { slots, client } = seed(600_000); + finalizeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '7'), [{ client } as never], { cursor: 200 }); + assert.equal(slots.lookupCursor('7')!.maxIdleMs, 600_000); + }); + + it('evicts on READ → cursor 0 and passes the server 0 through (ends the loop)', () => { const { slots, client } = seed(); - captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 100 }); - assert.deepEqual(slots.lookupCursor('idx', 100), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + const reply = finalizeFtCursor( + asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '7'), [{ client } as never], { cursor: 0 } + ); + assert.deepEqual(reply, { cursor: 0 }); + assert.equal(slots.lookupCursor('7'), undefined); }); it('evicts on DEL regardless of reply, then a follow-up READ MISSes', async () => { const { slots, client } = seed(); - captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '100'), [{ client } as any], 'OK'); - assert.equal(slots.lookupCursor('idx', 100), undefined); - await assert.rejects(routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), undefined, undefined)); + finalizeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '7'), [{ client } as never], 'OK'); + assert.equal(slots.lookupCursor('7'), undefined); + await assert.rejects(routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '7'), undefined, undefined)); }); }); -describe('cursor-id collision across indexes', () => { - it('keys on (index, cursorId) so same id under two indexes routes independently', async () => { +describe('cross-shard cursor-id collision', () => { + it('two shards minting the same real id for one index get distinct tokens and route independently', async () => { const slots = new FakeSlots(); const clientA = { id: 'a' }, clientB = { id: 'b' }; slots.clientsByAddress.set('a:1', clientA); slots.clientsByAddress.set('b:1', clientB); - slots.bindCursor('idxA', 1, 'a:1'); - slots.bindCursor('idxB', 1, 'b:1'); - assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxA', '1'), undefined, undefined), [{ client: clientA }]); - assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxB', '1'), undefined, undefined), [{ client: clientB }]); + // Both aggregates return server cursor 42 for the same index. + const replyA = finalizeFtCursor( + asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client: clientA } as never], { cursor: 42 } + ) as { cursor: number }; + const replyB = finalizeFtCursor( + asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client: clientB } as never], { cursor: 42 } + ) as { cursor: number }; + + assert.notEqual(replyA.cursor, replyB.cursor); + + const planA = await routeFtCursor( + asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', String(replyA.cursor)), undefined, undefined + ); + const planB = await routeFtCursor( + asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', String(replyB.cursor)), undefined, undefined + ); + assert.equal(planA[0].client, clientA); + assert.equal(planB[0].client, clientB); + assert.deepEqual(planA[0].parser!.redisArgs, ['FT.CURSOR', 'READ', 'idx', '42']); + assert.deepEqual(planB[0].parser!.redisArgs, ['FT.CURSOR', 'READ', 'idx', '42']); }); }); diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts index 73614accc83..5ec6bc30b43 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -1,4 +1,4 @@ -import type { CommandParser } from '../../client/parser'; +import { BasicCommandParser, type CommandParser } from '../../client/parser'; import type { RedisArgument } from '../../RESP/types'; import type { RequestRouter, RoutedCommand } from './dispatch'; @@ -13,130 +13,198 @@ export function argToString(arg: RedisArgument): string { } /** - * Pull the continuation cursor id out of an FT.AGGREGATE …WITHCURSOR / + * Pull the continuation cursor out of an FT.AGGREGATE …WITHCURSOR / * FT.CURSOR READ reply, across every reply path: * - transformed command path → `{ total, results, cursor }` (RESP2 + RESP3), * - raw RESP2 `sendCommand` → `[result, cursor]` (cursor at index 1), * - raw RESP3 `sendCommand` → a map with a `cursor` key (Map or object). - * Returns `undefined` when no cursor field is present (e.g. FT.CURSOR DEL). + * The value is returned as decoded (number, or string under a NUMBER type + * mapping) so a reply rewrite can preserve its wire type. Returns `undefined` + * when no cursor field is present (e.g. FT.CURSOR DEL). */ -export function extractCursorId(reply: unknown): number | undefined { +export function extractCursorValue(reply: unknown): number | string | undefined { if (reply == null) return undefined; if (reply instanceof Map) { - return reply.has('cursor') ? toCursorNumber(reply.get('cursor')) : undefined; + return reply.has('cursor') ? asCursorValue(reply.get('cursor')) : undefined; } if (Array.isArray(reply)) { - return toCursorNumber(reply[1]); + return asCursorValue(reply[1]); } if (typeof reply === 'object' && 'cursor' in (reply as Record)) { - return toCursorNumber((reply as Record).cursor); + return asCursorValue((reply as Record).cursor); } return undefined; } -function toCursorNumber(value: unknown): number | undefined { - if (value == null) return undefined; - const n = Number(value); - return Number.isNaN(n) ? undefined : n; +function asCursorValue(value: unknown): number | string | undefined { + if (typeof value === 'number' || typeof value === 'string') return value; + return undefined; +} + +function isExhausted(cursor: number | string): boolean { + return cursor === 0 || cursor === '0'; } -/** Read the numeric MAXIDLE (ms) an FT.AGGREGATE …WITHCURSOR declared, if any. */ +/** + * Read the numeric MAXIDLE (ms) an FT.AGGREGATE …WITHCURSOR declared, if any. + * `MAXIDLE 0` means "no idle limit" server-side (clamped to the server + * default), so it maps to `undefined` — the binding falls back to the default + * client-side TTL instead of expiring instantly. + */ function maxIdleFromAggregateArgs(redisArgs: ReadonlyArray): number | undefined { for (let i = 0; i < redisArgs.length - 1; i++) { if (argToString(redisArgs[i]).toUpperCase() === 'MAXIDLE') { const n = Number(argToString(redisArgs[i + 1])); - return Number.isNaN(n) ? undefined : n; + return Number.isNaN(n) || n <= 0 ? undefined : n; } } return undefined; } /** - * Sticky router for FT.CURSOR READ/DEL (HLD `request_policy: special`). These - * are keyless — there's no slot to route by — so we pin the exact node that - * minted the cursor via its recorded binding. A MISS (never created here, - * already exhausted, or the bound node left the cluster) is unusable by this - * client, so throw before any network call rather than fan out or guess. + * Sticky router for FT.CURSOR READ/DEL (HLD `request_policy: special`). + * + * The cursor argument the caller holds is a client-minted virtual token, not + * the server's cursor id: server ids are minted per node and two shards can + * mint the same id for one index, so the raw id cannot identify its node. The + * token resolves through the binding map to (node address, real cursor id) — + * pin that node and rewrite the cursor argument to the real id on the wire. + * + * A MISS (token never minted here, chain already exhausted, or expired) is + * unusable by this client, so throw before any network call rather than fan + * out or guess. */ export const routeFtCursor: RequestRouter = async (slots, parser) => { const { redisArgs } = parser; - const index = argToString(redisArgs[2]); - const cursorId = Number(argToString(redisArgs[3])); + const token = argToString(redisArgs[3]); - const binding = slots.lookupCursor(index, cursorId); + const binding = slots.lookupCursor(token); if (binding) { const client = await slots.getMasterByAddress(binding.address); - if (client) return [{ client }]; + if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }]; + + throw new Error( + `FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" ` + + `has left the cluster.` + ); } throw new Error( - `FT.CURSOR: no known node for cursor ${cursorId} on index "${index}". ` + - `The cursor was not created by this client instance, has already been ` + - `exhausted, or the node that served it has left the cluster.` + `FT.CURSOR: unknown cursor ${token} on index "${argToString(redisArgs[2])}". ` + + `Cluster cursors are minted per client instance and expire when idle — ` + + `the cursor was not created by this client, has already been exhausted, ` + + `or has expired.` ); }; +/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */ +function withCursorArg(parser: CommandParser, cursorId: string): CommandParser { + const sub = new BasicCommandParser(); + const { redisArgs } = parser; + for (let i = 0; i < redisArgs.length; i++) { + sub.push(i === 3 ? cursorId : redisArgs[i] as RedisArgument); + } + return sub; +} + /** - * Command-name-gated hook run after an FT.AGGREGATE / FT.CURSOR reply resolves - * (HLD "hardcoded by command name"). Captures, rebinds, or evicts the sticky - * cursor binding using the single-target plan's serving node. No-op for any - * other command, and for multi-target plans (cursor commands are single-node). + * Command-name-gated post-reply hook for FT.AGGREGATE / FT.CURSOR (invoked + * from `_executeWithPolicies` after the reducer, like `finalizeScanCursor`). + * Mints/rebinds/evicts the sticky binding and swaps the server cursor id in + * the reply for the client token, so the caller only ever loops on tokens. + * No-op for any other command, and for multi-target plans (cursor commands + * are single-node). Returns the (possibly rewritten) reply. */ -export function captureCursorBinding( +export function finalizeFtCursor( slots: ClusterSlots, parser: CommandParser, plan: ReadonlyArray, reply: unknown -): void { +): unknown { const { command, subcommand } = parser.commandIdentifier; const cmd = command.toUpperCase(); - const sub = subcommand?.toUpperCase(); - if (cmd !== 'FT.AGGREGATE' && cmd !== 'FT.CURSOR') return; - if (plan.length !== 1) return; + if (cmd !== 'FT.AGGREGATE' && cmd !== 'FT.CURSOR') return reply; + if (plan.length !== 1) return reply; const { redisArgs } = parser; - const client = plan[0].client; if (cmd === 'FT.AGGREGATE') { - const cursor = extractCursorId(reply); + const cursor = extractCursorValue(reply); // cursor 0 → exhausted in one batch, nothing to pin. - if (!cursor || !client) return; + if (cursor === undefined || isExhausted(cursor)) return reply; + + const client = plan[0].client; + if (!client) return reply; const address = slots.nodeAddressByClient(client); - if (address) { - slots.bindCursor(argToString(redisArgs[1]), cursor, address, maxIdleFromAggregateArgs(redisArgs)); - } - return; + if (!address) return reply; + + const token = slots.mintCursorToken(); + slots.bindCursor(token, { + address, + cursorId: argToString(cursor as RedisArgument), + maxIdleMs: maxIdleFromAggregateArgs(redisArgs) + }); + return withReplyCursor(reply, token, cursor); } - // FT.CURSOR READ / DEL — index at arg 2, cursor id at arg 3. - const index = argToString(redisArgs[2]); - const cursorId = Number(argToString(redisArgs[3])); + // FT.CURSOR READ / DEL — the caller-held token is at arg 3 (the routed + // sub-parser carries the real id; this hook receives the original parser). + const sub = subcommand === undefined ? undefined : argToString(subcommand).toUpperCase(); + const token = argToString(redisArgs[3]); if (sub === 'DEL') { // Self-cleaning: evict locally regardless of the server reply. - slots.evictCursor(index, cursorId); - return; + slots.evictCursor(token); + return reply; } if (sub === 'READ') { - // Reuse the node that served this READ (the binding we routed by, or a - // reverse-lookup of the pinned client) for any continuation cursor. - const address = slots.lookupCursor(index, cursorId)?.address - ?? (client ? slots.nodeAddressByClient(client) : undefined); - const next = extractCursorId(reply); - - if (next === 0 || next === undefined) { - slots.evictCursor(index, cursorId); // exhausted - } else if (next !== cursorId) { - slots.evictCursor(index, cursorId); // rebind continuation → same node - if (address) slots.bindCursor(index, next, address); - } else if (address) { - slots.bindCursor(index, cursorId, address); // unchanged → refresh createdAt + const binding = slots.lookupCursor(token); + if (!binding) return reply; // never bound here — nothing to maintain + + const next = extractCursorValue(reply); + if (next === undefined || isExhausted(next)) { + slots.evictCursor(token); // exhausted + return reply; // the server's 0 ends the caller's loop as-is } + + // Same node serves the continuation; refresh createdAt, keep MAXIDLE, and + // track the (usually unchanged) real id. The caller keeps its token. + slots.bindCursor(token, { + address: binding.address, + cursorId: argToString(next as RedisArgument), + maxIdleMs: binding.maxIdleMs + }); + return withReplyCursor(reply, token, next); } + + return reply; +} + +/** + * Rebuild the reply with the virtual token in place of the server cursor id, + * preserving the reply shape and the cursor's decoded type (a string cursor — + * NUMBER: String mapping — stays a string). + */ +function withReplyCursor(reply: unknown, token: string, original: number | string): unknown { + const cursor = typeof original === 'number' ? Number(token) : token; + + if (reply instanceof Map) { + const copy = new Map(reply as Map); + copy.set('cursor', cursor); + return copy; + } + + if (Array.isArray(reply)) { + const copy = reply.slice(); + copy[1] = cursor; + return copy; + } + + return { ...(reply as Record), cursor }; } diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts index 6210f4eb328..39a6a07a215 100644 --- a/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts @@ -13,7 +13,7 @@ class FakeSlots { masterOrder: Array = []; #seq = 0; - mintScanCursorToken() { return String(++this.#seq); } + mintCursorToken() { return String(++this.#seq); } bindScanCursor(token: string, address: string, cursor: string, visited: Set) { this.scanCursors.set(token, { address, cursor, visited, createdAt: 0 }); } diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts index e2b4d0f07e9..c4550377945 100644 --- a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts @@ -73,7 +73,7 @@ function withCursor(parser: CommandParser, cursor: string): CommandParser { /** * Post-reply hook for SCAN (invoked from `_executeWithPolicies` after the - * reducer, like `captureCursorBinding`): advances the chain state and swaps + * reducer, like `finalizeFtCursor`): advances the chain state and swaps * the server cursor in the reply for the chain's virtual token. No-op for any * other command or a non-single-target plan. Returns the (possibly rewritten) * reply. @@ -99,7 +99,7 @@ export function finalizeScanCursor( if (argToString(serverCursor) !== '0') { // Node not exhausted: resume it next call with the real cursor. - const token = callerCursor === '0' ? slots.mintScanCursorToken() : callerCursor; + const token = callerCursor === '0' ? slots.mintCursorToken() : callerCursor; slots.bindScanCursor(token, address, argToString(serverCursor), visited); return withReplyCursor(reply, token, serverCursor); } @@ -111,7 +111,7 @@ export function finalizeScanCursor( if (callerCursor !== '0') slots.evictScanCursor(callerCursor); return reply; // server cursor is already "0" — the chain is done } - const token = callerCursor === '0' ? slots.mintScanCursorToken() : callerCursor; + const token = callerCursor === '0' ? slots.mintCursorToken() : callerCursor; slots.bindScanCursor(token, next, '0', visited); return withReplyCursor(reply, token, serverCursor); } diff --git a/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts index 5c150931fcb..517772c248a 100644 --- a/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts +++ b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts @@ -11,6 +11,7 @@ import testUtils, { GLOBAL } from '../test-utils'; describe('FT.CURSOR sticky routing (cluster)', () => { const DOC_COUNT = 40; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper over the dynamic cluster surface async function seedIndex(cluster: any) { await cluster.ft.create('idx', { n: 'NUMERIC' }); const writes = []; @@ -48,7 +49,7 @@ describe('FT.CURSOR sticky routing (cluster)', () => { await cluster.ft.cursorDel('idx', cursor); await assert.rejects( cluster.ft.cursorRead('idx', cursor), - /no known node for cursor/, + /unknown cursor/, 'READ after DEL should MISS before any network call' ); }, GLOBAL.CLUSTERS.OPEN); @@ -64,7 +65,7 @@ describe('FT.CURSOR sticky routing (cluster)', () => { try { await assert.rejects( other.ft.cursorRead('idx', cursor), - /no known node for cursor/, + /unknown cursor/, 'a second client has no binding for the first client\'s cursor' ); } finally { From d0c6fff0097df0d37bfb82ae9fe25628adceffc0 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:22:05 +0300 Subject: [PATCH 35/54] test(client): replace stale CROSSSLOT mGet assertion with split behavior multi_shard routing makes cross-slot mGet succeed; assert ordered reassembly and null placement instead of the old rejection. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/client/lib/cluster/index.spec.ts b/packages/client/lib/cluster/index.spec.ts index d2082a862b2..cb7775ba844 100644 --- a/packages/client/lib/cluster/index.spec.ts +++ b/packages/client/lib/cluster/index.spec.ts @@ -314,8 +314,16 @@ describe('Cluster', () => { assert.ok(nodeClient instanceof RedisClient); }, GLOBAL.CLUSTERS.WITH_REPLICAS); - testUtils.testWithCluster('should throw CROSSSLOT error', async cluster => { - await assert.rejects(cluster.mGet(['a', 'b'])); + testUtils.testWithCluster('mGet splits cross-slot keys and preserves caller order', async cluster => { + // 'a' and 'b' hash to different slots — on master this rejected with + // CROSSSLOT; the multi_shard split routes each key to its shard and + // reassembles the replies in the caller's key order. + await Promise.all([cluster.set('a', 'value-a'), cluster.set('b', 'value-b')]); + assert.deepEqual(await cluster.mGet(['a', 'b']), ['value-a', 'value-b']); + assert.deepEqual( + await cluster.mGet(['b', 'missing', 'a']), + ['value-b', null, 'value-a'] + ); }, GLOBAL.CLUSTERS.OPEN); testUtils.testWithCluster('numeric aggregates honor a NUMBER type mapping', async cluster => { From 0207506f6c5cc3210c4b18d57e02fe57d90db942 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:24:22 +0300 Subject: [PATCH 36/54] fix(client): connect keyless-routed nodes lazily in cluster dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routeDefaultKeyless and the special-policy fallback read .client\! off the random node, which is undefined under minimizeConnections until first use — the plan then carried no client and the FT cursor post-reply hook could not bind the serving node. Resolve through nodeClient(), which connects on demand. Co-Authored-By: Claude Fable 5 --- .../lib/cluster/request-response-policies/dispatch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index ea5ad92ee70..e230d2dfdaf 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -105,8 +105,11 @@ function buildSubParser(sub: SubCommand): CommandParser { return parser; } +// `nodeClient` connects lazily — with `minimizeConnections` a node may have +// no client until first use, and `.client!` would put `undefined` in the plan +// (breaking the post-reply hooks that attribute the reply to `plan[0].client`). export const routeDefaultKeyless: RequestRouter = - async (slots) => [{ client: slots.getRandomNode().client! }]; + async (slots) => [{ client: await slots.nodeClient(slots.getRandomNode()) }]; export const routeDefaultKeyed: RequestRouter = async (slots, parser, isReadonly) => @@ -152,7 +155,7 @@ export const routeSpecial: RequestRouter = `node-redis: no cluster routing implemented for the "special" request policy of ` + `"${specialKey(parser)}"; routing to a single node. The reply may be incomplete.` ); - return [{ client: slots.getRandomNode().client! }]; + return [{ client: await slots.nodeClient(slots.getRandomNode()) }]; }; // --- response reducers --- From 559ebb20058c5619bb75377f16a308e697a39088 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:26:07 +0300 Subject: [PATCH 37/54] fix(client): surface a shard error from one_succeeded instead of AggregateError Promise.any wraps the all-rejected case (e.g. SCRIPT KILL with nothing running) in AggregateError, hiding the Redis error users expect; rethrow the first shard's error. Co-Authored-By: Claude Fable 5 --- .../dispatch.spec.ts | 22 ++++++++++++++++++- .../request-response-policies/dispatch.ts | 15 +++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts index b568a87868b..48936ec61d7 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import type { CommandParser } from '../../client/parser'; -import { reduceDefaultKeyed, reduceRandomKey, reduceSpecial, remapAggregateReply } from './dispatch'; +import { reduceDefaultKeyed, reduceOneSucceeded, reduceRandomKey, reduceSpecial, remapAggregateReply } from './dispatch'; import { RESP_TYPES } from '../../RESP/decoder'; import type { TypeMapping } from '../../RESP/types'; @@ -110,6 +110,26 @@ describe('reduceDefaultKeyed', () => { }); }); +describe('reduceOneSucceeded', () => { + it('returns the first fulfilled reply', async () => { + const reply = await reduceOneSucceeded([ + Promise.reject(new Error('NOTBUSY No scripts in execution right now.')), + Promise.resolve('OK') + ]); + assert.equal(reply, 'OK'); + }); + + it('surfaces a shard error instead of AggregateError when every node rejects', async () => { + await assert.rejects( + reduceOneSucceeded([ + Promise.reject(new Error('NOTBUSY No scripts in execution right now.')), + Promise.reject(new Error('NOTBUSY No scripts in execution right now.')) + ]), + /NOTBUSY/ + ); + }); +}); + describe('remapAggregateReply', () => { const STRING_MAPPING = { [RESP_TYPES.NUMBER]: String } as TypeMapping; diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index e230d2dfdaf..c73cf557495 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -160,8 +160,19 @@ export const routeSpecial: RequestRouter = // --- response reducers --- -export const reduceOneSucceeded = (promises: Promise[]): Promise => - Promise.any(promises); +/** + * `one_succeeded`: first fulfilled reply wins. When every node rejects the + * policy requires surfacing one of the shard errors (e.g. SCRIPT KILL's + * NOTBUSY), not the opaque `AggregateError` that `Promise.any` throws. + */ +export const reduceOneSucceeded = async (promises: Promise[]): Promise => { + try { + return await Promise.any(promises); + } catch (err) { + if (err instanceof AggregateError && err.errors.length > 0) throw err.errors[0]; + throw err; + } +}; export const reduceAllSucceeded = async (promises: Promise[]): Promise => { const responses = await Promise.all(promises); From d5cba5a30a1b693d8363efd54ee8ef22bf1b4c95 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:27:28 +0300 Subject: [PATCH 38/54] fix(client): correct aggregateLogicalOr seed and validation Seeded with 1s, the OR of all-zero shard replies aggregated to all ones; it also crashed on an empty replies array. Seed with 0s and add the same array-of-numbers validation as the AND sibling. Co-Authored-By: Claude Fable 5 --- .../generic-aggregators.spec.ts | 30 ++++++++++++++++ .../generic-aggregators.ts | 34 +++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts new file mode 100644 index 00000000000..de7b0dfcc8f --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts @@ -0,0 +1,30 @@ +import { strict as assert } from 'node:assert'; +import { aggregateLogicalAnd, aggregateLogicalOr } from './generic-aggregators'; + +describe('aggregateLogicalOr', () => { + it('ORs element-wise across shards', () => { + assert.deepEqual(aggregateLogicalOr([[1, 0, 0], [0, 0, 1]]), [1, 0, 1]); + }); + + it('returns all zeros when every shard reports zeros', () => { + assert.deepEqual(aggregateLogicalOr([[0, 0], [0, 0]]), [0, 0]); + }); + + it('returns [] for an empty replies array', () => { + assert.deepEqual(aggregateLogicalOr([]), []); + }); + + it('rejects non-numeric replies', () => { + assert.throws(() => aggregateLogicalOr([['1', '0']]), /logical OR aggregation/); + }); +}); + +describe('aggregateLogicalAnd', () => { + it('ANDs element-wise across shards (SCRIPT EXISTS)', () => { + assert.deepEqual(aggregateLogicalAnd([[1, 1, 0], [1, 0, 0]]), [1, 0, 0]); + }); + + it('rejects non-numeric replies', () => { + assert.throws(() => aggregateLogicalAnd([['1']]), /logical AND aggregation/); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts index 16de711f2c4..c8d5deb3cc2 100644 --- a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -32,16 +32,36 @@ export const aggregateLogicalAnd = (replies: Array): T => { return result as T; }; -//TODO fix this -export const aggregateLogicalOr = ( - replies: Array -): T => { - const result = Array((replies[0] as Array).length).fill(1); +/** + * Aggregates multiple arrays of numbers using logical OR operation. + * @remarks + * Mirror of `aggregateLogicalAnd` for the `agg_logical_or` response policy. + * No command in the current metadata snapshot uses it, but the reducer is + * registered in `RESPONSE_REDUCERS`, so it must be correct for the first + * command a future metadata regeneration tags with it. + */ +export const aggregateLogicalOr = (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 OR aggregation' + ); + } + + const result = Array(replies[0].length).fill(0); + for (const reply of replies) { - for (let i = 0; i < (reply as Array).length; i++) { - result[i] = result[i] || (reply as Array)[i]; + for (let i = 0; i < reply.length; i++) { + result[i] = result[i] || reply[i]; } } + return result as T; }; From 795cade44bcf1ea991365815264eb3ea4de17b95 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:31:32 +0300 Subject: [PATCH 39/54] fix(client): throw standard not-ready errors from cluster fan-out routes getAllClients/getAllMasterClients/getRandomNode skipped #assertReady, so policy-routed commands on a closed or offline cluster surfaced a generic 'produced no target nodes' error (or a TypeError) instead of ClientClosedError/ClientOfflineError like every other path. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/cluster-slots.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index f4797cf1696..5f1222839fa 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -876,6 +876,8 @@ export default class RedisClusterSlots< * Excludes dedicated PubSub connections: they cannot run regular commands. */ getAllClients(): Promise>> { + this.#assertReady(); + return Promise.all([ ...this.masters.map(master => this.nodeClient(master)), ...this.replicas.map(replica => this.nodeClient(replica)) @@ -883,6 +885,8 @@ export default class RedisClusterSlots< } getAllMasterClients(): Promise>> { + this.#assertReady(); + return Promise.all(this.masters.map(master => this.nodeClient(master))); } @@ -961,6 +965,8 @@ export default class RedisClusterSlots< _randomNodeIterator?: IterableIterator>; getRandomNode() { + this.#assertReady(); + this._randomNodeIterator ??= this.#iterateAllNodes(); return this._randomNodeIterator.next().value as ShardNode; } From 64fd33272e3a9546f4100c542d88a4ac3a76f73e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:34:41 +0300 Subject: [PATCH 40/54] fix(client): parse pre-7.0 COMMAND replies without throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transformCommandReply destructured and iterated reply fields 8-10 (tips, key specs, subcommands — added in Redis 7.0) unguarded, so client.command()/commandInfo() against older servers or truncating proxies threw 'tips is not iterable'. Default the missing fields. Co-Authored-By: Claude Fable 5 --- packages/client/lib/commands/COMMAND.spec.ts | 19 +++++++++++++++++++ .../lib/commands/generic-transformers.ts | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 908006694d3..aa132f97520 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -32,6 +32,25 @@ describe('COMMAND', () => { subcommands: [] } }, + { + name: 'pre-7.0 shape (no tips/key-specs/subcommands fields)', + input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST]] as unknown as 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, + nondeterministicOutput: false, + tips: [], + keySpecs: [], + subcommands: [] + } + }, { name: 'with valid policies', input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], [], []] satisfies CommandRawReply, diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 2e772327c30..f8c90a76a52 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -480,7 +480,10 @@ export function transformKeySpec(raw: unknown): KeySpec { export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply + // Fields 8-10 (tips, key specifications, subcommands) exist since Redis 7.0; + // default them so a shorter reply (older server, truncating proxy) parses + // instead of throwing "tips is not iterable". + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips = [], keySpecifications = [], subcommandsReply = []]: CommandRawReply ): CommandReply { From 304943101a6e54bdd2ee5fd475ed102b4ecc33b3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:42:29 +0300 Subject: [PATCH 41/54] fix(client): forward malformed raw cursor commands to the server routeFtCursor/routeScan indexed redisArgs positionally, so a raw sendCommand missing the cursor argument threw a client-side TypeError before any I/O; forward short commands to a node so the server's own arity error surfaces, as on master. Co-Authored-By: Claude Fable 5 --- .../request-response-policies/ft-cursor.spec.ts | 11 +++++++++++ .../cluster/request-response-policies/ft-cursor.ts | 7 +++++++ .../request-response-policies/scan-cursor.spec.ts | 10 ++++++++++ .../cluster/request-response-policies/scan-cursor.ts | 6 ++++++ 4 files changed, 34 insertions(+) diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts index fe8bf216b47..3737b52e099 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts @@ -20,6 +20,8 @@ class FakeSlots { lookupCursor(token: string) { return this.cursorBindings.get(token); } evictCursor(token: string) { this.cursorBindings.delete(token); } async getMasterByAddress(address: string) { return this.clientsByAddress.get(address); } + getRandomNode() { return { address: this.clientsByAddress.keys().next().value as string }; } + async nodeClient(node: { address: string }) { return this.clientsByAddress.get(node.address); } nodeAddressByClient(client: object) { for (const [address, c] of this.clientsByAddress) if (c === client) return address; return undefined; @@ -68,6 +70,15 @@ describe('routeFtCursor', () => { assert.deepEqual(plan[0].parser!.redisArgs, ['FT.CURSOR', 'READ', 'idx', '18446744073709551615']); }); + it('forwards a malformed FT.CURSOR (missing cursor arg) to a node for the server arity error', async () => { + const slots = new FakeSlots(); + const client = { id: 'node-a' }; + slots.clientsByAddress.set('127.0.0.1:7000', client); + + const plan = await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx'), undefined, undefined); + assert.deepEqual(plan, [{ client }]); + }); + it('throws on MISS (token never minted here)', async () => { const slots = new FakeSlots(); await assert.rejects( diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts index 5ec6bc30b43..9149fb5c503 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -80,6 +80,13 @@ function maxIdleFromAggregateArgs(redisArgs: ReadonlyArray): numb */ export const routeFtCursor: RequestRouter = async (slots, parser) => { const { redisArgs } = parser; + + // Malformed raw command (missing index/cursor): forward to any node so the + // server returns its own arity error instead of a client-side TypeError. + if (redisArgs.length < 4) { + return [{ client: await slots.nodeClient(slots.getRandomNode()) }]; + } + const token = argToString(redisArgs[3]); const binding = slots.lookupCursor(token); diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts index 39a6a07a215..d37a0de64a8 100644 --- a/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.spec.ts @@ -27,6 +27,8 @@ class FakeSlots { for (const [address, c] of this.clientsByAddress) if (c === client) return address; return undefined; } + getRandomNode() { return { address: this.masterOrder[0] }; } + async nodeClient(node: { address: string }) { return this.clientsByAddress.get(node.address); } addMaster(address: string) { const client = { id: address }; @@ -43,6 +45,14 @@ const parserOf = (...args: Array) => const asSlots = (s: FakeSlots) => s as any; describe('routeScan', () => { + it('forwards a malformed SCAN (no cursor arg) to a node for the server arity error', async () => { + const slots = new FakeSlots(); + const a = slots.addMaster('a:1'); + + const plan = await routeScan(asSlots(slots), parserOf('SCAN'), undefined, undefined); + assert.deepEqual(plan, [{ client: a }]); + }); + it('SCAN 0 starts on the first master', async () => { const slots = new FakeSlots(); const a = slots.addMaster('a:1'); diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts index c4550377945..f1a3e6d8e4f 100644 --- a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts @@ -27,6 +27,12 @@ type ClusterSlots = Parameters[0]; * may be missed or duplicated — same caveat as every cluster-wide scan. */ export const routeScan: RequestRouter = async (slots, parser) => { + // Malformed raw command (missing cursor): forward to any node so the server + // returns its own arity error instead of a client-side TypeError. + if (parser.redisArgs.length < 2) { + return [{ client: await slots.nodeClient(slots.getRandomNode()) }]; + } + const cursorArg = argToString(parser.redisArgs[1]); if (cursorArg === '0') { From 02b36d71558a5d798b89e7b592bcf645daefdfa9 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 17 Jul 2026 15:57:58 +0300 Subject: [PATCH 42/54] fix(client): restore slotNumber threading through cluster _execute The policies refactor dropped the { ...options, slotNumber } per-attempt options master passed to the node client, so queued commands carried no slot metadata and extractCommandsForSlots could not relocate them to the destination node during an SMIGRATED maintenance event. Pinned plans (default-keyed pins, multi_shard sub-commands) derive the slot from the parser's first key; keyless fan-outs carry none. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 1285da1d736..1053df06cfb 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -18,6 +18,7 @@ import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/i import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS, NUMERIC_AGG_POLICIES, remapAggregateReply } from './request-response-policies/dispatch'; +import calculateSlot from 'cluster-key-slot'; import { finalizeFtCursor } from './request-response-policies/ft-cursor'; import { finalizeScanCursor } from './request-response-policies/scan-cursor'; @@ -636,8 +637,21 @@ export default class RedisCluster< ): Promise { const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - let client = pinnedClient - ?? (await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client; + // The slot number travels with every attempt (commands-queue + // `slotNumber`): during an SMIGRATED maintenance event + // `extractCommandsForSlots` relocates queued commands to the destination + // node by this value. Pinned plans (default-keyed pins, multi_shard + // sub-commands) derive it from the parser's first key; keyless fan-outs + // carry none. The slot of a key never changes, so it is computed once + // even though MOVED redirects re-resolve the client. + let client: RedisClientType; + let slotNumber: number | undefined; + if (pinnedClient) { + client = pinnedClient; + slotNumber = parser.firstKey === undefined ? undefined : calculateSlot(parser.firstKey); + } else { + ({ client, slotNumber } = await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)); + } let i = 0; @@ -645,7 +659,8 @@ export default class RedisCluster< while (true) { try { - return await myFn(client, options); + const opts: ClusterCommandOptions = { ...options, slotNumber }; + return await myFn(client, opts); } catch (_err) { const err = _err as Error; myFn = fn; From c1938a6d9e3e479643142380ea29b5eb061b4141 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 22:37:11 +0300 Subject: [PATCH 43/54] fix(client): exclude VRANDMEMBER from client-side caching The server does not tag the vector-set family with nondeterministic_output (unlike SRANDMEMBER/HRANDFIELD/ZRANDMEMBER), so the derived metadata marked VRANDMEMBER cacheable and CSC froze the random pick until the key changed. Override with CACHEABLE: false until the server tags it; the rest of the V-family is deterministic for a given data state. Co-Authored-By: Claude Fable 5 --- packages/client/lib/commands/VRANDMEMBER.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/client/lib/commands/VRANDMEMBER.ts b/packages/client/lib/commands/VRANDMEMBER.ts index 330af13b24c..5a525cf06a8 100644 --- a/packages/client/lib/commands/VRANDMEMBER.ts +++ b/packages/client/lib/commands/VRANDMEMBER.ts @@ -2,6 +2,12 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, ArrayReply, Command, NullReply } from '../RESP/types'; export default { + // The server metadata makes VRANDMEMBER look cacheable (readonly + keyed) + // because, unlike its siblings (SRANDMEMBER/HRANDFIELD/ZRANDMEMBER), it is + // not tagged with the `nondeterministic_output` tip yet. Caching a random + // pick would freeze it until the key changes — override until the server + // tags it. + CACHEABLE: false, parseCommand(parser: CommandParser, key: RedisArgument, count?: number) { parser.push('VRANDMEMBER'); parser.pushKey(key); From 7253d2de138ff54aa4198c6c290e11d3930229de Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:02:48 +0300 Subject: [PATCH 44/54] perf(client): skip CSC eligibility work when caching is disabled _executeCommand ran the command-identifier decode and metadata lookup on every command even without a client-side cache; gate it behind csc. Co-Authored-By: Claude Fable 5 --- packages/client/lib/client/index.ts | 33 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 43b1303b86e..20197ca9856 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -1227,26 +1227,31 @@ export default class RedisClient< transformReply: TransformReply | undefined, ) { const csc = this._self.#clientSideCache; - const defaultTypeMapping = this._self.#options.commandOptions === commandOptions || - (this._self.#options.commandOptions?.typeMapping === commandOptions?.typeMapping); - const fn = () => { return this.sendCommand(parser.redisArgs, commandOptions) }; - // Override-first: a defined `Command.CACHEABLE` wins; otherwise CSC - // eligibility derives from the server flags/tips (see `isCacheable`). - const cacheable = isCacheable(defaultCommandMetadata.lookup(parser.commandIdentifier), command.CACHEABLE); + // Eligibility is only worth computing when caching is enabled — the + // metadata lookup decodes the command identifier (Buffer args included) + // and must not tax the common no-CSC path. + if (csc) { + const defaultTypeMapping = this._self.#options.commandOptions === commandOptions || + (this._self.#options.commandOptions?.typeMapping === commandOptions?.typeMapping); - if (csc && cacheable && defaultTypeMapping) { - return await csc.handleCache(this._self, parser as BasicCommandParser, fn, transformReply, commandOptions?.typeMapping); - } else { - const reply = await fn(); + // Override-first: a defined `Command.CACHEABLE` wins; otherwise CSC + // eligibility derives from the server flags/tips (see `isCacheable`). + const cacheable = isCacheable(defaultCommandMetadata.lookup(parser.commandIdentifier), command.CACHEABLE); - const finalReply = transformReply ? transformReply(reply, parser.preserve, commandOptions?.typeMapping) : reply; + if (cacheable && defaultTypeMapping) { + return await csc.handleCache(this._self, parser as BasicCommandParser, fn, transformReply, commandOptions?.typeMapping); + } + } - publish(CHANNELS.COMMAND_REPLY, () => ({ args: sanitizeArgs(parser.redisArgs), reply: finalReply, clientId: this._self._clientId })); + const reply = await fn(); - return finalReply; - } + const finalReply = transformReply ? transformReply(reply, parser.preserve, commandOptions?.typeMapping) : reply; + + publish(CHANNELS.COMMAND_REPLY, () => ({ args: sanitizeArgs(parser.redisArgs), reply: finalReply, clientId: this._self._clientId })); + + return finalReply; } /** From 6e599b58cdb9d3667875de358149059dc8b2a65e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:03:28 +0300 Subject: [PATCH 45/54] perf(client): memoize the parser command identifier The getter decodes Buffer args and allocates on every read; cluster commands read it up to three times (policy resolution plus both post-reply hooks). Co-Authored-By: Claude Fable 5 --- packages/client/lib/client/parser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index c1fabd795b6..f2a412300e9 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -60,6 +60,7 @@ export class BasicCommandParser implements CommandParser { #redisArgs: Array = []; #keys: Array = []; readonly #keyPrefix: RedisArgument | undefined; + #commandIdentifier: CommandIdentifier | undefined; preserve: unknown; /** @@ -93,14 +94,18 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } + // Memoized: read only after the command is fully parsed, and consumed up to + // three times per cluster command (policy resolution + both post-reply + // hooks) — each evaluation would otherwise re-decode Buffer args. get commandIdentifier(): CommandIdentifier { + if (this.#commandIdentifier) return this.#commandIdentifier; const rawCommand = this.#redisArgs[0]; const rawSubcommand = this.#redisArgs[1]; const command = rawCommand instanceof Buffer ? rawCommand.toString() : rawCommand; const subcommand = rawSubcommand === undefined ? undefined : rawSubcommand instanceof Buffer ? rawSubcommand.toString() : rawSubcommand; - return { command, subcommand }; + return this.#commandIdentifier = { command, subcommand }; } push(...arg: Array) { From 8aec34492a4f196aa14e7ba8a50ece4103ecc5ca Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:04:28 +0300 Subject: [PATCH 46/54] perf(client): fast-path default-keyed cluster commands Every single-key command paid the full plan/reducer/positionHints/ post-reply-hook machinery (~12-20 allocations and extra promise hops vs master) although all of it is a no-op for the default-keyed shape. Route by firstKey and pass the sole reply through directly. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 1053df06cfb..20bd882e6a9 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -545,6 +545,19 @@ export default class RedisCluster< const requestPolicy = policy.request const responsePolicy = policy.response + // Fast path: default-keyed request + response — the overwhelming majority + // of traffic (every single-key command). Route by firstKey and pass the + // sole reply through, skipping the plan/reducer/post-reply machinery, + // which is a no-op for this shape (the hooks only concern + // FT.AGGREGATE/FT.CURSOR/SCAN and the remap only numeric aggregates — + // none of them default-keyed). + if ( + requestPolicy === REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED && + responsePolicy === RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + ) { + return this._execute(parser, readonly, options, makeFn(parser)); + } + // https://redis.io/docs/latest/develop/reference/command-tips const router = REQUEST_ROUTERS[requestPolicy]; if (!router) { From 0b60e858b197eb098815faa0592fb22f9b1b3197 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:05:50 +0300 Subject: [PATCH 47/54] fix(client): attribute post-reply hooks to the redirected serving node After a MOVED/ASK redirect the reply comes from a different node than plan[0].client, so cursor bindings could pin the stale original target. _execute now records the client that served each attempt; single-target plans pass it to the finalize hooks. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 20bd882e6a9..1889026a9ab 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -587,11 +587,19 @@ export default class RedisCluster< ? { ...options, typeMapping: undefined } : options; + // Track the actually-serving client for single-target plans: after a + // MOVED/ASK redirect the reply comes from a different node than + // `plan[0].client`, and the post-reply hooks must bind cursors to the node + // that really served. Multi-target hooks are no-ops, so nothing to track. + const served: { client?: RedisClientType } = {}; const responsePromises = plan.map(entry => { const entryParser = entry.parser ?? parser; // Re-narrow the opaque routed client to this cluster's instantiation. const client = entry.client as RedisClientType | undefined; - return this._execute(entryParser, readonly, execOptions, makeFn(entryParser), client); + return this._execute( + entryParser, readonly, execOptions, makeFn(entryParser), client, + plan.length === 1 ? served : undefined + ); }); const reducer = RESPONSE_REDUCERS[responsePolicy]; @@ -604,6 +612,12 @@ export default class RedisCluster< reply = remapAggregateReply(reply, requestedMapping); } + // Attribute the reply to the node that actually served it (MOVED/ASK may + // have redirected away from the plan's original target). + const hookPlan = served.client && served.client !== plan[0].client + ? [{ ...plan[0], client: served.client }] + : plan; + // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the // serving node and swap the server cursor id in the reply for a // client-minted token (server ids are per-node and can collide across @@ -614,7 +628,7 @@ export default class RedisCluster< reply = finalizeFtCursor( this._slots as unknown as Parameters[0], parser, - plan, + hookPlan, reply ) as typeof reply; } catch { /* cursor finalization is best-effort */ } @@ -627,7 +641,7 @@ export default class RedisCluster< reply = finalizeScanCursor( this._slots as unknown as Parameters[0], parser, - plan, + hookPlan, reply ) as typeof reply; } catch { /* scan finalization is best-effort */ } @@ -646,7 +660,11 @@ export default class RedisCluster< isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise, - pinnedClient?: RedisClientType + pinnedClient?: RedisClientType, + // When given, records the client that actually served the reply — after a + // MOVED/ASK redirect that differs from the plan's original target, and the + // post-reply hooks (sticky cursor bindings) must attribute to it. + served?: { client?: RedisClientType } ): Promise { const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; @@ -672,6 +690,7 @@ export default class RedisCluster< while (true) { try { + if (served) served.client = client; const opts: ClusterCommandOptions = { ...options, slotNumber }; return await myFn(client, opts); } catch (_err) { From 6f3335cdafab4b5c64797c13e75f58d6f218e7a1 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:10:59 +0300 Subject: [PATCH 48/54] chore(client): remove scratch and one-off review artifacts command-router.ts (fully commented out), the assertion-free test.spec.ts docker suite, the readonly-discrepancies audit artifacts (fully mined into command definitions and the deviations ledger), and a stray TODO in the lua-multi-incr example. Co-Authored-By: Claude Fable 5 --- examples/lua-multi-incr.js | 1 - .../command-router.ts | 15 - .../request-response-policies/index.ts | 1 - .../request-response-policies/test.spec.ts | 25 -- readonly-discrepancies.md | 205 ------------- scripts/readonly-discrepancies.mjs | 288 ------------------ 6 files changed, 535 deletions(-) delete mode 100644 packages/client/lib/cluster/request-response-policies/command-router.ts delete mode 100644 packages/client/lib/cluster/request-response-policies/test.spec.ts delete mode 100644 readonly-discrepancies.md delete mode 100644 scripts/readonly-discrepancies.mjs diff --git a/examples/lua-multi-incr.js b/examples/lua-multi-incr.js index 645c41b6c5f..8f872a1c0a5 100644 --- a/examples/lua-multi-incr.js +++ b/examples/lua-multi-incr.js @@ -7,7 +7,6 @@ const client = createClient({ scripts: { mincr: defineScript({ NUMBER_OF_KEYS: 2, - // TODO add RequestPolicy: , SCRIPT: 'return {' + 'redis.pcall("INCRBY", KEYS[1], ARGV[1]),' + diff --git a/packages/client/lib/cluster/request-response-policies/command-router.ts b/packages/client/lib/cluster/request-response-policies/command-router.ts deleted file mode 100644 index e7dacb51f85..00000000000 --- a/packages/client/lib/cluster/request-response-policies/command-router.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index d28ac18869b..4a5ef8f4a95 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -3,4 +3,3 @@ export * from './dispatch'; export { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; -// export { type CommandRouter } from './command-router'; diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts deleted file mode 100644 index 739d892ecbe..00000000000 --- a/packages/client/lib/cluster/request-response-policies/test.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/readonly-discrepancies.md b/readonly-discrepancies.md deleted file mode 100644 index dce9168e59c..00000000000 --- a/readonly-discrepancies.md +++ /dev/null @@ -1,205 +0,0 @@ - - -## BUG_WRITE_AS_RO (34) - -we mark IS_READ_ONLY but server flags `write` (cluster would route to a replica) - -| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File | -| --- | --- | --- | --- | --- | -| `FT.ALIASADD` | true | false | write, denyoom, module | `packages/search/lib/commands/ALIASADD.ts` | -| `FT.ALIASDEL` | true | false | write, module | `packages/search/lib/commands/ALIASDEL.ts` | -| `FT.ALIASUPDATE` | true | false | write, denyoom, module | `packages/search/lib/commands/ALIASUPDATE.ts` | -| `FT.ALTER` | true | false | write, denyoom, module | `packages/search/lib/commands/ALTER.ts` | -| `FT.CONFIG\|SET` | true | false | write, module | `packages/search/lib/commands/CONFIG_SET.ts` | -| `FT.CREATE` | true | false | write, denyoom, module | `packages/search/lib/commands/CREATE.ts` | -| `FT.DICTADD` | true | false | write, denyoom, module | `packages/search/lib/commands/DICTADD.ts` | -| `FT.DICTDEL` | true | false | write, module | `packages/search/lib/commands/DICTDEL.ts` | -| `FT.DROPINDEX` | true | false | write, module | `packages/search/lib/commands/DROPINDEX.ts` | -| `FT.SUGADD` | true | false | write, denyoom, module | `packages/search/lib/commands/SUGADD.ts` | -| `FT.SUGDEL` | true | false | write, module | `packages/search/lib/commands/SUGDEL.ts` | -| `FT.SYNUPDATE` | true | false | write, denyoom, module | `packages/search/lib/commands/SYNUPDATE.ts` | -| `blpop` | true | false | write, blocking | `packages/client/lib/commands/BLPOP.ts` | -| `brpop` | true | false | write, blocking | `packages/client/lib/commands/BRPOP.ts` | -| `getdel` | true | false | write, fast | `packages/client/lib/commands/GETDEL.ts` | -| `getex` | true | false | write, fast | `packages/client/lib/commands/GETEX.ts` | -| `getset` | true | false | write, denyoom, fast | `packages/client/lib/commands/GETSET.ts` | -| `hpexpireat` | true | false | write, fast | `packages/client/lib/commands/HPEXPIREAT.ts` | -| `hsetnx` | true | false | write, denyoom, fast | `packages/client/lib/commands/HSETNX.ts` | -| `linsert` | true | false | write, denyoom | `packages/client/lib/commands/LINSERT.ts` | -| `lrem` | true | false | write | `packages/client/lib/commands/LREM.ts` | -| `lset` | true | false | write, denyoom | `packages/client/lib/commands/LSET.ts` | -| `mset` | true | false | write, denyoom | `packages/client/lib/commands/MSET.ts` | -| `msetnx` | true | false | write, denyoom | `packages/client/lib/commands/MSETNX.ts` | -| `pexpire` | true | false | write, fast | `packages/client/lib/commands/PEXPIRE.ts` | -| `pexpireat` | true | false | write, fast | `packages/client/lib/commands/PEXPIREAT.ts` | -| `pfadd` | true | false | write, denyoom, fast | `packages/client/lib/commands/PFADD.ts` | -| `rename` | true | false | write | `packages/client/lib/commands/RENAME.ts` | -| `renamenx` | true | false | write, fast | `packages/client/lib/commands/RENAMENX.ts` | -| `restore-asking` | true | false | write, denyoom, asking | `packages/client/lib/commands/RESTORE-ASKING.ts` | -| `sort` | true | false | write, denyoom, movablekeys | `packages/client/lib/commands/SORT.ts` | -| `xreadgroup` | true | false | write, blocking, movablekeys | `packages/client/lib/commands/XREADGROUP.ts` | -| `zdiffstore` | true | false | write, denyoom, movablekeys | `packages/client/lib/commands/ZDIFFSTORE.ts` | -| `bf.reserve` | true | false | write, denyoom, module | `packages/bloom/lib/commands/bloom/RESERVE.ts` | - -## MISSED_RO (44) - -we do not mark IS_READ_ONLY but server flags `readonly` (lost replica routing) - -| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File | -| --- | --- | --- | --- | --- | -| `ts.info` | false | true | readonly, module | `packages/time-series/lib/commands/INFO_DEBUG.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_GROUPBY.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_MULTIAGGR.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.ts` | -| `ts.mrevrange` | false | true | readonly, module | `packages/time-series/lib/commands/MREVRANGE_WITHLABELS_MULTIAGGR.ts` | -| `ts.revrange` | false | true | readonly, module | `packages/time-series/lib/commands/REVRANGE.ts` | -| `ts.revrange` | false | true | readonly, module | `packages/time-series/lib/commands/REVRANGE_MULTIAGGR.ts` | -| `FT.AGGREGATE` | false | true | readonly, module | `packages/search/lib/commands/AGGREGATE.ts` | -| `FT.AGGREGATE` | false | true | readonly, module | `packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts` | -| `FT.SEARCH` | false | true | readonly, module | `packages/search/lib/commands/SEARCH_NOCONTENT.ts` | -| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHPAYLOADS.ts` | -| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHSCORES.ts` | -| `FT.SUGGET` | false | true | readonly, module | `packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.ts` | -| `json.debug` | false | true | readonly, module | `packages/json/lib/commands/DEBUG_MEMORY.ts` | -| `json.get` | false | true | readonly, module | `packages/json/lib/commands/GET.ts` | -| `json.objkeys` | false | true | readonly, module | `packages/json/lib/commands/OBJKEYS.ts` | -| `fcall_ro` | false | true | readonly, noscript, stale, skip_monitor, no_mandatory_keys, movablekeys | `packages/client/lib/commands/FCALL_RO.ts` | -| `geosearch` | false | true | readonly | `packages/client/lib/commands/GEOSEARCH_WITH.ts` | -| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_IDX.ts` | -| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_IDX_WITHMATCHLEN.ts` | -| `lcs` | false | true | readonly | `packages/client/lib/commands/LCS_LEN.ts` | -| `lpos` | false | true | readonly | `packages/client/lib/commands/LPOS_COUNT.ts` | -| `srandmember` | false | true | readonly | `packages/client/lib/commands/SRANDMEMBER_COUNT.ts` | -| `touch` | false | true | readonly, fast | `packages/client/lib/commands/TOUCH.ts` | -| `VLINKS` | false | true | readonly, module, fast | `packages/client/lib/commands/VLINKS_WITHSCORES.ts` | -| `VSIM` | false | true | readonly, module | `packages/client/lib/commands/VSIM_WITHSCORES.ts` | -| `xrevrange` | false | true | readonly | `packages/client/lib/commands/XREVRANGE.ts` | -| `zdiff` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZDIFF_WITHSCORES.ts` | -| `zinter` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZINTER_WITHSCORES.ts` | -| `zrandmember` | false | true | readonly | `packages/client/lib/commands/ZRANDMEMBER_COUNT.ts` | -| `zrandmember` | false | true | readonly | `packages/client/lib/commands/ZRANDMEMBER_COUNT_WITHSCORES.ts` | -| `zrangebyscore` | false | true | readonly | `packages/client/lib/commands/ZRANGEBYSCORE_WITHSCORES.ts` | -| `zrange` | false | true | readonly | `packages/client/lib/commands/ZRANGE_WITHSCORES.ts` | -| `zrank` | false | true | readonly, fast | `packages/client/lib/commands/ZRANK_WITHSCORE.ts` | -| `zunion` | false | true | readonly, movablekeys | `packages/client/lib/commands/ZUNION_WITHSCORES.ts` | -| `topk.query` | false | true | readonly, module | `packages/bloom/lib/commands/top-k/QUERY.ts` | -| `tdigest.byrevrank` | false | true | readonly, module | `packages/bloom/lib/commands/t-digest/BYREVRANK.ts` | -| `tdigest.revrank` | false | true | readonly, module | `packages/bloom/lib/commands/t-digest/REVRANK.ts` | -| `cf.exists` | false | true | readonly, module, fast | `packages/bloom/lib/commands/cuckoo/EXISTS.ts` | - -## NOISE (104) - -server has neither `readonly` nor `write` (admin/conn/pubsub/cluster) — likely by-design - -| Command | Ours IS_READ_ONLY | Server readonly | Server flags | RO ok? | Why | File | -| --- | --- | --- | --- | --- | --- | --- | -| `acl\|cat` | true | false | noscript, loading, stale | Yes | reads static ACL categories | `packages/client/lib/commands/ACL_CAT.ts` | -| `acl\|deluser` | true | false | admin, noscript, loading, stale | No | mutates ACL | `packages/client/lib/commands/ACL_DELUSER.ts` | -| `acl\|dryrun` | true | false | admin, noscript, loading, stale | Yes | simulates, no mutation | `packages/client/lib/commands/ACL_DRYRUN.ts` | -| `acl\|genpass` | true | false | noscript, loading, stale | Yes | pure RNG, node-local | `packages/client/lib/commands/ACL_GENPASS.ts` | -| `acl\|getuser` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_GETUSER.ts` | -| `acl\|list` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_LIST.ts` | -| `acl\|load` | true | false | admin, noscript, loading, stale | No | reloads ACL from file | `packages/client/lib/commands/ACL_LOAD.ts` | -| `acl\|log` | true | false | admin, noscript, loading, stale | Yes | reads ACL security log | `packages/client/lib/commands/ACL_LOG.ts` | -| `acl\|save` | true | false | admin, noscript, loading, stale | No | writes ACL to file | `packages/client/lib/commands/ACL_SAVE.ts` | -| `acl\|setuser` | true | false | admin, noscript, loading, stale | No | mutates ACL | `packages/client/lib/commands/ACL_SETUSER.ts` | -| `acl\|users` | true | false | admin, noscript, loading, stale | Yes | reads ACL | `packages/client/lib/commands/ACL_USERS.ts` | -| `acl\|whoami` | true | false | noscript, loading, stale | Yes | connection identity read | `packages/client/lib/commands/ACL_WHOAMI.ts` | -| `asking` | true | false | fast | Yes | connection-local cluster redirect marker | `packages/client/lib/commands/ASKING.ts` | -| `auth` | true | false | noscript, loading, stale, fast, no_auth, allow_busy | Yes | connection-local auth | `packages/client/lib/commands/AUTH.ts` | -| `bgrewriteaof` | true | false | admin, noscript, no_async_loading | No | triggers AOF rewrite on the node | `packages/client/lib/commands/BGREWRITEAOF.ts` | -| `bgsave` | true | false | admin, noscript, no_async_loading | No | triggers RDB save on the node | `packages/client/lib/commands/BGSAVE.ts` | -| `client\|caching` | true | false | noscript, loading, stale | Yes | connection-local tracking toggle | `packages/client/lib/commands/CLIENT_CACHING.ts` | -| `client\|getname` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_GETNAME.ts` | -| `client\|getredir` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_GETREDIR.ts` | -| `client\|id` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_ID.ts` | -| `client\|info` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_INFO.ts` | -| `client\|kill` | true | false | admin, noscript, loading, stale | No | mutates other connections (admin) | `packages/client/lib/commands/CLIENT_KILL.ts` | -| `client\|list` | true | false | admin, noscript, loading, stale | Yes | reads connections (per-node view) | `packages/client/lib/commands/CLIENT_LIST.ts` | -| `client\|no-evict` | true | false | admin, noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_NO-EVICT.ts` | -| `client\|no-touch` | true | false | noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_NO-TOUCH.ts` | -| `client\|pause` | true | false | admin, noscript, loading, stale | No | pauses command processing (server state) | `packages/client/lib/commands/CLIENT_PAUSE.ts` | -| `client\|setname` | true | false | noscript, loading, stale | Yes | sets own connection name | `packages/client/lib/commands/CLIENT_SETNAME.ts` | -| `client\|tracking` | true | false | noscript, loading, stale | Yes | connection-local toggle | `packages/client/lib/commands/CLIENT_TRACKING.ts` | -| `client\|trackinginfo` | true | false | noscript, loading, stale | Yes | connection-local read | `packages/client/lib/commands/CLIENT_TRACKINGINFO.ts` | -| `client\|unblock` | true | false | admin, noscript, loading, stale | No | mutates another client (admin) | `packages/client/lib/commands/CLIENT_UNBLOCK.ts` | -| `client\|unpause` | true | false | admin, noscript, loading, stale | No | resumes command processing (server state) | `packages/client/lib/commands/CLIENT_UNPAUSE.ts` | -| `cluster\|addslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_ADDSLOTS.ts` | -| `cluster\|addslotsrange` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_ADDSLOTSRANGE.ts` | -| `cluster\|bumpepoch` | true | false | admin, stale, no_async_loading | No | mutates cluster epoch | `packages/client/lib/commands/CLUSTER_BUMPEPOCH.ts` | -| `cluster\|count-failure-reports` | true | false | admin, loading, stale | Yes | reads failure reports | `packages/client/lib/commands/CLUSTER_COUNT-FAILURE-REPORTS.ts` | -| `cluster\|countkeysinslot` | true | false | stale | Yes | reads (per-node) | `packages/client/lib/commands/CLUSTER_COUNTKEYSINSLOT.ts` | -| `cluster\|delslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_DELSLOTS.ts` | -| `cluster\|delslotsrange` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_DELSLOTSRANGE.ts` | -| `cluster\|failover` | true | false | admin, stale, no_async_loading | No | triggers failover | `packages/client/lib/commands/CLUSTER_FAILOVER.ts` | -| `cluster\|flushslots` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_FLUSHSLOTS.ts` | -| `cluster\|forget` | true | false | admin, stale, no_async_loading | No | mutates node set | `packages/client/lib/commands/CLUSTER_FORGET.ts` | -| `cluster\|getkeysinslot` | true | false | stale | Yes | reads keys in slot (per-node) | `packages/client/lib/commands/CLUSTER_GETKEYSINSLOT.ts` | -| `cluster\|info` | true | false | loading, stale | Yes | reads cluster state | `packages/client/lib/commands/CLUSTER_INFO.ts` | -| `cluster\|keyslot` | true | false | loading, stale | Yes | pure hash computation | `packages/client/lib/commands/CLUSTER_KEYSLOT.ts` | -| `cluster\|links` | true | false | loading, stale | Yes | reads links | `packages/client/lib/commands/CLUSTER_LINKS.ts` | -| `cluster\|meet` | true | false | admin, stale, no_async_loading | No | mutates node set | `packages/client/lib/commands/CLUSTER_MEET.ts` | -| `cluster\|myid` | true | false | loading, stale | Yes | reads node id | `packages/client/lib/commands/CLUSTER_MYID.ts` | -| `cluster\|myshardid` | true | false | loading, stale | Yes | reads shard id | `packages/client/lib/commands/CLUSTER_MYSHARDID.ts` | -| `cluster\|nodes` | true | false | loading, stale | Yes | reads topology (per-node view) | `packages/client/lib/commands/CLUSTER_NODES.ts` | -| `cluster\|replicas` | true | false | admin, loading, stale | Yes | reads replicas | `packages/client/lib/commands/CLUSTER_REPLICAS.ts` | -| `cluster\|replicate` | true | false | admin, stale, no_async_loading | No | changes replication target | `packages/client/lib/commands/CLUSTER_REPLICATE.ts` | -| `cluster\|reset` | true | false | admin, noscript, stale | No | resets cluster node | `packages/client/lib/commands/CLUSTER_RESET.ts` | -| `cluster\|saveconfig` | true | false | admin, stale, no_async_loading | No | writes nodes.conf | `packages/client/lib/commands/CLUSTER_SAVECONFIG.ts` | -| `cluster\|set-config-epoch` | true | false | admin, stale, no_async_loading | No | mutates epoch | `packages/client/lib/commands/CLUSTER_SET-CONFIG-EPOCH.ts` | -| `cluster\|setslot` | true | false | admin, stale, no_async_loading | No | mutates slot map | `packages/client/lib/commands/CLUSTER_SETSLOT.ts` | -| `cluster\|slots` | true | false | loading, stale | Yes | reads slot map | `packages/client/lib/commands/CLUSTER_SLOTS.ts` | -| `command` | true | false | loading, stale | Yes | static command metadata, node-local | `packages/client/lib/commands/COMMAND.ts` | -| `command\|count` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_COUNT.ts` | -| `command\|getkeys` | true | false | loading, stale | Yes | pure arg parse | `packages/client/lib/commands/COMMAND_GETKEYS.ts` | -| `command\|getkeysandflags` | true | false | loading, stale | Yes | pure arg parse | `packages/client/lib/commands/COMMAND_GETKEYSANDFLAGS.ts` | -| `command\|info` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_INFO.ts` | -| `command\|list` | true | false | loading, stale | Yes | static metadata | `packages/client/lib/commands/COMMAND_LIST.ts` | -| `config\|get` | true | false | admin, noscript, loading, stale | Yes | reads config (per-node) | `packages/client/lib/commands/CONFIG_GET.ts` | -| `config\|resetstat` | true | false | admin, noscript, loading, stale | No | resets stats counters | `packages/client/lib/commands/CONFIG_RESETSTAT.ts` | -| `config\|rewrite` | true | false | admin, noscript, loading, stale | No | writes config file | `packages/client/lib/commands/CONFIG_REWRITE.ts` | -| `config\|set` | true | false | admin, noscript, loading, stale | No | mutates config | `packages/client/lib/commands/CONFIG_SET.ts` | -| `echo` | true | false | loading, stale, fast | Yes | node-local no-op | `packages/client/lib/commands/ECHO.ts` | -| `function\|dump` | true | false | noscript | Yes | reads function payload | `packages/client/lib/commands/FUNCTION_DUMP.ts` | -| `function\|kill` | true | false | noscript, allow_busy | No | kills running function | `packages/client/lib/commands/FUNCTION_KILL.ts` | -| `function\|stats` | true | false | noscript, allow_busy | Yes | reads runtime (per-node) | `packages/client/lib/commands/FUNCTION_STATS.ts` | -| `hotkeys\|get` | true | false | admin, noscript | Yes | reads hotkey stats | `packages/client/lib/commands/HOTKEYS_GET.ts` | -| `info` | true | false | loading, stale | Yes | reads server stats (per-node) | `packages/client/lib/commands/INFO.ts` | -| `lastsave` | true | false | loading, stale, fast | Yes | reads last-save time (per-node) | `packages/client/lib/commands/LASTSAVE.ts` | -| `latency\|doctor` | true | false | admin, noscript, loading, stale | Yes | reads latency report | `packages/client/lib/commands/LATENCY_DOCTOR.ts` | -| `latency\|graph` | true | false | admin, noscript, loading, stale | Yes | reads latency graph | `packages/client/lib/commands/LATENCY_GRAPH.ts` | -| `latency\|histogram` | true | false | admin, noscript, loading, stale | Yes | reads latency histogram | `packages/client/lib/commands/LATENCY_HISTOGRAM.ts` | -| `latency\|history` | true | false | admin, noscript, loading, stale | Yes | reads latency history | `packages/client/lib/commands/LATENCY_HISTORY.ts` | -| `latency\|latest` | true | false | admin, noscript, loading, stale | Yes | reads latency samples | `packages/client/lib/commands/LATENCY_LATEST.ts` | -| `memory\|doctor` | true | false | | Yes | reads memory report | `packages/client/lib/commands/MEMORY_DOCTOR.ts` | -| `memory\|malloc-stats` | true | false | | Yes | reads allocator stats | `packages/client/lib/commands/MEMORY_MALLOC-STATS.ts` | -| `memory\|stats` | true | false | | Yes | reads memory stats | `packages/client/lib/commands/MEMORY_STATS.ts` | -| `module\|list` | true | false | admin, noscript | Yes | reads loaded modules | `packages/client/lib/commands/MODULE_LIST.ts` | -| `module\|load` | true | false | admin, noscript, no_async_loading | No | loads module (server state) | `packages/client/lib/commands/MODULE_LOAD.ts` | -| `module\|unload` | true | false | admin, noscript, no_async_loading | No | unloads module (server state) | `packages/client/lib/commands/MODULE_UNLOAD.ts` | -| `ping` | true | false | fast | Yes | node-local no-op | `packages/client/lib/commands/PING.ts` | -| `publish` | true | false | pubsub, loading, stale, fast | Yes | keyless; propagates cluster-wide via bus | `packages/client/lib/commands/PUBLISH.ts` | -| `pubsub\|channels` | true | false | pubsub, loading, stale | Yes | reads pubsub state (per-node) | `packages/client/lib/commands/PUBSUB_CHANNELS.ts` | -| `pubsub\|numpat` | true | false | pubsub, loading, stale | Yes | reads pubsub state | `packages/client/lib/commands/PUBSUB_NUMPAT.ts` | -| `pubsub\|numsub` | true | false | pubsub, loading, stale | Yes | reads pubsub state | `packages/client/lib/commands/PUBSUB_NUMSUB.ts` | -| `pubsub\|shardchannels` | true | false | pubsub, loading, stale | Yes | reads shard pubsub state | `packages/client/lib/commands/PUBSUB_SHARDCHANNELS.ts` | -| `pubsub\|shardnumsub` | true | false | pubsub, loading, stale | Yes | reads shard pubsub state | `packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts` | -| `readonly` | true | false | loading, stale, fast | Yes | connection-local cluster mode toggle | `packages/client/lib/commands/READONLY.ts` | -| `readwrite` | true | false | loading, stale, fast | Yes | connection-local cluster mode toggle | `packages/client/lib/commands/READWRITE.ts` | -| `replicaof` | true | false | admin, noscript, stale, no_async_loading | No | changes replication topology | `packages/client/lib/commands/REPLICAOF.ts` | -| `role` | true | false | noscript, loading, stale, fast | Yes | reads role (per-node) | `packages/client/lib/commands/ROLE.ts` | -| `save` | true | false | admin, noscript, no_async_loading, no_multi | No | blocking RDB save | `packages/client/lib/commands/SAVE.ts` | -| `script\|debug` | true | false | noscript | Yes | connection-local debug toggle | `packages/client/lib/commands/SCRIPT_DEBUG.ts` | -| `script\|exists` | true | false | noscript | Yes | reads script cache (per-node) | `packages/client/lib/commands/SCRIPT_EXISTS.ts` | -| `script\|flush` | true | false | noscript | No | flushes script cache | `packages/client/lib/commands/SCRIPT_FLUSH.ts` | -| `script\|kill` | true | false | noscript, allow_busy | No | kills running script | `packages/client/lib/commands/SCRIPT_KILL.ts` | -| `script\|load` | true | false | noscript, stale | No | writes to node script cache; primary needed for EVALSHA | `packages/client/lib/commands/SCRIPT_LOAD.ts` | -| `spublish` | true | false | pubsub, loading, stale, fast | Yes | keyless; shard pubsub | `packages/client/lib/commands/SPUBLISH.ts` | -| `time` | true | false | loading, stale, fast | Yes | reads node clock, node-local | `packages/client/lib/commands/TIME.ts` | -| `wait` | true | false | blocking | No | waits for replica acks; must run on primary | `packages/client/lib/commands/WAIT.ts` | - diff --git a/scripts/readonly-discrepancies.mjs b/scripts/readonly-discrepancies.mjs deleted file mode 100644 index a278019572d..00000000000 --- a/scripts/readonly-discrepancies.mjs +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env node -// Find discrepancies between our IS_READ_ONLY command flag and the server's -// `readonly` command flag (from COMMAND INFO). -// -// Command name is derived from the FILE NAME (not parser.push), then resolved -// against the server by trimming trailing tokens until COMMAND INFO recognizes -// it. This collapses variant files (e.g. ZRANGE_WITHSCORES -> zrange) onto -// their base command without hardcoding a suffix list. -// -// Usage: node scripts/readonly-discrepancies.mjs (SHOW_UNKNOWN=1 to list unknowns) -// Requires a running redis at 127.0.0.1:6379 (redis-cli on PATH). - -import { readFileSync, globSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { basename } from 'node:path'; - -// Module command prefix by package dir (+ bloom subfolder). '' = core, no prefix. -const PACKAGE_PREFIX = { - client: '', - search: 'ft', - json: 'json', - 'time-series': 'ts' -}; -const BLOOM_SUBDIR_PREFIX = { - bloom: 'bf', - cuckoo: 'cf', - 'top-k': 'topk', - 'count-min-sketch': 'cms', - 't-digest': 'tdigest' -}; - -function prefixFor(path) { - const parts = path.split('/'); - const pkg = parts[1]; // packages//lib/commands/... - if (pkg === 'bloom') { - const sub = parts[4]; // packages/bloom/lib/commands//FILE.ts - return BLOOM_SUBDIR_PREFIX[sub] ?? null; - } - return PACKAGE_PREFIX[pkg] ?? null; -} - -// Filenames that glue command + arg with no separator, so token-splitting -// can't recover the real command name. Map file basename -> server name. -const NAME_OVERRIDES = { - INCREXBYFLOAT: 'increx' // pushes INCREX ... BYFLOAT -}; - -const files = globSync('packages/*/lib/commands/**/*.ts', { cwd: process.cwd() }) - .filter(f => !f.endsWith('.spec.ts') && !f.endsWith('index.ts')); - -const commands = []; -for (const f of files) { - const base = basename(f, '.ts'); - // Command files are UPPERCASE (GET, ACL_CAT, ZRANGE_WITHSCORES); skip helpers etc. - if (base !== base.toUpperCase()) continue; - - const prefix = prefixFor(f); - if (prefix === null) continue; // unknown package/subdir - - const roMatch = readFileSync(f, 'utf8').match(/IS_READ_ONLY\s*:\s*(true|false)/); - const isReadOnly = roMatch ? roMatch[1] === 'true' : false; - - // Filename tokens: split on '_', lowercase. Hyphens kept (count-failure-reports). - // Overrides bypass token-splitting for glued names (single token, no prefix). - const override = NAME_OVERRIDES[base]; - const tokens = override ? [override] : base.toLowerCase().split('_'); - commands.push({ file: f, prefix: override ? '' : prefix, tokens, isReadOnly }); -} - -// Build a server name from a prefix + the first `len` filename tokens joined -// by `sep`. Format: `.` for modules, `` for core. -// sep '_' -> matches names with underscores (bitfield_ro, tdigest.trimmed_mean) -// sep '|' -> matches container subcommands (acl|cat, object|encoding) -function nameFor(c, len, sep) { - const joined = c.tokens.slice(0, len).join(sep); - return c.prefix ? `${c.prefix}.${joined}` : joined; -} - -// Batched trim-until-found. Query current-length names for all unresolved -// commands; keep the hits, decrement length on misses, repeat. -function redisCommandInfo(names) { - const raw = execFileSync('redis-cli', ['--json', 'COMMAND', 'INFO', ...names], { - encoding: 'utf8', - maxBuffer: 1024 * 1024 * 64 - }); - return JSON.parse(raw); -} - -let pending = commands.map(c => ({ c, len: c.tokens.length })); -const resolved = new Map(); // command obj -> server info entry -while (pending.length) { - const active = pending.filter(p => p.len >= 1); - if (!active.length) break; - // Try both separators at this length: '_' first (bitfield_ro before bitfield), - // then '|' (acl|cat). Query both in one batch. - const usNames = active.map(p => nameFor(p.c, p.len, '_')); - const barNames = active.map(p => nameFor(p.c, p.len, '|')); - const infos = redisCommandInfo([...usNames, ...barNames]); - const n = active.length; - const next = []; - active.forEach((p, i) => { - const info = infos[i] ?? infos[i + n]; // '_' hit preferred, else '|' - if (info) resolved.set(p.c, info); - else if (p.len > 1) next.push({ c: p.c, len: p.len - 1 }); - // len===1 and still null -> genuinely unknown, drop - }); - pending = next; -} - -const discrepancies = []; -const unknown = []; -for (const c of commands) { - const info = resolved.get(c); - if (!info) { unknown.push(c); continue; } - const flags = info[2] || []; - const serverReadOnly = flags.includes('readonly'); - const serverWrite = flags.includes('write'); - if (serverReadOnly === c.isReadOnly) continue; - - let bucket; - if (c.isReadOnly && serverWrite) bucket = 'BUG_WRITE_AS_RO'; - else if (!c.isReadOnly && serverReadOnly) bucket = 'MISSED_RO'; - else bucket = 'NOISE'; - discrepancies.push({ - command: info[0], - file: c.file, - ours: c.isReadOnly, - server: serverReadOnly, - serverFlags: flags, - bucket - }); -} - -console.log(`\n`); - -const BUCKET_DESC = { - BUG_WRITE_AS_RO: 'we mark IS_READ_ONLY but server flags `write` (cluster would route to a replica)', - MISSED_RO: 'we do not mark IS_READ_ONLY but server flags `readonly` (lost replica routing)', - NOISE: 'server has neither `readonly` nor `write` (admin/conn/pubsub/cluster) — likely by-design' -}; - -// Manual verdict for each NOISE (keyless) command: is IS_READ_ONLY=true -// (i.e. safe to route to a replica / does not require the primary) correct? -// Yes = read-only introspection OR connection/node-local -> replica-safe -// No = mutates server/cluster/replication/persistence state OR needs primary -// Server `readonly` flag is absent for ALL of these because it only tags -// KEYSPACE reads; these are keyless, so the flag says nothing about them. -const NOISE_VERDICT = { - 'acl|cat': ['Yes', 'reads static ACL categories'], - 'acl|deluser': ['No', 'mutates ACL'], - 'acl|dryrun': ['Yes', 'simulates, no mutation'], - 'acl|genpass': ['Yes', 'pure RNG, node-local'], - 'acl|getuser': ['Yes', 'reads ACL'], - 'acl|list': ['Yes', 'reads ACL'], - 'acl|load': ['No', 'reloads ACL from file'], - 'acl|log': ['Yes', 'reads ACL security log'], - 'acl|save': ['No', 'writes ACL to file'], - 'acl|setuser': ['No', 'mutates ACL'], - 'acl|users': ['Yes', 'reads ACL'], - 'acl|whoami': ['Yes', 'connection identity read'], - 'asking': ['Yes', 'connection-local cluster redirect marker'], - 'auth': ['Yes', 'connection-local auth'], - 'bgrewriteaof': ['No', 'triggers AOF rewrite on the node'], - 'bgsave': ['No', 'triggers RDB save on the node'], - 'client|caching': ['Yes', 'connection-local tracking toggle'], - 'client|getname': ['Yes', 'connection-local read'], - 'client|getredir': ['Yes', 'connection-local read'], - 'client|id': ['Yes', 'connection-local read'], - 'client|info': ['Yes', 'connection-local read'], - 'client|kill': ['No', 'mutates other connections (admin)'], - 'client|list': ['Yes', 'reads connections (per-node view)'], - 'client|no-evict': ['Yes', 'connection-local toggle'], - 'client|no-touch': ['Yes', 'connection-local toggle'], - 'client|pause': ['No', 'pauses command processing (server state)'], - 'client|setname': ['Yes', 'sets own connection name'], - 'client|tracking': ['Yes', 'connection-local toggle'], - 'client|trackinginfo': ['Yes', 'connection-local read'], - 'client|unblock': ['No', 'mutates another client (admin)'], - 'client|unpause': ['No', 'resumes command processing (server state)'], - 'cluster|addslots': ['No', 'mutates slot map'], - 'cluster|addslotsrange': ['No', 'mutates slot map'], - 'cluster|bumpepoch': ['No', 'mutates cluster epoch'], - 'cluster|count-failure-reports': ['Yes', 'reads failure reports'], - 'cluster|countkeysinslot': ['Yes', 'reads (per-node)'], - 'cluster|delslots': ['No', 'mutates slot map'], - 'cluster|delslotsrange': ['No', 'mutates slot map'], - 'cluster|failover': ['No', 'triggers failover'], - 'cluster|flushslots': ['No', 'mutates slot map'], - 'cluster|forget': ['No', 'mutates node set'], - 'cluster|getkeysinslot': ['Yes', 'reads keys in slot (per-node)'], - 'cluster|info': ['Yes', 'reads cluster state'], - 'cluster|keyslot': ['Yes', 'pure hash computation'], - 'cluster|links': ['Yes', 'reads links'], - 'cluster|meet': ['No', 'mutates node set'], - 'cluster|myid': ['Yes', 'reads node id'], - 'cluster|myshardid': ['Yes', 'reads shard id'], - 'cluster|nodes': ['Yes', 'reads topology (per-node view)'], - 'cluster|replicas': ['Yes', 'reads replicas'], - 'cluster|replicate': ['No', 'changes replication target'], - 'cluster|reset': ['No', 'resets cluster node'], - 'cluster|saveconfig': ['No', 'writes nodes.conf'], - 'cluster|set-config-epoch': ['No', 'mutates epoch'], - 'cluster|setslot': ['No', 'mutates slot map'], - 'cluster|slots': ['Yes', 'reads slot map'], - 'command': ['Yes', 'static command metadata, node-local'], - 'command|count': ['Yes', 'static metadata'], - 'command|getkeys': ['Yes', 'pure arg parse'], - 'command|getkeysandflags': ['Yes', 'pure arg parse'], - 'command|info': ['Yes', 'static metadata'], - 'command|list': ['Yes', 'static metadata'], - 'config|get': ['Yes', 'reads config (per-node)'], - 'config|resetstat': ['No', 'resets stats counters'], - 'config|rewrite': ['No', 'writes config file'], - 'config|set': ['No', 'mutates config'], - 'echo': ['Yes', 'node-local no-op'], - 'function|dump': ['Yes', 'reads function payload'], - 'function|kill': ['No', 'kills running function'], - 'function|stats': ['Yes', 'reads runtime (per-node)'], - 'hotkeys|get': ['Yes', 'reads hotkey stats'], - 'info': ['Yes', 'reads server stats (per-node)'], - 'lastsave': ['Yes', 'reads last-save time (per-node)'], - 'latency|doctor': ['Yes', 'reads latency report'], - 'latency|graph': ['Yes', 'reads latency graph'], - 'latency|histogram': ['Yes', 'reads latency histogram'], - 'latency|history': ['Yes', 'reads latency history'], - 'latency|latest': ['Yes', 'reads latency samples'], - 'memory|doctor': ['Yes', 'reads memory report'], - 'memory|malloc-stats': ['Yes', 'reads allocator stats'], - 'memory|stats': ['Yes', 'reads memory stats'], - 'module|list': ['Yes', 'reads loaded modules'], - 'module|load': ['No', 'loads module (server state)'], - 'module|unload': ['No', 'unloads module (server state)'], - 'ping': ['Yes', 'node-local no-op'], - 'publish': ['Yes', 'keyless; propagates cluster-wide via bus'], - 'pubsub|channels': ['Yes', 'reads pubsub state (per-node)'], - 'pubsub|numpat': ['Yes', 'reads pubsub state'], - 'pubsub|numsub': ['Yes', 'reads pubsub state'], - 'pubsub|shardchannels': ['Yes', 'reads shard pubsub state'], - 'pubsub|shardnumsub': ['Yes', 'reads shard pubsub state'], - 'readonly': ['Yes', 'connection-local cluster mode toggle'], - 'readwrite': ['Yes', 'connection-local cluster mode toggle'], - 'replicaof': ['No', 'changes replication topology'], - 'role': ['Yes', 'reads role (per-node)'], - 'save': ['No', 'blocking RDB save'], - 'script|debug': ['Yes', 'connection-local debug toggle'], - 'script|exists': ['Yes', 'reads script cache (per-node)'], - 'script|flush': ['No', 'flushes script cache'], - 'script|kill': ['No', 'kills running script'], - 'script|load': ['No', 'writes to node script cache; primary needed for EVALSHA'], - 'spublish': ['Yes', 'keyless; shard pubsub'], - 'time': ['Yes', 'reads node clock, node-local'], - 'wait': ['No', 'waits for replica acks; must run on primary'] -}; - -for (const bucket of ['BUG_WRITE_AS_RO', 'MISSED_RO', 'NOISE']) { - const rows = discrepancies.filter(d => d.bucket === bucket); - console.log(`## ${bucket} (${rows.length})`); - console.log(`\n${BUCKET_DESC[bucket]}\n`); - const noise = bucket === 'NOISE'; - if (noise) { - console.log('| Command | Ours IS_READ_ONLY | Server readonly | Server flags | RO ok? | Why | File |'); - console.log('| --- | --- | --- | --- | --- | --- | --- |'); - } else { - console.log('| Command | Ours IS_READ_ONLY | Server readonly | Server flags | File |'); - console.log('| --- | --- | --- | --- | --- |'); - } - for (const d of rows) { - const cmd = d.command.replaceAll('|', '\\|'); // escape pipe for md cell - if (noise) { - const [ok, why] = NOISE_VERDICT[d.command] ?? ['?', 'UNMAPPED — review']; - console.log( - `| \`${cmd}\` | ${d.ours} | ${d.server} | ${d.serverFlags.join(', ')} | ${ok} | ${why} | \`${d.file}\` |` - ); - } else { - console.log( - `| \`${cmd}\` | ${d.ours} | ${d.server} | ${d.serverFlags.join(', ')} | \`${d.file}\` |` - ); - } - } - console.log(''); -} - -if (process.env.SHOW_UNKNOWN) { - console.log(`\n=== UNKNOWN (${unknown.length}) ===`); - for (const u of unknown) { - console.log(`${nameFor(u, u.tokens.length, '_')} ${u.file}`); - } -} From 02b56d09632ac7c44c68a712b82c8ec6cdfb31ef Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:13:01 +0300 Subject: [PATCH 49/54] fix(client): widen the served client through unknown at the hook boundary The MOVED/ASK attribution cast compared this instantiation's client type against the plan's erased base client type; a clean (non- incremental) tsc build rejects both the comparison and the direct cast. Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 1889026a9ab..ee88b2788a6 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -613,9 +613,12 @@ export default class RedisCluster< } // Attribute the reply to the node that actually served it (MOVED/ASK may - // have redirected away from the plan's original target). - const hookPlan = served.client && served.client !== plan[0].client - ? [{ ...plan[0], client: served.client }] + // have redirected away from the plan's original target). The plan carries + // the erased base client type (routing runs below the typed surface), so + // widen this instantiation's client back at the boundary. + const servedClient = served.client as unknown as typeof plan[0]['client']; + const hookPlan = servedClient && servedClient !== plan[0].client + ? [{ ...plan[0], client: servedClient }] : plan; // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the From a7dac4b391d3a2f20a7b6a38a2c8ccd2d3ffed07 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:19:51 +0300 Subject: [PATCH 50/54] refactor(client): dedupe policy-layer helpers and prune dead resolver API - five reduce* wrappers collapse into a reduceWith lift - command-name uppercase gates share upperCommand (dispatch, ft-cursor, scan-cursor) - module/command dot-split shares parseCommandName (resolver + dynamic factory) - default-policy synthesis shares defaultCommandPolicies (cluster fallback + dynamic factory) - PolicyResolver.withFallback removed (zero production callers; the constructor fallback stays) along with the two never-produced PolicyResult error variants Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 13 +----- .../request-response-policies/dispatch.ts | 38 +++++----------- .../request-response-policies/ft-cursor.ts | 14 ++++-- .../request-response-policies/scan-cursor.ts | 4 +- .../dynamic-policy-resolver-factory.ts | 44 +++---------------- packages/client/lib/command-metadata/index.ts | 4 +- .../command-metadata/policies-constants.ts | 13 ++++++ .../static-metadata-resolver.spec.ts | 4 +- .../static-metadata-resolver.ts | 22 +++------- packages/client/lib/command-metadata/types.ts | 23 +++++----- 10 files changed, 69 insertions(+), 110 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index ee88b2788a6..ca76567d09c 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,7 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { defaultCommandMetadata, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; +import { defaultCommandMetadata, defaultCommandPolicies, isReplicaSafe, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandMetadata } from '../command-metadata'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS, NUMERIC_AGG_POLICIES, remapAggregateReply } from './request-response-policies/dispatch'; import calculateSlot from 'cluster-key-slot'; import { finalizeFtCursor } from './request-response-policies/ft-cursor'; @@ -523,18 +523,9 @@ export default class RedisCluster< // passed through) rather than failing. Scripts/functions are single-slot // by contract, so default-keyed is always correct for them. Known // multi_shard commands that can't be split still throw from the splitter. - const hasKeys = parser.keys.length > 0; const policy: CommandMetadata = policyResult.ok ? policyResult.value - : { - request: hasKeys - ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - response: hasKeys - ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - isKeyless: !hasKeys - }; + : defaultCommandPolicies(parser.keys.length === 0); // Override-first: a defined `IS_READ_ONLY` (command definition, script, or // the raw `sendCommand` caller argument threaded in as `isReadonly`) wins; diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index c73cf557495..9d885ded989 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -21,7 +21,7 @@ import { type RequestPolicyWithDefaults, type ResponsePolicyWithDefaults } from '../../command-metadata/policies-constants'; -import { routeFtCursor } from './ft-cursor'; +import { routeFtCursor, upperCommand } from './ft-cursor'; import { routeScan } from './scan-cursor'; // Routing runs *below* the typed command surface: routers never inspect the @@ -121,8 +121,8 @@ export const routeDefaultKeyed: RequestRouter = * preserves the caller's casing, so normalize before matching. */ function specialKey(parser: CommandParser): string { - const { command, subcommand } = parser.commandIdentifier; - const c = command.toUpperCase(); + const { subcommand } = parser.commandIdentifier; + const c = upperCommand(parser); return subcommand ? `${c} ${subcommand.toUpperCase()}` : c; } @@ -179,30 +179,16 @@ export const reduceAllSucceeded = async (promises: Promise[]): Promise return responses[0]; }; -export const reduceLogicalAnd = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return aggregateLogicalAnd(responses) as T; -}; +/** Lifts a synchronous all-replies aggregator into a response reducer. */ +const reduceWith = (aggregate: (replies: Array) => unknown) => + async (promises: Promise[]): Promise => + aggregate(await Promise.all(promises)) as T; -export const reduceLogicalOr = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return aggregateLogicalOr(responses) as T; -}; - -export const reduceMin = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return aggregateMin(responses) as T; -}; - -export const reduceMax = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return aggregateMax(responses) as T; -}; - -export const reduceSum = async (promises: Promise[]): Promise => { - const responses = await Promise.all(promises); - return aggregateSum(responses) as T; -}; +export const reduceLogicalAnd = reduceWith(aggregateLogicalAnd); +export const reduceLogicalOr = reduceWith(aggregateLogicalOr); +export const reduceMin = reduceWith(aggregateMin); +export const reduceMax = reduceWith(aggregateMax); +export const reduceSum = reduceWith(aggregateSum); /** * RANDOMKEY under `all_shards`: each master returns a random key from its own diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts index 9149fb5c503..745f2f82e6d 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -12,6 +12,14 @@ export function argToString(arg: RedisArgument): string { return typeof arg === 'string' ? arg : arg.toString(); } +/** + * Uppercased wire command name for command-name gates — `commandIdentifier` + * preserves the caller's casing, so normalize before matching. + */ +export function upperCommand(parser: CommandParser): string { + return parser.commandIdentifier.command.toUpperCase(); +} + /** * Pull the continuation cursor out of an FT.AGGREGATE …WITHCURSOR / * FT.CURSOR READ reply, across every reply path: @@ -132,8 +140,7 @@ export function finalizeFtCursor( plan: ReadonlyArray, reply: unknown ): unknown { - const { command, subcommand } = parser.commandIdentifier; - const cmd = command.toUpperCase(); + const cmd = upperCommand(parser); if (cmd !== 'FT.AGGREGATE' && cmd !== 'FT.CURSOR') return reply; if (plan.length !== 1) return reply; @@ -161,7 +168,8 @@ export function finalizeFtCursor( // FT.CURSOR READ / DEL — the caller-held token is at arg 3 (the routed // sub-parser carries the real id; this hook receives the original parser). - const sub = subcommand === undefined ? undefined : argToString(subcommand).toUpperCase(); + const { subcommand } = parser.commandIdentifier; + const sub = subcommand === undefined ? undefined : subcommand.toUpperCase(); const token = argToString(redisArgs[3]); if (sub === 'DEL') { diff --git a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts index f1a3e6d8e4f..8c1f6f739b8 100644 --- a/packages/client/lib/cluster/request-response-policies/scan-cursor.ts +++ b/packages/client/lib/cluster/request-response-policies/scan-cursor.ts @@ -1,7 +1,7 @@ import { BasicCommandParser, type CommandParser } from '../../client/parser'; import type { RedisArgument } from '../../RESP/types'; import type { RequestRouter, RoutedCommand } from './dispatch'; -import { argToString } from './ft-cursor'; +import { argToString, upperCommand } from './ft-cursor'; // Routing/finalization runs below the typed command surface (see dispatch.ts), // so the slots handle is the erased base instantiation. `_executeWithPolicies` @@ -90,7 +90,7 @@ export function finalizeScanCursor( plan: ReadonlyArray, reply: unknown ): unknown { - if (parser.commandIdentifier.command.toUpperCase() !== 'SCAN') return reply; + if (upperCommand(parser) !== 'SCAN') return reply; if (plan.length !== 1 || !plan[0].client) return reply; const serverCursor = extractScanCursor(reply); diff --git a/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts b/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts index 735d94c96ec..50d256ffc9f 100644 --- a/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/command-metadata/dynamic-policy-resolver-factory.ts @@ -1,7 +1,7 @@ import type { CommandReply } from '../commands/generic-transformers'; import type { CommandMetadata } from './policies-constants'; -import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; -import type { PolicyResolver, ModuleMetadataRecords } from './types'; +import { REQUEST_POLICIES_WITH_DEFAULTS, defaultCommandPolicies } from './policies-constants'; +import { parseCommandName, type PolicyResolver, type ModuleMetadataRecords } from './types'; import { StaticMetadataResolver } from './static-metadata-resolver'; /** @@ -45,7 +45,7 @@ export class DynamicPolicyResolverFactory { const policies: ModuleMetadataRecords = {}; for (const command of commands) { - const parsed = DynamicPolicyResolverFactory.#parseCommandName(command.name); + const parsed = parseCommandName(command.name); // Skip commands with invalid format (more than one dot) if (!parsed) { @@ -67,30 +67,6 @@ export class DynamicPolicyResolverFactory { return policies; } - /** - * 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 CommandMetadata for a command based on its characteristics. * @@ -103,14 +79,8 @@ export class DynamicPolicyResolverFactory { // 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; - + const defaults = defaultCommandPolicies(isKeyless); + let subcommands: Record | undefined; if(command.subcommands.length > 0) { subcommands = {}; @@ -127,11 +97,11 @@ export class DynamicPolicyResolverFactory { } } - const request = command.policies.request ?? defaultRequest; + const request = command.policies.request ?? defaults.request; return { request, - response: command.policies.response ?? defaultResponse, + response: command.policies.response ?? defaults.response, isKeyless, // Mirror the raw server signals verbatim. Derivation (replica-safety, // CSC eligibility) is NOT precomputed here — it lives in the diff --git a/packages/client/lib/command-metadata/index.ts b/packages/client/lib/command-metadata/index.ts index 672a61bd03b..f5c1e80011c 100644 --- a/packages/client/lib/command-metadata/index.ts +++ b/packages/client/lib/command-metadata/index.ts @@ -14,7 +14,7 @@ import { COMMAND_METADATA } from './command-metadata-data'; * Process-wide resolver over the generated static metadata table. The table is * static generated data, so a single shared instance serves every client * (standalone, cluster, sentinel, pool) — no constructor threading required. - * Kept injectable (`withFallback`) so a future per-connection dynamic resolver - * built from each server's own `COMMAND` reply can override it. + * A future per-connection dynamic resolver built from each server's own + * `COMMAND` reply can chain to this one via the constructor's fallback. */ export const defaultCommandMetadata = new StaticMetadataResolver(COMMAND_METADATA); diff --git a/packages/client/lib/command-metadata/policies-constants.ts b/packages/client/lib/command-metadata/policies-constants.ts index efc66459d80..5acf2504941 100644 --- a/packages/client/lib/command-metadata/policies-constants.ts +++ b/packages/client/lib/command-metadata/policies-constants.ts @@ -110,6 +110,19 @@ export const RESPONSE_POLICIES_WITH_DEFAULTS = { export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof typeof RESPONSE_POLICIES_WITH_DEFAULTS]; +/** + * The default policies for a command without request/response tips, per the + * command-tips spec: keyed → single shard by hash slot with order-preserving + * replies; keyless → arbitrary shard, replies passed through/merged. Shared + * by the dynamic resolver factory (table construction) and the cluster + * dispatch fallback for commands unknown to the resolver. + */ +export function defaultCommandPolicies(isKeyless: boolean): Pick { + return isKeyless + ? { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, isKeyless: true } + : { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, isKeyless: false }; +} + export interface CommandMetadata { readonly request: RequestPolicyWithDefaults; readonly response: ResponsePolicyWithDefaults; diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts index 92229ad99cf..b3555c57a10 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts @@ -195,7 +195,7 @@ describe('StaticMetadataResolver', () => { }); describe('fallback', () => { - it('falls back to provided resolver on unknown command', () => { + it('falls back to the constructor-provided resolver on unknown command', () => { const fallback = new StaticMetadataResolver({ std: { customping: { @@ -205,7 +205,7 @@ describe('StaticMetadataResolver', () => { } } }); - const chained = resolver.withFallback(fallback); + const chained = new StaticMetadataResolver(undefined, fallback); const r = chained.resolvePolicy({ command: 'customping', subcommand: undefined }); assert.equal(r.ok, true); }); diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.ts b/packages/client/lib/command-metadata/static-metadata-resolver.ts index 288a7c6f17d..47e7e123163 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.ts @@ -1,4 +1,4 @@ -import type { PolicyResult, PolicyResolver, ModuleMetadataRecords, CommandMetadataRecords } from './types'; +import { parseCommandName, type PolicyResult, type PolicyResolver, type ModuleMetadataRecords, type CommandMetadataRecords } from './types'; import { COMMAND_METADATA } from './command-metadata-data'; import { CommandIdentifier } from '../client/parser'; import type { CommandMetadata } from './policies-constants'; @@ -37,17 +37,7 @@ export class StaticMetadataResolver implements PolicyResolver { } /** - * 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 StaticMetadataResolver with the specified fallback - */ - withFallback(fallbackResolver: PolicyResolver): StaticMetadataResolver { - return new StaticMetadataResolver(this.policies, fallbackResolver); - } - - /** - * Convenience over `resolvePolicy` for the resolve-then-fallback readers: + * Convenience over `resolvePolicy` for the override-first predicate readers: * returns the resolved metadata or `undefined` on any miss. */ lookup(commandIdentifier: CommandIdentifier): CommandMetadata | undefined { @@ -56,15 +46,13 @@ export class StaticMetadataResolver implements PolicyResolver { } resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { - const parts = commandIdentifier.command.toLowerCase().split('.'); + const parsed = parseCommandName(commandIdentifier.command.toLowerCase()); - if (parts.length > 2) { + if (!parsed) { return { ok: false, error: 'wrong-command-or-module-name' }; } - const [moduleName, commandName] = parts.length === 1 - ? ['std', parts[0]] - : parts; + const { moduleName, commandName } = parsed; if (!this.policies[moduleName]) { if (this.fallbackResolver) { diff --git a/packages/client/lib/command-metadata/types.ts b/packages/client/lib/command-metadata/types.ts index 863fef1697b..c4f7c48aa7b 100644 --- a/packages/client/lib/command-metadata/types.ts +++ b/packages/client/lib/command-metadata/types.ts @@ -5,8 +5,7 @@ export type Either = | { readonly ok: true; readonly value: TOk } | { readonly ok: false; readonly error: TError }; -export type PolicyResult = Either; - +export type PolicyResult = Either; export interface PolicyResolver { /** @@ -16,18 +15,22 @@ export interface PolicyResolver { /** * Convenience over `resolvePolicy`: returns the resolved metadata or - * `undefined` on any miss, for the resolve-then-fallback predicates + * `undefined` on any miss, for the override-first predicates * (`isReplicaSafe(resolver.lookup(id), command.IS_READ_ONLY)`). */ lookup(commandIdentifier: CommandIdentifier): CommandMetadata | undefined; +} - /** - * 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; +/** + * Parses a `COMMAND`-style command name into its module/command parts: + * `"ping"` → `std.ping`, `"ft.search"` → `ft.search`. More than one dot is + * invalid in Redis → `undefined`. Callers own case normalization. + */ +export function parseCommandName(fullCommandName: string): { moduleName: string; commandName: string } | undefined { + const parts = fullCommandName.split('.'); + if (parts.length === 1) return { moduleName: 'std', commandName: fullCommandName }; + if (parts.length === 2) return { moduleName: parts[0], commandName: parts[1] }; + return undefined; } export type CommandMetadataRecords = Record; From 689af4d5a3947017803d694b636a53c10b4aa6db Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 20 Jul 2026 23:21:41 +0300 Subject: [PATCH 51/54] fix(client): harden policy dispatch and resolver lookups - aggregateMerge handles plain-object replies (RESP3 maps under the default type mapping) instead of throwing after all nodes executed - the response reducer is validated before any per-node promise is dispatched, so an unknown policy no longer orphans in-flight rejections - resolver lookup tables are null-prototype: command names like 'constructor' or '__proto__' miss the table instead of resolving an Object.prototype member as metadata Co-Authored-By: Claude Fable 5 --- packages/client/lib/cluster/index.ts | 11 +++++---- .../generic-aggregators.spec.ts | 23 ++++++++++++++++++- .../generic-aggregators.ts | 11 ++++++++- .../static-metadata-resolver.spec.ts | 13 +++++++++++ .../static-metadata-resolver.ts | 9 +++++--- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index ca76567d09c..b027ed6aecf 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -554,6 +554,13 @@ export default class RedisCluster< if (!router) { throw new Error(`Unknown request policy ${requestPolicy}`); } + // Validated before any per-node promise is dispatched — throwing after + // would orphan in-flight rejections (unhandled-rejection noise) and run + // side effects for a reply that can never be reduced. + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); + } // Routers are typed against the erased base cluster types (routing is // below the typed command surface); bridge this instantiation's slots in. const plan = await router( @@ -593,10 +600,6 @@ export default class RedisCluster< ); }); - const reducer = RESPONSE_REDUCERS[responsePolicy]; - if (!reducer) { - throw new Error(`Unknown response policy ${responsePolicy}`); - } const positionHints = plan.map(entry => entry.groupIndices); let reply = await (reducer(responsePromises, parser, positionHints) as Promise); if (numericAgg) { diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts index de7b0dfcc8f..d77e5edc350 100644 --- a/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.spec.ts @@ -1,5 +1,5 @@ import { strict as assert } from 'node:assert'; -import { aggregateLogicalAnd, aggregateLogicalOr } from './generic-aggregators'; +import { aggregateLogicalAnd, aggregateLogicalOr, aggregateMerge } from './generic-aggregators'; describe('aggregateLogicalOr', () => { it('ORs element-wise across shards', () => { @@ -19,6 +19,27 @@ describe('aggregateLogicalOr', () => { }); }); +describe('aggregateMerge', () => { + it('merges array replies with dedup', () => { + assert.deepEqual(aggregateMerge([['a', 'b'], ['b', 'c']]), ['a', 'b', 'c']); + }); + + it('merges Map replies (last node wins per key)', () => { + const merged = aggregateMerge>([ + new Map([['a', 1], ['b', 1]]), + new Map([['b', 2]]) + ]); + assert.deepEqual([...merged.entries()], [['a', 1], ['b', 2]]); + }); + + it('merges plain-object replies (RESP3 maps under the default type mapping)', () => { + assert.deepEqual( + aggregateMerge([{ a: 1, b: 1 }, { b: 2 }]), + { a: 1, b: 2 } + ); + }); +}); + describe('aggregateLogicalAnd', () => { it('ANDs element-wise across shards (SCRIPT EXISTS)', () => { assert.deepEqual(aggregateLogicalAnd([[1, 1, 0], [1, 0, 0]]), [1, 0, 0]); diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts index c8d5deb3cc2..d5fcedd552f 100644 --- a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -128,7 +128,6 @@ export const aggregateMerge = (replies: Array): T => { 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) { @@ -139,6 +138,16 @@ export const aggregateMerge = (replies: Array): T => { return map as T; } + // RESP3 map replies decode to plain objects under the default type + // mapping; merge them like the Map branch (last node wins per key). + if(typeof firstReply === 'object' && firstReply !== null) { + const merged: Record = {}; + for(const reply of replies) { + Object.assign(merged, reply); + } + return merged as T; + } + throw new Error('Unsupported reply type for merge aggregation'); }; diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts index b3555c57a10..e9ea9f7539c 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts @@ -192,6 +192,19 @@ describe('StaticMetadataResolver', () => { assert.equal(r.ok, false); if (!r.ok) assert.equal(r.error, 'wrong-command-or-module-name'); }); + + it('Object.prototype member names miss the table instead of resolving', () => { + for (const name of ['constructor', '__proto__', 'hasOwnProperty', 'toString']) { + const r = resolver.resolvePolicy({ command: name, subcommand: undefined }); + assert.equal(r.ok, false, name); + if (!r.ok) assert.equal(r.error, 'unknown-command', name); + } + // Unknown subcommand named like a prototype member falls back to the + // container's policy instead of resolving a Function as metadata. + const r = resolver.resolvePolicy({ command: 'config', subcommand: 'constructor' }); + assert.equal(r.ok, true); + if (r.ok) assert.equal(typeof r.value, 'object'); + }); }); describe('fallback', () => { diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.ts b/packages/client/lib/command-metadata/static-metadata-resolver.ts index 47e7e123163..29dcaa628a8 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.ts @@ -3,9 +3,12 @@ import { COMMAND_METADATA } from './command-metadata-data'; import { CommandIdentifier } from '../client/parser'; import type { CommandMetadata } from './policies-constants'; +// The rebuilt lookup tables use null-prototype objects: command names come +// off the wire, and a name like "constructor" or "__proto__" must miss the +// table instead of resolving an Object.prototype member as metadata. const lowercaseCommandMetadata = (metadata: CommandMetadata): CommandMetadata => { if (!metadata.subcommands) return metadata; - const subcommands: Record = {}; + const subcommands: Record = Object.create(null); for (const [name, sub] of Object.entries(metadata.subcommands)) { subcommands[name.toLowerCase()] = lowercaseCommandMetadata(sub); } @@ -13,9 +16,9 @@ const lowercaseCommandMetadata = (metadata: CommandMetadata): CommandMetadata => }; const lowercaseModuleMetadata = (metadata: ModuleMetadataRecords): ModuleMetadataRecords => { - const out: ModuleMetadataRecords = {}; + const out: ModuleMetadataRecords = Object.create(null); for (const [moduleName, commands] of Object.entries(metadata)) { - const normalized: CommandMetadataRecords = {}; + const normalized: CommandMetadataRecords = Object.create(null); for (const [commandName, entry] of Object.entries(commands)) { normalized[commandName.toLowerCase()] = lowercaseCommandMetadata(entry); } From 7d0de80af10d1f39f3de0884636a045e6888e422 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 24 Jul 2026 11:46:27 +0300 Subject: [PATCH 52/54] refactor(client): drop vestigial script flag from CSC eligibility The CSC command-eligibility HLD was revised to reference only the `script_runner` command flag (Redis 8.10) and dropped the earlier provisional `script` flag name. `isCacheable` checked both; the bare `script` clause was dead (the server ships `script_runner`, and no test covered it). Prune to `script_runner` only and update the comments. No behavior change: EVAL_RO/EVALSHA_RO/FCALL_RO stay non-cacheable via `script_runner`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/command-metadata/predicates.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/command-metadata/predicates.ts b/packages/client/lib/command-metadata/predicates.ts index 26d15f96777..b3dcdc86b67 100644 --- a/packages/client/lib/command-metadata/predicates.ts +++ b/packages/client/lib/command-metadata/predicates.ts @@ -60,7 +60,7 @@ export function isReplicaSafe( * key-tracking based, so keyless read-only commands like KEYS must not cache), * - no `nondeterministic_output` tip (value nondeterminism; `*_output_order` * is fine — HGETALL/SMEMBERS stay cacheable), - * - no `script` / `script_runner` flag (EVAL_RO/EVALSHA_RO/FCALL_RO). + * - no `script_runner` flag (EVAL_RO/EVALSHA_RO/FCALL_RO). * * Unknown commands with no declared intent are not cacheable. */ @@ -75,8 +75,7 @@ export function isCacheable( && meta.flags.includes('readonly') && !meta.isKeyless && !tips.includes('nondeterministic_output') - // `script` (HLD name) / `script_runner` (the flag Redis 8.10 ships) mark the - // EVAL_RO/EVALSHA_RO/FCALL_RO family, which must not cache. - && !meta.flags.includes('script') + // `script_runner` (Redis 8.10) marks the EVAL_RO/EVALSHA_RO/FCALL_RO family, + // which must not cache. && !meta.flags.includes('script_runner'); } From 8f70362982a561861d64e94c391068f01e1ea743 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 24 Jul 2026 11:46:34 +0300 Subject: [PATCH 53/54] chore(client): regenerate command metadata from Redis 8.9.241 Regenerate the static command-metadata table against a live Redis 8.9.241 dev build (localhost:3000) with all bundled modules loaded. - Adds 5 commands (421 -> 426): HIMPORT (+subcommands), BACKUP container, LMOVEM, BLMOVEM, QUERYLABELS. All classify correctly (write commands non-cacheable/non-replica-safe; QUERYLABELS readonly+dont_cache+keyless non-cacheable). - Drops the stale `dont_cache` tip on TOUCH: the server no longer tags it. TOUCH stays non-cacheable via its override-first `CACHEABLE: false`. No existing command policies changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../command-metadata/command-metadata-data.ts | 144 +++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/command-metadata/command-metadata-data.ts b/packages/client/lib/command-metadata/command-metadata-data.ts index 74f9fadc053..c2d67dcd48d 100644 --- a/packages/client/lib/command-metadata/command-metadata-data.ts +++ b/packages/client/lib/command-metadata/command-metadata-data.ts @@ -1,5 +1,5 @@ // This file is auto-generated by scripts/generate-command-metadata-data.ts — do not edit manually. -// Source: Redis 255.255.255, 421 commands. +// Source: Redis 8.9.241, 426 commands. import { ModuleMetadataRecords } from "./types"; export const COMMAND_METADATA: ModuleMetadataRecords = { @@ -1383,6 +1383,77 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "allow_busy" ] }, + "backup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "abort": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "cleanup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "loading", + "stale" + ] + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale" + ] + }, + "seal": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "start": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "noscript" + ] + }, + "status": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "admin", + "stale" + ] + } + } + }, "bgrewriteaof": { "request": "default-keyless", "response": "default-keyless", @@ -1456,6 +1527,16 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "blocking" ] }, + "blmovem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom", + "blocking" + ] + }, "blmpop": { "request": "default-keyed", "response": "default-keyed", @@ -2834,6 +2915,43 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "fast" ] }, + "himport": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [], + "subcommands": { + "discard": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true, + "flags": [] + }, + "discardall": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true, + "flags": [] + }, + "prepare": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "denyoom" + ] + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + } + } + }, "hincrby": { "request": "default-keyed", "response": "default-keyed", @@ -3298,6 +3416,15 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "denyoom" ] }, + "lmovem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false, + "flags": [ + "write", + "denyoom" + ] + }, "lmpop": { "request": "default-keyed", "response": "default-keyed", @@ -4584,9 +4711,6 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "readonly", "fast" ], - "tips": [ - "dont_cache" - ], "keySpecs": [ { "beginSearch": { @@ -5669,6 +5793,18 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "dont_cache" ] }, + "querylabels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "flags": [ + "readonly", + "module" + ], + "tips": [ + "dont_cache" + ] + }, "range": { "request": "default-keyed", "response": "default-keyed", From ded408fd00b7e69440dfa4553570ed819b0d8c75 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 27 Jul 2026 16:12:26 +0300 Subject: [PATCH 54/54] feat(client): add HIMPORT command family with managed fieldset lifecycle Adds first-class support for the Redis 8.10 HIMPORT command family (hinted hash templates): HIMPORT PREPARE/SET/DISCARD/DISCARDALL, exposed as hImportPrepare/hImportSet/hImportDiscard/hImportDiscardAll and marked @experimental. Fieldsets are server-side session state scoped to one physical connection, so the client owns the fieldset lifecycle: a shared FieldsetRegistry records registrations on the logical client, and a per-connection hook transparently pipelines PREPARE in front of SET on sessions that lack the fieldset or hold a stale field list, replays pending discards before dependent commands, and retries a SET once on 'no such fieldset' for registered fieldsets. DISCARD/DISCARDALL replies are registry-based, giving deterministic semantics across topologies. Cluster routing needs no HIMPORT-specific code: the policy layer fans the session commands to all masters from server command tips. Response policies are pinned client-side (all_succeeded for PREPARE, agg_max for the discards) because the server defines no response policy and the default fan-out reducer rejects scalar replies from multiple masters. Co-Authored-By: Claude Fable 5 --- packages/client/lib/client/index.ts | 244 ++++++++++++++++++ packages/client/lib/client/pool.ts | 7 + packages/client/lib/cluster/cluster-slots.ts | 8 + .../command-metadata/command-metadata-data.ts | 6 +- .../static-metadata-resolver.spec.ts | 30 +++ .../lib/commands/HIMPORT_DISCARD.spec.ts | 36 +++ .../client/lib/commands/HIMPORT_DISCARD.ts | 9 + .../lib/commands/HIMPORT_DISCARDALL.spec.ts | 36 +++ .../client/lib/commands/HIMPORT_DISCARDALL.ts | 9 + .../lib/commands/HIMPORT_PREPARE.spec.ts | 50 ++++ .../client/lib/commands/HIMPORT_PREPARE.ts | 11 + .../client/lib/commands/HIMPORT_SET.spec.ts | 63 +++++ packages/client/lib/commands/HIMPORT_SET.ts | 13 + packages/client/lib/commands/index.ts | 114 ++++++++ packages/client/lib/himport/registry.spec.ts | 178 +++++++++++++ packages/client/lib/himport/registry.ts | 198 ++++++++++++++ .../client/lib/himport/transparency.spec.ts | 209 +++++++++++++++ packages/client/lib/sentinel/index.ts | 5 + .../scripts/command-metadata-overrides.ts | 19 ++ 19 files changed, 1242 insertions(+), 3 deletions(-) create mode 100644 packages/client/lib/commands/HIMPORT_DISCARD.spec.ts create mode 100644 packages/client/lib/commands/HIMPORT_DISCARD.ts create mode 100644 packages/client/lib/commands/HIMPORT_DISCARDALL.spec.ts create mode 100644 packages/client/lib/commands/HIMPORT_DISCARDALL.ts create mode 100644 packages/client/lib/commands/HIMPORT_PREPARE.spec.ts create mode 100644 packages/client/lib/commands/HIMPORT_PREPARE.ts create mode 100644 packages/client/lib/commands/HIMPORT_SET.spec.ts create mode 100644 packages/client/lib/commands/HIMPORT_SET.ts create mode 100644 packages/client/lib/himport/registry.spec.ts create mode 100644 packages/client/lib/himport/registry.ts create mode 100644 packages/client/lib/himport/transparency.spec.ts diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 20197ca9856..d8644a3d3ad 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -26,9 +26,35 @@ import { ClientMetricsHandle, ClientRegistry } from '../opentelemetry'; import { ClientIdentity, ClientRole, generateClientId } from './identity'; import { trace, sanitizeArgs, publish, CHANNELS, type CommandTraceContext } from './tracing'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; +import { FieldsetRegistry, PreparedFieldsets } from '../himport/registry'; +import HIMPORT_DISCARD from '../commands/HIMPORT_DISCARD'; +import HIMPORT_DISCARDALL from '../commands/HIMPORT_DISCARDALL'; +import HIMPORT_PREPARE from '../commands/HIMPORT_PREPARE'; +import HIMPORT_SET from '../commands/HIMPORT_SET'; const noop = () => {}; +const HIMPORT_SESSION_SUBCOMMANDS = new Set(['PREPARE', 'DISCARD', 'DISCARDALL']); + +/** + * MULTI/pipeline stores raw args only, so the HIMPORT transparency hook never sees these + * commands — a PREPARE/DISCARD executed that way would silently diverge the client registry + * from server state (a fieldset the registry doesn't know about, or a discarded one it + * would resurrect via lazy prepare). Rejected client-side at the exec funnel, which covers + * both the typed multi methods and raw `multi.addCommand(...)`. HIMPORT SET stays allowed: + * it mutates no registry state (the fieldset must already exist on the carrying connection). + */ +function assertNoHimportSessionCommands(commands: Array) { + for (const { args } of commands) { + if (String(args[0]).toUpperCase() !== 'HIMPORT') continue; + if (HIMPORT_SESSION_SUBCOMMANDS.has(String(args[1]).toUpperCase())) { + throw new Error( + 'HIMPORT PREPARE/DISCARD/DISCARDALL are not supported inside MULTI/pipeline; call them on the client before the transaction' + ); + } + } +} + export interface RedisClientOptions< M extends RedisModules = RedisModules, F extends RedisFunctions = RedisFunctions, @@ -162,6 +188,14 @@ export interface RedisClientOptions< * ``` */ clientSideCache?: ClientSideCacheProvider | ClientSideCacheConfig; + /** + * @internal + * Shared HIMPORT fieldset registry. Pool/cluster/sentinel construct one instance and inject + * it into every client they own so a fieldset registered on the logical client can be + * transparently re-prepared on any physical connection; `duplicate()` injects the parent's. + * Not a user-facing option — when omitted the client owns a fresh registry. + */ + himportRegistry?: FieldsetRegistry; /** * If set to true, disables sending client identifier (user-agent like message) to the redis server */ @@ -550,6 +584,10 @@ export default class RedisClient< #dirtyWatch?: string; #watchEpoch?: number; #clientSideCache?: ClientSideCacheProvider; + // What the user registered on the logical client (shared across pooled/cluster/sentinel + // clients via the himportRegistry option) vs. what THIS socket's server session holds. + #himportRegistry: FieldsetRegistry; + #preparedFieldsets = new PreparedFieldsets(); #credentialsSubscription: Disposable | null = null; // Flag used to pause writing to the socket during maintenance windows. // When true, prevents new commands from being written while waiting for: @@ -689,6 +727,7 @@ export default class RedisClient< this.#queue = this.#initiateQueue(this.#clientIdentity.id); this.#socket = this.#initiateSocket(this.#clientIdentity.id); + this.#himportRegistry = this.#options.himportRegistry ?? new FieldsetRegistry(); this.#registerForMetrics(); @@ -982,6 +1021,10 @@ export default class RedisClient< .on('error', err => { this.emit('error', err); this.#clientSideCache?.onError(); + // Session state died with the socket. Cleared HERE (not on re-ready): a hImportSet + // issued between disconnect and re-ready would otherwise see a live-looking entry, + // skip the PREPARE injection, and replay bare onto the new socket's empty session. + this.#preparedFieldsets.clear(); if (this.#socket.isOpen && !this.#options.disableOfflineQueue) { this.#queue.flushWaitingForReply(err); } else { @@ -1142,6 +1185,9 @@ export default class RedisClient< >(overrides?: Partial>) { return new (Object.getPrototypeOf(this).constructor)({ ...this._self.#options, + // The options spread only carries the registry if the user originally passed one — + // inject the live instance explicitly: a duplicate SHARES the parent's registrations. + himportRegistry: this._self.#himportRegistry, commandOptions: this._commandOptions, ...overrides }) as RedisClientType<_M, _F, _S, _RESP, _TYPE_MAPPING>; @@ -1176,6 +1222,9 @@ export default class RedisClient< if(this._self.#socket) { this._self._ejectSocket().destroy(); } + // A swapped-in socket carries a fresh server session with no other observable signal + // on this client — the explicit wipe is the only fieldset-invalidation mechanism here. + this._self.#preparedFieldsets.clear(); this._self.#socket = socket; this._self.#attachListeners(this._self.#socket); } @@ -1226,6 +1275,13 @@ export default class RedisClient< commandOptions: CommandOptions | undefined, transformReply: TransformReply | undefined, ) { + if ( + command === HIMPORT_SET || command === HIMPORT_PREPARE || + command === HIMPORT_DISCARD || command === HIMPORT_DISCARDALL + ) { + return this._self.#executeHimport(this, command, parser, commandOptions, transformReply); + } + const csc = this._self.#clientSideCache; const fn = () => { return this.sendCommand(parser.redisArgs, commandOptions) }; @@ -1254,6 +1310,184 @@ export default class RedisClient< return finalReply; } + /** + * HIMPORT transparency layer (design: ../himport/registry.ts module doc). Runs instead of + * the normal `_executeCommand` tail for the four HIMPORT commands and keeps this + * connection's server session coherent with the client-level fieldset registry: + * + * 1. Reconcile — replays DISCARDs this session missed (it was serving other commands when + * the user discarded on another connection). + * 2. Lazy prepare — pipelines a PREPARE in front of a SET when this session lacks the + * fieldset or holds a stale field-list version. No await in between: the server + * processes them in arrival order on one socket. + * 3. Registry-based replies — DISCARD/DISCARDALL resolve with the registry mutation result + * (`1`/count of registrations removed), not the per-session server reply, so the answer + * is deterministic across pools and clusters. + * 4. Retry-once — a SET failing with `no such fieldset` for a *registered* fieldset means + * this session lost its state through a path the client did not observe; re-prepare on + * this same connection and retry a single time. + * + * Raw `sendCommand(['HIMPORT', ...])` bypasses this layer by construction. + */ + async #executeHimport( + // The proxy-aware caller (`duplicate`d command-options carriers derive from the client), + // needed for `sendCommand` merging and `_commandOptions`; `this` inside is always _self. + client: RedisClient, + command: Command, + parser: CommandParser, + commandOptions: CommandOptions | undefined, + transformReply: TransformReply | undefined, + retried = false + ): Promise { + const registry = this.#himportRegistry; + const prepared = this.#preparedFieldsets; + const args = parser.redisArgs; + // The injected commands must ride with the main command's effective queue placement: + // same chainId (ASK-redirect chains flush per chain) and same asap-ness (asap unshifts + // to the queue front — a non-asap injection would let an asap SET jump its own PREPARE). + const effectiveAsap = commandOptions?.asap ?? client._commandOptions?.asap; + const effectiveChainId = commandOptions?.chainId ?? client._commandOptions?.chainId; + const injectOpts: CommandOptions = { asap: effectiveAsap, chainId: effectiveChainId }; + + // Commands to reach the wire BEFORE the main command, in this order. Each carries the + // rollback undoing its optimistic bookkeeping if the server rejects it. + const prelude: Array<{ args: Array, rollback: () => void }> = []; + + // -- Reconcile: replay discards this session may still be holding. A DISCARD must + // precede a same-name SET on the wire, or the SET would write through the discarded + // template; hence prelude, not fire-and-forget. + if (prepared.syncedDiscardCount < registry.discardCount) { + const countAtInjection = registry.discardCount; + const pending = registry.pendingDiscards(prepared.names()); + // Every session name is pending → one DISCARDALL wipes the session in a single + // command. Safe precisely because nothing this session holds is worth keeping. + const collapseToDiscardAll = pending.size > 0 && pending.size === prepared.size; + const rollbackVersions = new Map(); + for (const name of pending) { + rollbackVersions.set(name, prepared.get(name)!); + prepared.delete(name); + } + prepared.syncedDiscardCount = countAtInjection; + // Failed injected DISCARD → the fieldset is still alive on this session with no + // client-side trace; restore the trace and knock the synced count back so the next + // HIMPORT command re-reconciles. Guarded so a newer PREPARE is never clobbered. + const rollback = (names: Iterable) => { + for (const name of names) { + const version = rollbackVersions.get(name); + if (version !== undefined && prepared.get(name) === undefined) { + prepared.set(name, version); + } + } + prepared.syncedDiscardCount = Math.min(prepared.syncedDiscardCount, countAtInjection - 1); + }; + if (collapseToDiscardAll) { + prelude.push({ args: ['HIMPORT', 'DISCARDALL'], rollback: () => rollback(pending) }); + } else { + for (const name of pending) { + prelude.push({ args: ['HIMPORT', 'DISCARD', name], rollback: () => rollback([name]) }); + } + } + } + + // -- Per-command registry bookkeeping, optimistic (before any reply) so concurrent + // same-tick commands see the final state and don't double-prepare (NF.2). + let userPrepare: { name: string, version: number } | undefined; + let registryReply: number | undefined; + let setName: string | undefined; + + if (command === HIMPORT_PREPARE) { + const name = String(args[2]); + registry.set(name, args.slice(3)); + const version = registry.get(name)!.version; + userPrepare = { name, version }; + prepared.set(name, version); + } else if (command === HIMPORT_SET) { + // args layout: [HIMPORT, SET, key, fieldset, ...values] — index 2 is the (possibly + // keyPrefix-affected) key, index 3 is the fieldset name. + const name = String(args[3]); + setName = name; + const entry = registry.get(name); + if (entry !== undefined) { + const sessionVersion = prepared.get(name); + if (sessionVersion === undefined || sessionVersion < entry.version) { + prepared.set(name, entry.version); + prelude.push({ + args: ['HIMPORT', 'PREPARE', name, ...entry.fields], + // Injected-PREPARE failures are often transient (LOADING, failover) — roll back + // only the session claim, never the registration, or a valid fieldset would + // permanently lose lazy re-prepare. + rollback: () => { + if (prepared.get(name) === entry.version) prepared.delete(name); + } + }); + } + } + // Name absent from the registry → send as-is; the server's `no such fieldset` is + // authoritative and must not be masked. + } else if (command === HIMPORT_DISCARD) { + const name = String(args[2]); + registryReply = registry.discard(name) ? 1 : 0; + prepared.delete(name); + } else { + registryReply = registry.discardAll(); + prepared.clear(); + } + + // -- Enqueue. Everything below runs in one synchronous tick, so the prelude and the + // main command flush to the socket in a single write (the HLD-blessed pipelining). + const send = () => client.sendCommand(parser.redisArgs, commandOptions); + let mainPromise: Promise; + if (effectiveAsap) { + // asap unshifts, so consecutive front-insertions reverse: enqueue the main command + // first, then the prelude back-to-front — the wire sees prelude order, then main. + mainPromise = send(); + for (let i = prelude.length - 1; i >= 0; i--) { + const injection = prelude[i]; + client.sendCommand(injection.args, injectOpts).catch(injection.rollback); + } + } else { + for (const injection of prelude) { + client.sendCommand(injection.args, injectOpts).catch(injection.rollback); + } + mainPromise = send(); + } + + let reply: unknown; + try { + reply = await mainPromise; + } catch (err) { + if (command === HIMPORT_PREPARE && userPrepare !== undefined) { + // A rejected user PREPARE (e.g. duplicate field name) must not leave a registration + // that lazy prepare would replay forever. Version-guarded: a newer successful + // PREPARE must not be clobbered. Deleting through `discard()` also bumps + // discardCount, so sessions still holding an OLDER field list for this name + // reconcile it away instead of silently serving stale SETs. + const { name, version } = userPrepare; + if (prepared.get(name) === version) prepared.delete(name); + if (registry.get(name)?.version === version) registry.discard(name); + } else if ( + command === HIMPORT_SET && setName !== undefined && !retried && + (err as Error)?.message?.includes?.('no such fieldset') && + registry.get(setName) !== undefined + ) { + // Recover-and-retry-once (HLD NF.4): the session claim lied — state was lost through + // a path the client did not observe. Blind retries on another connection would fail + // identically; re-prepare HERE and retry a single time. + prepared.delete(setName); + return this.#executeHimport(client, command, parser, commandOptions, transformReply, true); + } + throw err; + } + + const finalReply = registryReply !== undefined + ? registryReply + : transformReply ? transformReply(reply, parser.preserve, commandOptions?.typeMapping) : reply; + + publish(CHANNELS.COMMAND_REPLY, () => ({ args: sanitizeArgs(parser.redisArgs), reply: finalReply, clientId: this._clientId })); + + return finalReply; + } + /** * @internal */ @@ -1520,6 +1754,8 @@ export default class RedisClient< commands: Array, selectedDB?: number ) { + assertNoHimportSessionCommands(commands); + if (!this._self.#socket.isOpen) { return Promise.reject(new ClientClosedError()); } @@ -1576,6 +1812,8 @@ export default class RedisClient< commands: Array, selectedDB?: number ) { + assertNoHimportSessionCommands(commands); + const dirtyWatch = this._self.#dirtyWatch; this._self.#dirtyWatch = undefined; const watchEpoch = this._self.#watchEpoch; @@ -1764,6 +2002,12 @@ export default class RedisClient< * Reset the client to its default state (i.e. stop PubSub, stop monitoring, select default DB, etc.) */ async reset() { + // RESET wipes the server session's fieldsets. Cleared at entry, not after the + // round-trip: RESET queues at the #toWrite tail, so a concurrent hImportSet whose hook + // runs before a post-completion clear would see a live entry and queue a bare SET + // BEHIND the RESET — onto the wiped session. With the entry-time clear it re-injects + // PREPARE, which queues behind RESET too, in the correct order. + this._self.#preparedFieldsets.clear(); const chainId = Symbol('Reset Chain'), promises = [this._self.#queue.reset(chainId)], selectedDB = this._self.#options?.database ?? 0; diff --git a/packages/client/lib/client/pool.ts b/packages/client/lib/client/pool.ts index 34942bcca4a..dac53f71d3c 100644 --- a/packages/client/lib/client/pool.ts +++ b/packages/client/lib/client/pool.ts @@ -14,6 +14,7 @@ import SingleEntryCache from '../single-entry-cache'; import { MULTI_MODE, MultiMode } from '../multi-command'; import { publish, CHANNELS } from './tracing'; import { ClientIdentity, ClientRole, generateClientId } from './identity'; +import { FieldsetRegistry } from '../himport/registry'; export interface RedisPoolOptions { /** @@ -324,6 +325,12 @@ export class RedisClientPool< } } + // One fieldset registry for the whole pool: a fieldset registered through any borrowed + // connection must be transparently re-preparable on every other pooled connection. + // Deliberately NOT inherited when the pool is created from an existing client + // (createPool spreads the client's options, which never carry an instance). + clientOptions = { ...clientOptions, himportRegistry: new FieldsetRegistry() }; + // Capture the key prefix for the pool's own parser construction, then strip it from // the pooled clients' options: the pool builds the (already prefixed) parser and hands // it to a pooled client, which never re-parses — so inner clients must not re-prefix. diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 5f1222839fa..18c32c58a40 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -10,6 +10,7 @@ import { BasicPooledClientSideCache, PooledClientSideCacheProvider } from '../cl import { SMIGRATED_EVENT, SMigratedEvent, dbgMaintenance } from '../client/enterprise-maintenance-manager'; import { ClientRole } from '../client/identity'; import ClusterReconnectionTracker from './cluster-reconnection-tracker'; +import { FieldsetRegistry } from '../himport/registry'; interface NodeAddress { host: string; @@ -161,6 +162,12 @@ export default class RedisClusterSlots< readonly nodeByAddress = new Map | ShardNode>(); pubSubNode?: PubSubNode; clientSideCache?: PooledClientSideCacheProvider; + /** + * One fieldset registry for the whole cluster: HIMPORT PREPARE fans out to all masters + * via the policy layer, and every node client (including MOVED/rediscovered nodes and + * SMIGRATED destinations) must lazily re-prepare from the same registrations. + */ + readonly #himportRegistry = new FieldsetRegistry(); smigratedSeqIdsSeen = new Set; /** * Per-instance sticky FT cursor bindings, keyed by the client-minted virtual @@ -644,6 +651,7 @@ export default class RedisClusterSlots< let wasReady = false; const client = this.#clientFactory( this.#clientOptionsDefaults({ clientSideCache: this.clientSideCache, + himportRegistry: this.#himportRegistry, RESP: this.#options.RESP, socket, readonly, diff --git a/packages/client/lib/command-metadata/command-metadata-data.ts b/packages/client/lib/command-metadata/command-metadata-data.ts index c2d67dcd48d..4a48ef1db9c 100644 --- a/packages/client/lib/command-metadata/command-metadata-data.ts +++ b/packages/client/lib/command-metadata/command-metadata-data.ts @@ -2923,19 +2923,19 @@ export const COMMAND_METADATA: ModuleMetadataRecords = { "subcommands": { "discard": { "request": "all_shards", - "response": "default-keyless", + "response": "agg_max", "isKeyless": true, "flags": [] }, "discardall": { "request": "all_shards", - "response": "default-keyless", + "response": "agg_max", "isKeyless": true, "flags": [] }, "prepare": { "request": "all_shards", - "response": "default-keyless", + "response": "all_succeeded", "isKeyless": true, "flags": [ "denyoom" diff --git a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts index e9ea9f7539c..b53a8dce785 100644 --- a/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts +++ b/packages/client/lib/command-metadata/static-metadata-resolver.spec.ts @@ -155,6 +155,36 @@ describe('StaticMetadataResolver', () => { } }); + it('HIMPORT session subcommands: all_shards with pinned response reducers', () => { + // The server tips request_policy:all_shards and NO response policy; the pinned + // reducers replace default-keyless, whose aggregateMerge throws on scalar fan-out + // replies (simple-string OK / integers) whenever ≥2 masters reply. + const expected: Array<{ subcommand: string; response: string }> = [ + { subcommand: 'PREPARE', response: 'all_succeeded' }, + { subcommand: 'DISCARD', response: 'agg_max' }, + { subcommand: 'DISCARDALL', response: 'agg_max' } + ]; + for (const { subcommand, response } of expected) { + const result = resolver.resolvePolicy({ command: 'HIMPORT', subcommand }); + assert.equal(result.ok, true, `expected HIMPORT ${subcommand} to resolve`); + if (result.ok) { + assert.equal(result.value.request, 'all_shards', subcommand); + assert.equal(result.value.response, response, subcommand); + assert.equal(result.value.isKeyless, true, subcommand); + } + } + }); + + it('HIMPORT SET: default keyed routing (key at position 2)', () => { + const result = resolver.resolvePolicy({ command: 'HIMPORT', subcommand: 'SET' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.isKeyless, false); + } + }); + it('SCAN keeps the server special/special (cluster-wide scan chain)', () => { const result = resolver.resolvePolicy({ command: 'SCAN', subcommand: '0' }); assert.equal(result.ok, true); diff --git a/packages/client/lib/commands/HIMPORT_DISCARD.spec.ts b/packages/client/lib/commands/HIMPORT_DISCARD.spec.ts new file mode 100644 index 00000000000..a71145a163e --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_DISCARD.spec.ts @@ -0,0 +1,36 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import HIMPORT_DISCARD from './HIMPORT_DISCARD'; +import { parseArgs } from './generic-transformers'; + +describe('HIMPORT DISCARD', () => { + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(HIMPORT_DISCARD, 'fieldset'), + ['HIMPORT', 'DISCARD', 'fieldset'] + ); + }); + }); + + describe('behavior', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + testUtils.testAll('hImportDiscard', async client => { + await client.hImportPrepare('fieldset', ['f1', 'f2']); + + assert.equal( + await client.hImportDiscard('fieldset'), + 1 + ); + + assert.equal( + await client.hImportDiscard('fieldset'), + 0 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + }); +}); diff --git a/packages/client/lib/commands/HIMPORT_DISCARD.ts b/packages/client/lib/commands/HIMPORT_DISCARD.ts new file mode 100644 index 00000000000..3559c9e9e37 --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_DISCARD.ts @@ -0,0 +1,9 @@ +import { CommandParser } from '../client/parser'; +import { NumberReply, Command } from '../RESP/types'; + +export default { + parseCommand(parser: CommandParser, fieldset: string) { + parser.push('HIMPORT', 'DISCARD', fieldset); + }, + transformReply: undefined as unknown as () => NumberReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/HIMPORT_DISCARDALL.spec.ts b/packages/client/lib/commands/HIMPORT_DISCARDALL.spec.ts new file mode 100644 index 00000000000..56d9455a0f5 --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_DISCARDALL.spec.ts @@ -0,0 +1,36 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import HIMPORT_DISCARDALL from './HIMPORT_DISCARDALL'; +import { parseArgs } from './generic-transformers'; + +describe('HIMPORT DISCARDALL', () => { + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(HIMPORT_DISCARDALL), + ['HIMPORT', 'DISCARDALL'] + ); + }); + }); + + describe('behavior', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + testUtils.testAll('hImportDiscardAll', async client => { + assert.equal( + await client.hImportDiscardAll(), + 0 + ); + + await client.hImportPrepare('fieldset', ['f1', 'f2']); + + assert.equal( + await client.hImportDiscardAll(), + 1 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + }); +}); diff --git a/packages/client/lib/commands/HIMPORT_DISCARDALL.ts b/packages/client/lib/commands/HIMPORT_DISCARDALL.ts new file mode 100644 index 00000000000..fa658a57368 --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_DISCARDALL.ts @@ -0,0 +1,9 @@ +import { CommandParser } from '../client/parser'; +import { NumberReply, Command } from '../RESP/types'; + +export default { + parseCommand(parser: CommandParser) { + parser.push('HIMPORT', 'DISCARDALL'); + }, + transformReply: undefined as unknown as () => NumberReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/HIMPORT_PREPARE.spec.ts b/packages/client/lib/commands/HIMPORT_PREPARE.spec.ts new file mode 100644 index 00000000000..1d19143b191 --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_PREPARE.spec.ts @@ -0,0 +1,50 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import HIMPORT_PREPARE from './HIMPORT_PREPARE'; +import { parseArgs } from './generic-transformers'; + +describe('HIMPORT PREPARE', () => { + describe('transformArguments', () => { + it('string', () => { + assert.deepEqual( + parseArgs(HIMPORT_PREPARE, 'fieldset', 'field'), + ['HIMPORT', 'PREPARE', 'fieldset', 'field'] + ); + }); + + it('array', () => { + assert.deepEqual( + parseArgs(HIMPORT_PREPARE, 'fieldset', ['f1', 'f2']), + ['HIMPORT', 'PREPARE', 'fieldset', 'f1', 'f2'] + ); + }); + + it('preserves caller field order', () => { + assert.deepEqual( + parseArgs(HIMPORT_PREPARE, 'fieldset', ['c', 'a', 'b']), + ['HIMPORT', 'PREPARE', 'fieldset', 'c', 'a', 'b'] + ); + }); + }); + + describe('behavior', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + testUtils.testAll('hImportPrepare', async client => { + assert.equal( + await client.hImportPrepare('fieldset', ['f1', 'f2']), + 'OK' + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testWithClient('rejects duplicate field names', async client => { + await assert.rejects( + client.hImportPrepare('fieldset', ['f1', 'f1']), + /duplicate field name/ + ); + }, GLOBAL.SERVERS.OPEN); + }); +}); diff --git a/packages/client/lib/commands/HIMPORT_PREPARE.ts b/packages/client/lib/commands/HIMPORT_PREPARE.ts new file mode 100644 index 00000000000..babfefd584f --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_PREPARE.ts @@ -0,0 +1,11 @@ +import { CommandParser } from '../client/parser'; +import { SimpleStringReply, Command } from '../RESP/types'; +import { RedisVariadicArgument } from './generic-transformers'; + +export default { + parseCommand(parser: CommandParser, fieldset: string, fields: RedisVariadicArgument) { + parser.push('HIMPORT', 'PREPARE', fieldset); + parser.pushVariadic(fields); + }, + transformReply: undefined as unknown as () => SimpleStringReply<'OK'> +} as const satisfies Command; diff --git a/packages/client/lib/commands/HIMPORT_SET.spec.ts b/packages/client/lib/commands/HIMPORT_SET.spec.ts new file mode 100644 index 00000000000..20b5d7d5dd6 --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_SET.spec.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import HIMPORT_SET from './HIMPORT_SET'; +import { parseArgs } from './generic-transformers'; + +describe('HIMPORT SET', () => { + describe('transformArguments', () => { + it('string', () => { + assert.deepEqual( + parseArgs(HIMPORT_SET, 'key', 'fieldset', 'value'), + ['HIMPORT', 'SET', 'key', 'fieldset', 'value'] + ); + }); + + it('array', () => { + assert.deepEqual( + parseArgs(HIMPORT_SET, 'key', 'fieldset', ['v1', 'v2']), + ['HIMPORT', 'SET', 'key', 'fieldset', 'v1', 'v2'] + ); + }); + }); + + describe('behavior', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + testUtils.testAll('hImportSet roundtrip', async client => { + await client.hImportPrepare('fieldset', ['f1', 'f2']); + + assert.equal( + await client.hImportSet('key', 'fieldset', ['v1', 'v2']), + 'OK' + ); + + // enumeration order is canonicalized server-side — assert content, not order + assert.deepEqual( + await client.hGetAll('key'), + { f1: 'v1', f2: 'v2' } + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testWithClient('rejects on value count mismatch', async client => { + await client.hImportPrepare('fieldset', ['f1', 'f2']); + + await assert.rejects( + client.hImportSet('key', 'fieldset', ['v1']), + /value count/ + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('rejects with WRONGTYPE on non-hash key', async client => { + await client.set('key', 'string'); + await client.hImportPrepare('fieldset', ['f1']); + + await assert.rejects( + client.hImportSet('key', 'fieldset', ['v1']), + /WRONGTYPE/ + ); + }, GLOBAL.SERVERS.OPEN); + }); +}); diff --git a/packages/client/lib/commands/HIMPORT_SET.ts b/packages/client/lib/commands/HIMPORT_SET.ts new file mode 100644 index 00000000000..b0c37eae62a --- /dev/null +++ b/packages/client/lib/commands/HIMPORT_SET.ts @@ -0,0 +1,13 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, SimpleStringReply, Command } from '../RESP/types'; +import { RedisVariadicArgument } from './generic-transformers'; + +export default { + parseCommand(parser: CommandParser, key: RedisArgument, fieldset: string, values: RedisVariadicArgument) { + parser.push('HIMPORT', 'SET'); + parser.pushKey(key); + parser.push(fieldset); + parser.pushVariadic(values); + }, + transformReply: undefined as unknown as () => SimpleStringReply<'OK'> +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 3ae4f4485ff..0ca0efb92c0 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -162,6 +162,10 @@ import HGET from './HGET'; import HGETALL from './HGETALL'; import HGETDEL from './HGETDEL'; import HGETEX from './HGETEX'; +import HIMPORT_DISCARD from './HIMPORT_DISCARD'; +import HIMPORT_DISCARDALL from './HIMPORT_DISCARDALL'; +import HIMPORT_PREPARE from './HIMPORT_PREPARE'; +import HIMPORT_SET from './HIMPORT_SET'; import HINCRBY from './HINCRBY'; import HINCRBYFLOAT from './HINCRBYFLOAT'; import HKEYS from './HKEYS'; @@ -2347,6 +2351,116 @@ export default { * @param options - Options for setting expiration */ hGetEx: HGETEX, + /** + * + * @experimental + * + * Removes a fieldset registration from the client and discards it from the server + * connection session. The reply is registry-based: `1` if the fieldset was registered on + * this client and removed, `0` otherwise. + * @param fieldset - Name of the fieldset to discard + * @see https://redis.io/commands/himport-discard/ + * @since 8.10 + */ + HIMPORT_DISCARD, + /** + * + * @experimental + * + * Removes a fieldset registration from the client and discards it from the server + * connection session. The reply is registry-based: `1` if the fieldset was registered on + * this client and removed, `0` otherwise. + * @param fieldset - Name of the fieldset to discard + * @see https://redis.io/commands/himport-discard/ + * @since 8.10 + */ + hImportDiscard: HIMPORT_DISCARD, + /** + * + * @experimental + * + * Removes all fieldset registrations from the client and discards them from the server + * connection session. The reply is registry-based: the number of fieldsets that were + * registered on this client and removed. + * @see https://redis.io/commands/himport-discardall/ + * @since 8.10 + */ + HIMPORT_DISCARDALL, + /** + * + * @experimental + * + * Removes all fieldset registrations from the client and discards them from the server + * connection session. The reply is registry-based: the number of fieldsets that were + * registered on this client and removed. + * @see https://redis.io/commands/himport-discardall/ + * @since 8.10 + */ + hImportDiscardAll: HIMPORT_DISCARDALL, + /** + * + * @experimental + * + * Registers an ordered list of field names under a fieldset name for use by subsequent + * HIMPORT SET calls. The client keeps the registration and transparently re-prepares + * connections as needed (reconnects, pool growth, cluster topology changes). Raw + * `sendCommand(['HIMPORT', ...])` and raw `sendCommand(['RESET'])` bypass this managed + * layer. `duplicate()` shares the parent's registrations; `createPool()` does not. + * Inside MULTI, PREPARE/DISCARD/DISCARDALL are rejected client-side; HIMPORT SET is + * allowed if the fieldset was prepared on that connection beforehand. + * @param fieldset - Name to register the field list under + * @param fields - One or more hash field names; values in later HIMPORT SET calls map + * positionally to this order + * @see https://redis.io/commands/himport-prepare/ + * @since 8.10 + */ + HIMPORT_PREPARE, + /** + * + * @experimental + * + * Registers an ordered list of field names under a fieldset name for use by subsequent + * HIMPORT SET calls. The client keeps the registration and transparently re-prepares + * connections as needed (reconnects, pool growth, cluster topology changes). Raw + * `sendCommand(['HIMPORT', ...])` and raw `sendCommand(['RESET'])` bypass this managed + * layer. `duplicate()` shares the parent's registrations; `createPool()` does not. + * Inside MULTI, PREPARE/DISCARD/DISCARDALL are rejected client-side; HIMPORT SET is + * allowed if the fieldset was prepared on that connection beforehand. + * @param fieldset - Name to register the field list under + * @param fields - One or more hash field names; values in later HIMPORT SET calls map + * positionally to this order + * @see https://redis.io/commands/himport-prepare/ + * @since 8.10 + */ + hImportPrepare: HIMPORT_PREPARE, + /** + * + * @experimental + * + * Creates or fully replaces the hash at key using the field list of a prepared fieldset. + * Values map positionally to the fields given to HIMPORT PREPARE for that fieldset. Hash + * field enumeration order is not guaranteed to match the prepare order. + * @param key - Hash key to create or replace + * @param fieldset - Name of a fieldset registered with HIMPORT PREPARE + * @param values - Values, one per prepared field, in the prepared order + * @see https://redis.io/commands/himport-set/ + * @since 8.10 + */ + HIMPORT_SET, + /** + * + * @experimental + * + * Creates or fully replaces the hash at key using the field list of a prepared fieldset. + * Values map positionally to the fields given to HIMPORT PREPARE for that fieldset. Hash + * field enumeration order is not guaranteed to match the prepare order. + * @param key - Hash key to create or replace + * @param fieldset - Name of a fieldset registered with HIMPORT PREPARE + * @param values - Values, one per prepared field, in the prepared order + * @see https://redis.io/commands/himport-set/ + * @since 8.10 + */ + hImportSet: HIMPORT_SET, /** * Increments the integer value of a field in a hash by the given number * @param key - Key of the hash diff --git a/packages/client/lib/himport/registry.spec.ts b/packages/client/lib/himport/registry.spec.ts new file mode 100644 index 00000000000..eafce9f1fe8 --- /dev/null +++ b/packages/client/lib/himport/registry.spec.ts @@ -0,0 +1,178 @@ +import { strict as assert } from 'node:assert'; +import { FieldsetRegistry, PreparedFieldsets } from './registry'; + +describe('FieldsetRegistry', () => { + describe('set', () => { + it('is idempotent: same fields keep the same version', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a', 'b']); + const version = registry.get('fs')!.version; + + registry.set('fs', ['a', 'b']); + assert.equal(registry.get('fs')!.version, version); + }); + + it('bumps the version on a changed field list', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a', 'b']); + const version = registry.get('fs')!.version; + + registry.set('fs', ['a', 'c']); + assert.ok(registry.get('fs')!.version > version); + }); + + it('field order matters — reordered fields are a new version', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a', 'b']); + const version = registry.get('fs')!.version; + + registry.set('fs', ['b', 'a']); + assert.ok(registry.get('fs')!.version > version); + }); + + it('versions stay monotonic across discard + re-prepare', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a', 'b']); + const version = registry.get('fs')!.version; + + registry.discard('fs'); + registry.set('fs', ['a', 'b']); + assert.ok(registry.get('fs')!.version > version); + }); + + it('compares fields byte-wise: Buffer and string with identical bytes are equal', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a', Buffer.from('b')]); + const version = registry.get('fs')!.version; + + registry.set('fs', [Buffer.from('a'), 'b']); + assert.equal(registry.get('fs')!.version, version); + }); + + it('stores a copy — mutating the caller array does not affect the registry', () => { + const registry = new FieldsetRegistry(); + const fields = ['a', 'b']; + registry.set('fs', fields); + + fields[1] = 'mutated'; + assert.deepEqual(registry.get('fs')!.fields, ['a', 'b']); + }); + + it('accepts empty string as a fieldset name', () => { + const registry = new FieldsetRegistry(); + registry.set('', ['a']); + assert.ok(registry.get('') !== undefined); + }); + }); + + describe('discard', () => { + it('returns true and bumps discardCount for a registered name', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a']); + + assert.equal(registry.discard('fs'), true); + assert.equal(registry.discardCount, 1); + assert.equal(registry.get('fs'), undefined); + }); + + it('no-op discard returns false and does not bump discardCount', () => { + const registry = new FieldsetRegistry(); + + assert.equal(registry.discard('missing'), false); + assert.equal(registry.discardCount, 0); + }); + + it('repeated discards bump discardCount once', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a']); + + registry.discard('fs'); + registry.discard('fs'); + registry.discard('fs'); + assert.equal(registry.discardCount, 1); + }); + }); + + describe('discardAll', () => { + it('returns the number of removed registrations with a single discardCount bump', () => { + const registry = new FieldsetRegistry(); + registry.set('fs1', ['a']); + registry.set('fs2', ['b']); + + assert.equal(registry.discardAll(), 2); + assert.equal(registry.discardCount, 1); + assert.equal(registry.get('fs1'), undefined); + }); + + it('no-op on an empty registry: returns 0, no bump', () => { + const registry = new FieldsetRegistry(); + + assert.equal(registry.discardAll(), 0); + assert.equal(registry.discardCount, 0); + }); + }); + + describe('pendingDiscards', () => { + it('returns exactly the session-only names', () => { + const registry = new FieldsetRegistry(); + registry.set('kept', ['a']); + registry.set('discarded', ['b']); + registry.discard('discarded'); + + assert.deepEqual( + registry.pendingDiscards(new Set(['kept', 'discarded'])), + new Set(['discarded']) + ); + }); + + it('a discarded and re-registered name is not pending', () => { + const registry = new FieldsetRegistry(); + registry.set('fs', ['a']); + registry.discard('fs'); + registry.set('fs', ['b']); + + assert.deepEqual( + registry.pendingDiscards(new Set(['fs'])), + new Set() + ); + }); + }); +}); + +describe('PreparedFieldsets', () => { + it('tracks name → version', () => { + const prepared = new PreparedFieldsets(); + prepared.set('fs', 3); + + assert.equal(prepared.get('fs'), 3); + assert.equal(prepared.get('missing'), undefined); + }); + + it('names() returns a snapshot of tracked names', () => { + const prepared = new PreparedFieldsets(); + prepared.set('fs1', 1); + prepared.set('fs2', 2); + + assert.deepEqual(prepared.names(), new Set(['fs1', 'fs2'])); + }); + + it('clear() wipes entries but keeps syncedDiscardCount', () => { + const prepared = new PreparedFieldsets(); + prepared.set('fs', 1); + prepared.syncedDiscardCount = 5; + + prepared.clear(); + assert.equal(prepared.size, 0); + assert.equal(prepared.syncedDiscardCount, 5); + }); + + it('delete() removes a single entry', () => { + const prepared = new PreparedFieldsets(); + prepared.set('fs1', 1); + prepared.set('fs2', 2); + + assert.equal(prepared.delete('fs1'), true); + assert.equal(prepared.delete('fs1'), false); + assert.deepEqual(prepared.names(), new Set(['fs2'])); + }); +}); diff --git a/packages/client/lib/himport/registry.ts b/packages/client/lib/himport/registry.ts new file mode 100644 index 00000000000..f2154c7ffcb --- /dev/null +++ b/packages/client/lib/himport/registry.ts @@ -0,0 +1,198 @@ +import { RedisArgument } from '../RESP/types'; + +/** + * Client-side bookkeeping for the HIMPORT command family (hinted hash templates, Redis 8.10). + * + * Server-side fieldsets are session state attached to one physical connection: they die on + * disconnect and RESET, and no other connection can see them. The client therefore keeps two + * layers of state: + * + * - {@link FieldsetRegistry} — one per logical client (standalone client, pool, cluster, + * sentinel), shared by every `RedisClient` the logical client owns. It records what the + * user *registered* (`hImportPrepare`) and is the replay source for transparently + * re-preparing connections. + * - {@link PreparedFieldsets} — one per `RedisClient`, never shared. It mirrors what that + * client's *current socket session* actually holds, so the lazy-prepare hook can decide + * whether an `HIMPORT SET` needs a `PREPARE` pipelined in front of it. + * + * TODO(himport-multi) — auto-prepare inside MULTI/pipeline. The multi queue stores raw args + * only (`multi-command.ts`) — no `Command` identity survives. Recipe when picked up: in + * `_executeMulti` sniff HIMPORT SET raw args (Buffer-tolerant compare of args[0]/args[1]) and + * inject PREPAREs under the same `chainId` BEFORE the `['MULTI']` (fieldsets are + * session-level — the PREPARE need not be inside the transaction); keep injected promises out + * of the positional result mapping (`transformReplies` uses only the last reply); same + * treatment in `_executePipeline`. Needs aborted-EXEC registry bookkeeping thought through. + * Until then: inside MULTI the fieldset must already exist on that connection (prepare + * beforehand outside the MULTI) — documented. The session-subcommand reject guard already + * scans queued commands at the same funnel; when this TODO is picked up, revisit whether that + * reject should become a registry-mirroring sniff instead. + */ + +export interface Fieldset { + /** + * The field names exactly as the caller passed them to `hImportPrepare` — never deduped, + * sorted, or reordered (the server pairs SET values to this order positionally). Holds a + * sliced copy, never an alias of the caller's array; mutating a Buffer's *contents* in + * place still aliases — conventional Node.js behavior. + */ + fields: Array; + /** + * Staleness token from the registry-wide monotonic counter. A connection whose + * `PreparedFieldsets` entry holds a lower number must re-PREPARE before its next SET. + */ + version: number; +} + +/** + * What the user registered on the logical client: fieldset name → ordered field list. + * Single source of truth for lazy re-prepare; also the source of the registry-based + * DISCARD/DISCARDALL replies. Mutated only by the per-connection command hook — safe because + * `set` is idempotent (cluster fan-out runs the hook once per master against this same + * instance) and `discard`/`discardAll` are effective-only. + */ +export class FieldsetRegistry { + /** + * Fieldset name → registration. Map keys are `String()`-coerced names (a Buffer key would + * be identity-compared by `Map` and poison lookups); values hold sliced copies, never + * aliases of caller arrays. + */ + #fieldsets = new Map(); + + /** + * Source of `Fieldset.version`. Registry-wide and never reset — a per-entry counter + * restarting at 0 after discard + re-prepare would make a stale connection entry compare + * as fresh, skip the re-PREPARE, and silently bind values against the old field list. + */ + #versionCounter = 0; + + /** + * Counts EFFECTIVE discards only (a discard of an unknown name does not bump), so no-op + * discards never force fleet-wide reconciles and a cluster fan-out of one user DISCARD + * bumps exactly once (the first hook run deletes, the rest early-return). Connections + * compare their `syncedDiscardCount` snapshot against this to detect pending discards. + */ + discardCount = 0; + + /** + * Idempotent upsert: registering the same name with a deep-equal field list keeps the + * existing version (cluster fan-out and per-worker startup prepares must not churn + * versions); a new name or a changed field list gets the next counter value. + */ + set(name: string, fields: Array): void { + const key = String(name); + const existing = this.#fieldsets.get(key); + if (existing !== undefined && fieldsEqual(existing.fields, fields)) return; + this.#fieldsets.set(key, { + fields: fields.slice(), + version: ++this.#versionCounter + }); + } + + get(name: string): Fieldset | undefined { + return this.#fieldsets.get(String(name)); + } + + /** + * Returns `true` iff the name was registered — this boolean IS the user-facing + * `hImportDiscard` reply (registry-based, not the server session's). + */ + discard(name: string): boolean { + const removed = this.#fieldsets.delete(String(name)); + if (removed) this.discardCount++; + return removed; + } + + /** + * Returns the number of registrations removed — this count IS the user-facing + * `hImportDiscardAll` reply. + */ + discardAll(): number { + const removed = this.#fieldsets.size; + if (removed > 0) { + this.#fieldsets.clear(); + this.discardCount++; + } + return removed; + } + + /** + * Names a connection's session still holds but the registry no longer does — i.e. the + * DISCARDs that connection must replay to catch up. Names that were discarded and + * re-registered are NOT returned: the version-gated lazy PREPARE (a silent server-side + * replace) covers them without a DISCARD. + */ + pendingDiscards(sessionNames: Set): Set { + const pending = new Set(); + for (const name of sessionNames) { + if (!this.#fieldsets.has(name)) pending.add(name); + } + return pending; + } +} + +/** + * What one `RedisClient`'s current socket session holds. Every entry is a CLAIM about + * server-side session state, not client state: it is tied to the socket's lifetime, so the + * client wipes this whole object on socket error, `reset()`, and socket replacement. A claim + * that survives an unenumerated loss path lies — the `no such fieldset` recover-and-retry + * net heals that at the cost of one extra round trip. + */ +export class PreparedFieldsets { + /** + * Fieldset name → the {@link Fieldset.version} this session was prepared with. A missing + * entry or a lower version means the next dependent command must pipeline a PREPARE first. + */ + #versions = new Map(); + + /** + * Snapshot of `FieldsetRegistry.discardCount` this connection has reconciled up to. + * `syncedDiscardCount < registry.discardCount` means discards happened that this session + * may still hold — reconcile before the next HIMPORT command. Knocked back when an + * injected DISCARD fails, so the reconcile re-runs. + */ + syncedDiscardCount = 0; + + get(name: string): number | undefined { + return this.#versions.get(name); + } + + set(name: string, version: number): void { + this.#versions.set(name, version); + } + + delete(name: string): boolean { + return this.#versions.delete(name); + } + + names(): Set { + return new Set(this.#versions.keys()); + } + + get size(): number { + return this.#versions.size; + } + + clear(): void { + this.#versions.clear(); + } +} + +/** + * Element-wise, byte-level equality: a `Buffer` and a `string` with identical bytes are + * equal. Byte-level matters because the idempotency decision must match what the wire would + * carry, not the JS representation. + */ +function fieldsEqual(a: Array, b: Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i], y = b[i]; + if (typeof x === 'string' && typeof y === 'string') { + if (x !== y) return false; + } else { + const xb = typeof x === 'string' ? Buffer.from(x) : x; + const yb = typeof y === 'string' ? Buffer.from(y) : y; + if (!xb.equals(yb)) return false; + } + } + return true; +} diff --git a/packages/client/lib/himport/transparency.spec.ts b/packages/client/lib/himport/transparency.spec.ts new file mode 100644 index 00000000000..2ea9a8138b8 --- /dev/null +++ b/packages/client/lib/himport/transparency.spec.ts @@ -0,0 +1,209 @@ +import { strict as assert } from 'node:assert'; +import { once } from 'node:events'; +import testUtils, { GLOBAL } from '../test-utils'; + +/** + * Integration tests for the HIMPORT transparency layer (`#executeHimport` + + * `FieldsetRegistry`/`PreparedFieldsets`): lazy prepare, lazy discard reconcile, + * registry-based replies, and the recover-and-retry-once net. + */ +describe('HIMPORT transparency layer', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + // Force a disconnect and wait for the socket error — the next command reconnects. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function killClient(client: any): Promise { + const onceErrorPromise = once(client, 'error'); + await client.sendCommand(['QUIT']); + await Promise.all([ + onceErrorPromise, + assert.rejects(client.ping()) + ]); + } + + testUtils.testWithClient('lazily re-prepares after a reconnect', async client => { + await client.hImportPrepare('fs', ['f1', 'f2']); + await killClient(client); + + // The new session has no fieldsets; the hook must pipeline a PREPARE in front. + assert.equal(await client.hImportSet('key', 'fs', ['v1', 'v2']), 'OK'); + assert.deepEqual(await client.hGetAll('key'), { f1: 'v1', f2: 'v2' }); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('re-prepares when the field list changes (version staleness)', async client => { + await client.hImportPrepare('fs', ['old1', 'old2']); + await client.hImportSet('key1', 'fs', ['v1', 'v2']); + + await client.hImportPrepare('fs', ['new1', 'new2']); + await client.hImportSet('key2', 'fs', ['v1', 'v2']); + + assert.deepEqual(await client.hGetAll('key2'), { new1: 'v1', new2: 'v2' }); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('lazily re-prepares after a typed reset()', async client => { + await client.hImportPrepare('fs', ['f1']); + await client.reset(); + + assert.equal(await client.hImportSet('key', 'fs', ['v1']), 'OK'); + assert.deepEqual(await client.hGetAll('key'), { f1: 'v1' }); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('discard stops lazy prepare; the server error propagates', async client => { + await client.hImportPrepare('fs', ['f1']); + assert.equal(await client.hImportDiscard('fs'), 1); + + await assert.rejects( + client.hImportSet('key', 'fs', ['v1']), + /no such fieldset/ + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('asap SET reaches the wire behind its injected PREPARE', async client => { + await client.hImportPrepare('fs', ['f1']); + // Fresh socket, empty session — the injection must unshift IN FRONT of the asap SET; + // same-order enqueue would put the SET first and fail with `no such fieldset`. + await killClient(client); + + assert.equal(await client.asap().hImportSet('key', 'fs', ['v1']), 'OK'); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('rejected user PREPARE removes the registration', async client => { + await assert.rejects( + client.hImportPrepare('fs', ['dup', 'dup']), + /duplicate field name/ + ); + + // No lazy-prepare retry loop: the registration is gone, the server error propagates. + await assert.rejects( + client.hImportSet('key', 'fs', ['v1', 'v2']), + /no such fieldset/ + ); + + // A corrected PREPARE registers fresh. + await client.hImportPrepare('fs', ['f1', 'f2']); + assert.equal(await client.hImportSet('key', 'fs', ['v1', 'v2']), 'OK'); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('retries once when the session lost its state unobserved', async client => { + await client.hImportPrepare('fs', ['f1']); + await client.hImportSet('key1', 'fs', ['v1']); + + // Raw RESET bypasses the typed reset() wipe: the server session is gone but the + // client still claims the fieldset is prepared — exactly the lie the + // recover-and-retry-once net exists to absorb. + await client.sendCommand(['RESET']); + + assert.equal(await client.hImportSet('key2', 'fs', ['v1']), 'OK'); + // hGet, not hGetAll: raw RESET also reverted the connection protocol to RESP2, so + // map-shaped replies would decode flat; a bulk string is identical in both protocols. + assert.equal(await client.hGet('key2', 'f1'), 'v1'); + }, GLOBAL.SERVERS.OPEN); + + describe('MULTI/pipeline guard', () => { + testUtils.testWithClient('rejects session subcommands inside MULTI', async client => { + for (const build of [ + () => client.multi().hImportPrepare('fs', ['f1']), + () => client.multi().hImportDiscard('fs'), + () => client.multi().hImportDiscardAll(), + () => client.multi().addCommand(['HIMPORT', 'PREPARE', 'fs', 'f1']) + ]) { + await assert.rejects( + build().exec(), + /not supported inside MULTI\/pipeline/ + ); + } + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('allows hImportSet inside MULTI after an out-of-band prepare', async client => { + await client.hImportPrepare('fs', ['f1']); + // Same connection: prepare above already prepared this session. + const replies = await client.multi() + .hImportSet('key', 'fs', ['v1']) + .exec(); + + assert.deepEqual(replies, ['OK']); + assert.deepEqual(await client.hGetAll('key'), { f1: 'v1' }); + }, GLOBAL.SERVERS.OPEN); + }); + + describe('pool', () => { + testUtils.testWithClientPool('each borrowed connection lazily self-prepares', async pool => { + await pool.hImportPrepare('fs', ['f1', 'f2']); + + // Concurrent SETs spread across pooled connections; each one must transparently + // prepare its own session on first use. + const keys = ['key1', 'key2', 'key3', 'key4', 'key5', 'key6']; + const replies = await Promise.all( + keys.map(key => pool.hImportSet(key, 'fs', ['v1', 'v2'])) + ); + assert.deepEqual(replies, keys.map(() => 'OK')); + + assert.deepEqual(await pool.hGetAll('key6'), { f1: 'v1', f2: 'v2' }); + }, { + ...GLOBAL.SERVERS.OPEN, + poolOptions: { minimum: 3 } + }); + + testUtils.testWithClientPool('re-prepare with changed fields reaches every connection', async pool => { + await pool.hImportPrepare('fs', ['old1', 'old2']); + await Promise.all( + ['a1', 'a2', 'a3', 'a4'].map(key => pool.hImportSet(key, 'fs', ['v1', 'v2'])) + ); + + await pool.hImportPrepare('fs', ['new1', 'new2']); + const keys = ['b1', 'b2', 'b3', 'b4', 'b5', 'b6']; + await Promise.all(keys.map(key => pool.hImportSet(key, 'fs', ['v1', 'v2']))); + + for (const key of keys) { + assert.deepEqual(await pool.hGetAll(key), { new1: 'v1', new2: 'v2' }); + } + }, { + ...GLOBAL.SERVERS.OPEN, + poolOptions: { minimum: 3 } + }); + + testUtils.testWithClientPool('discard is coherent across connections (lazy reconcile)', async pool => { + await pool.hImportPrepare('fs', ['f1', 'f2']); + // Spread lazy prepares across connections so several sessions hold the fieldset. + await Promise.all( + ['a1', 'a2', 'a3', 'a4', 'a5', 'a6'].map(key => pool.hImportSet(key, 'fs', ['v1', 'v2'])) + ); + + // Registry-based reply: 1 regardless of which connection the DISCARD borrows. + assert.equal(await pool.hImportDiscard('fs'), 1); + assert.equal(await pool.hImportDiscard('fs'), 0); + + // No stale-SET success on ANY connection: sessions still holding the fieldset must + // reconcile the discard before the SET reaches the wire. + for (const key of ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']) { + await assert.rejects( + pool.hImportSet(key, 'fs', ['v1', 'v2']), + /no such fieldset/ + ); + } + }, { + ...GLOBAL.SERVERS.OPEN, + poolOptions: { minimum: 3 } + }); + }); + + describe('cluster', () => { + testUtils.testWithCluster('prepare fans out; SETs route by slot; discardAll is registry-based', async cluster => { + // Pins the §7 response-policy fix: without the all_succeeded override the fan-out + // reply reduction throws 'Unsupported reply type for merge aggregation'. + assert.equal(await cluster.hImportPrepare('fs', ['f1', 'f2']), 'OK'); + + // Keys hashing to different slots — each SET routes to its own master. + const keys = ['key:{1}', 'key:{2}', 'key:{3}', 'key:{4}', 'key:{5}']; + const replies = await Promise.all( + keys.map(key => cluster.hImportSet(key, 'fs', ['v1', 'v2'])) + ); + assert.deepEqual(replies, keys.map(() => 'OK')); + assert.deepEqual(await cluster.hGetAll('key:{3}'), { f1: 'v1', f2: 'v2' }); + + // Registry-based reply: exactly 1 registered fieldset removed — not a per-master + // sum, not a session count. + assert.equal(await cluster.hImportDiscardAll(), 1); + }, GLOBAL.CLUSTERS.OPEN); + }); +}); diff --git a/packages/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index 92e89f838d9..4bf145bc880 100644 --- a/packages/client/lib/sentinel/index.ts +++ b/packages/client/lib/sentinel/index.ts @@ -20,6 +20,7 @@ import { TcpNetConnectOpts } from 'node:net'; import { RedisTcpSocketOptions } from '../client/socket'; import { BasicPooledClientSideCache, PooledClientSideCacheProvider } from '../client/cache'; import { ClientIdentity, ClientRole, generateClientId } from '../client/identity'; +import { FieldsetRegistry } from '../himport/registry'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { ScanOptions } from '../commands/SCAN'; @@ -862,6 +863,10 @@ export class RedisSentinelInternal< if (this.#nodeClientOptions.url !== undefined) { throw new Error("invalid nodeClientOptions for Sentinel"); } + // One fieldset registry across master/replica node clients: fieldsets registered before + // a failover must be transparently re-preparable on the promoted master's connections. + // (Sentinel-monitor clients use #sentinelClientOptions and never run HIMPORT.) + this.#nodeClientOptions.himportRegistry = new FieldsetRegistry(); if (options.clientSideCache) { if (options.clientSideCache instanceof PooledClientSideCacheProvider) { diff --git a/packages/client/scripts/command-metadata-overrides.ts b/packages/client/scripts/command-metadata-overrides.ts index 4c88272a905..72137a1287a 100644 --- a/packages/client/scripts/command-metadata-overrides.ts +++ b/packages/client/scripts/command-metadata-overrides.ts @@ -94,6 +94,25 @@ export const COMMAND_OVERRIDES: Readonly del: { request: 'special', response: 'default-keyless', isKeyless: true } } }, + // HIMPORT session subcommands fan out to all masters (server-tipped + // request_policy:all_shards) but the server defines no response_policy, and the + // default-keyless reducer crashes on scalar fan-out replies (`aggregateMerge` throws + // 'Unsupported reply type for merge aggregation' for simple strings/integers whenever + // ≥2 masters reply). These reducers implement the HLD's "default fan-out handling" + // client-side with semantics preserved: + // - prepare → all_succeeded: every master must acknowledge; first `OK` is the reply. + // - discard/discardall → agg_max: the client hook substitutes registry-based replies + // (exactly one node client performs the registry mutation and returns the real 1/count, + // siblings return 0), so max recovers the registry answer. Request policies are NOT + // overridden — all_shards comes from the server's own tips. HIMPORT SET carries a key + // at position 2 and keeps default keyed routing. + 'std.himport': { + subcommands: { + prepare: { response: 'all_succeeded' }, + discard: { response: 'agg_max' }, + discardall: { response: 'agg_max' } + } + }, 'std.info': KEYLESS, 'std.memory': { subcommands: { doctor: KEYLESS, 'malloc-stats': KEYLESS, stats: KEYLESS } }, 'std.latency': { subcommands: { doctor: KEYLESS, graph: KEYLESS, histogram: KEYLESS, history: KEYLESS, latest: KEYLESS } },