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/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 bc45f5e4651..c1fabd795b6 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 | undefined }; + export interface CommandParser { redisArgs: ReadonlyArray; keys: ReadonlyArray; firstKey: RedisArgument | undefined; preserve: unknown; + commandIdentifier: CommandIdentifier; push: (...arg: Array) => unknown; pushVariadic: (vals: RedisVariadicArgument) => unknown; @@ -90,6 +93,16 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } + get commandIdentifier(): CommandIdentifier { + const rawCommand = this.#redisArgs[0]; + const rawSubcommand = this.#redisArgs[1]; + const command = rawCommand instanceof Buffer ? rawCommand.toString() : rawCommand; + const subcommand = rawSubcommand === undefined + ? undefined + : rawSubcommand instanceof Buffer ? rawSubcommand.toString() : rawSubcommand; + return { command, subcommand }; + } + push(...arg: Array) { this.#redisArgs.push(...arg); }; @@ -137,6 +150,17 @@ export class BasicCommandParser implements CommandParser { this.#addKey(key, applyPrefix); } + /** + * Records a routing key whose value is already present in the pushed args, + * without appending it again. Used by the raw cluster `sendCommand` path, + * where the caller supplies the routing key separately from the full, + * already-assembled argument list — so `firstKey` resolves for routing while + * `redisArgs` stays an exact copy of the command sent on the wire. + */ + markRoutingKey(key: RedisArgument) { + this.#keys.push(key); + } + pushKeysLength(keys: RedisVariadicArgument) { if (Array.isArray(keys)) { this.#redisArgs.push(keys.length.toString()); diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index a3d69688162..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; @@ -818,6 +840,23 @@ export default class RedisClusterSlots< } } + /** + * 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(): Promise>> { + return Promise.all(this.masters.map(master => this.nodeClient(master))); + } + async getClientAndSlotNumber( firstKey: RedisArgument | undefined, isReadonly: boolean | undefined @@ -931,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 0203f14f209..75fcceb01ae 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -10,12 +10,15 @@ 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, 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 | @@ -29,7 +32,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, @@ -180,11 +182,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( - parser.firstKey, + return this._self._executeWithPolicies( + parser, command.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -196,11 +198,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( - parser.firstKey, + return this._self._executeWithPolicies( + parser, command.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -214,11 +216,11 @@ export default class RedisCluster< parser.push(...prefix); fn.parseCommand(parser, ...args); - return this._self._execute( - parser.firstKey, + return this._self._executeWithPolicies( + parser, fn.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(fn, parser, opts, transformReply) + p => (client, opts) => client._executeCommand(fn, p, opts, transformReply) ); }; } @@ -232,11 +234,11 @@ export default class RedisCluster< parser.push(...prefix); script.parseCommand(parser, ...args); - return this._self._execute( - parser.firstKey, + return this._self._executeWithPolicies( + parser, script.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeScript(script, parser, opts, transformReply) + p => (client, opts) => client._executeScript(script, p, opts, transformReply) ); }; } @@ -294,6 +296,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`. @@ -374,6 +377,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< @@ -467,83 +472,175 @@ export default class RedisCluster< }; } - async _execute( - firstKey: RedisArgument | undefined, + /** + * 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 a `makeFn` factory that builds the per-client + * execution closure (command, script, or raw `sendCommand`). + */ + async _executeWithPolicies( + parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, - fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise + makeFn: (parser: CommandParser) => (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { - const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); - let i = 0; + const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); + + // 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]; + if (!router) { + throw new Error(`Unknown request policy ${requestPolicy}`); + } + // 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, + policy.keySpecs + ); + + if (plan.length === 0) { + throw new Error(`Request policy ${requestPolicy} produced no target nodes`); + } - let myFn = fn; + 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); + }); - while (true) { - try { - const opts: ClusterCommandOptions = { ...options, slotNumber }; - return await myFn(client, opts); - } catch (_err) { - const err = _err as Error; - myFn = fn; + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); + } + const positionHints = plan.map(entry => entry.groupIndices); + 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; + } + /** + * 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 + * `_executeWithPolicies`. + */ + 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; + + 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; + } - // TODO: error class - if (++i > maxCommandRedirections || !(err instanceof Error)) { - if (err instanceof Error) { + 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; - } - - 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; + throw err; } - - throw err; } - } } async sendCommand( @@ -559,11 +656,24 @@ export default class RedisCluster< ...this._commandOptions, ...options } - return this._self._execute( - firstKey, + + // `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(); + 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. 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) ); } 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/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 new file mode 100644 index 00000000000..a94c2e872fb --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -0,0 +1,305 @@ +import { BasicCommandParser, type CommandParser } from '../../client/parser'; +import type { RedisClientType } from '../../client'; +import type { + 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, + aggregateMax, + aggregateMerge, + aggregateMin, + aggregateSum +} from './generic-aggregators'; +import { + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS, + 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 +// `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, + keySpecs: ReadonlyArray | undefined +) => Promise>; + +export type ResponseReducer = ( + responsePromises: Promise[], + 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) => (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 + })); + }; + +/** + * 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) => + [{ 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; +} + +/** + * 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, 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.` + ); + return [{ client: slots.getRandomNode().client! }]; + }; + +// --- 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; +}; + +/** + * 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 => { + 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[], + _parser: CommandParser, + positionHints?: Array | undefined> +): Promise => { + const responses = await Promise.all(promises); + + // 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 --- + +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>; 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..c23f1cefa61 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -0,0 +1,146 @@ +import type { CommandReply } from '../../commands/generic-transformers'; +import type { CommandPolicies } from './policies-constants'; +import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; +import type { PolicyResolver, ModulePolicyRecords } from './types'; +import { StaticPolicyResolver } from './static-policy-resolver'; + +/** + * Function type that returns command information from Redis + */ +export type CommandFetcher = () => Promise>; + +/** + * A factory for creating policy resolvers that dynamically build policies based on the Redis server's COMMAND response. + * + * This factory fetches command information from Redis and analyzes the response to determine + * appropriate routing policies for each command, returning a StaticPolicyResolver with the built policies. + */ +export class DynamicPolicyResolverFactory { + /** + * Creates a StaticPolicyResolver by fetching command information from Redis + * and building appropriate policies based on the command characteristics. + * + * @param commandFetcher Function to fetch command information from Redis + * @param fallbackResolver Optional fallback resolver to use when policies are not found + * @returns A new StaticPolicyResolver with the fetched policies + */ + static async create( + commandFetcher: CommandFetcher, + fallbackResolver?: PolicyResolver + ): Promise { + const commands = await commandFetcher(); + const policies = 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) { + 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 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 CommandPolicies for a command based on its characteristics. + * + * Priority order: + * 1. Use explicit policies from the command if available + * 2. Classify as DEFAULT_KEYLESS if keySpecification is empty + * 3. Classify as DEFAULT_KEYED if keySpecification is not empty + */ + static #buildCommandPolicies(command: CommandReply): CommandPolicies { + // Determine if command is keyless based on keySpecification + const isKeyless = command.isKeyless + + // Determine default policies based on key specification + const defaultRequest = isKeyless + ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + const defaultResponse = isKeyless + ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + + let subcommands: Record | undefined; + if(command.subcommands.length > 0) { + subcommands = {}; + for (const subcommand of command.subcommands) { + + // Subcommands are in format "parentCommand|subcommand" + const parts = subcommand.name.split("\|") + if(parts.length !== 2) { + throw new Error(`Invalid subcommand name: ${subcommand.name}`); + } + const subcommandName = parts[1]; + + subcommands[subcommandName] = DynamicPolicyResolverFactory.#buildCommandPolicies(subcommand); + } + } + + const request = command.policies.request ?? defaultRequest; + + return { + 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 + }; + } +} \ 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..f38843dae25 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -0,0 +1,388 @@ +import { strict as assert } from 'node:assert'; +import type { CommandReply } from '../../commands/generic-transformers'; +import { DynamicPolicyResolverFactory, type CommandFetcher, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from '.'; +import testUtils, { GLOBAL } from '../../test-utils'; + + +const createMockCommandFetcher = (commands: Array): CommandFetcher => async () => commands; + +describe('DynamicPolicyResolverFactory', () => { + + describe('create', () => { + it('should create StaticPolicyResolver with empty policies', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + assert.ok(resolver instanceof StaticPolicyResolver); + }); + + it('should create StaticPolicyResolver with fallback', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const fallbackResolver = new StaticPolicyResolver({ + std: { + ping: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true + } + } + }); + + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); + assert.ok(resolver instanceof StaticPolicyResolver); + + const result = resolver.resolvePolicy({ command: 'ping', 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('create with commands', () => { + it('should classify keyless commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'ping', + arity: -1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + 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); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('should classify keyed commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'get', + arity: 2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: false, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + 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); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + }); + + it('should use explicit policies when available', async () => { + const mockCommands: Array = [ + { + name: 'dbsize', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'agg_sum'); + } + }); + + 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 = [ + { + name: 'ft.search', + arity: -2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + policies: { request: 'all_shards', response: 'special' }, + isKeyless: false, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy({ command: 'ft.search', subcommand: undefined }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'special'); + } + }); + + it('should handle valid module commands', async () => { + const mockCommands: Array = [ + { + name: 'json.get', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + 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); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('resolvePolicy', () => { + it('should work with created resolver', async () => { + const mockCommands: Array = [ + { + name: 'test', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy({ command: 'test', subcommand: undefined }); + assert.equal(result.ok, true); + }); + + it('should handle unknown commands', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy({ command: 'unknown', subcommand: undefined }); + 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({ command: 'unknown.command', subcommand: undefined }); + 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({ command: 'too.many.dots.here', subcommand: undefined }); + assert.equal(result.ok, false); + assert.equal(result.error, 'wrong-command-or-module-name'); + }); + }); + + describe('edge cases', () => { + it('should handle commands with partial policies', async () => { + const mockCommands: Array = [ + { + name: 'partial-request', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false, + keySpecs: [], + subcommands: [] + }, + { + name: 'partial-response', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: undefined, response: 'agg_sum' }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + // Command with only request policy should fall back to defaults + let result = resolver.resolvePolicy({ command: 'partial-request', subcommand: undefined }); + 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({ command: 'partial-response', 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.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({ command: 'any-command', subcommand: undefined }); + 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({ 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); + } else { + assert.fail('Expected pingResult.ok to be true'); + } + + // Test that get command is classified as keyed + 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); + } else { + assert.fail('Expected getResult.ok to be true'); + } + + // Test that dbsize command uses explicit policies if available + const dbsizeResult = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); + + 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/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 new file mode 100644 index 00000000000..161f73f6f72 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -0,0 +1,101 @@ +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` 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, + 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 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, + 'FT.DICTADD': KEYLESS, + 'FT.DICTDEL': KEYLESS, + 'FT.DICTDUMP': KEYLESS, + 'FT.SUGLEN': KEYED, + 'FT.CURSOR': SPECIAL_CURSOR, + '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('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); + }); + + 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/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts new file mode 100644 index 00000000000..16de711f2c4 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -0,0 +1,124 @@ +/** + * 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; + } + + throw new Error('Unsupported reply type for merge aggregation'); + +}; diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts new file mode 100644 index 00000000000..a06604a8961 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -0,0 +1,11 @@ +export type { Either, PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; + +export { StaticPolicyResolver } from './static-policy-resolver'; +export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; + +export * from './policies-constants'; +export { POLICIES } from './static-policies-data'; +export * 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..83edc56a577 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts @@ -0,0 +1,198 @@ +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], + keyPositions: [1, 2, 3] + }); + }); + + 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], + keyPositions: [1, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['DEL', '{b}1'], + groupIndices: [1], + keyPositions: [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], + keyPositions: [1, 3] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSET', '{b}1', 'v2'], + groupIndices: [1], + keyPositions: [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], + keyPositions: [2, 4] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSETEX', '1', '{b}1', 'v2', 'NX', 'EX', '10'], + groupIndices: [1], + keyPositions: [2] + }); + }); + + 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..0e27d06a564 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -0,0 +1,160 @@ +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; + /** + * 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; +}; + +/** + * 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). Keys keep their + // original absolute positions. + if (slotGroups.size === 1) { + const [[slot, groupIndices]] = slotGroups; + const keyPositions = groupIndices.map(group => keyRegionStart + group * keyStep); + subCommands.set(slot, { args: [...args], groupIndices, keyPositions }); + 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(); + } + // 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, keyPositions }); + } + + 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; +} 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..2abe0ef69f4 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -0,0 +1,124 @@ +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. + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ + ALL_NODES: "all_nodes", + /** + * The client should execute the command on all master shards (e.g., the DBSIZE command). + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ + ALL_SHARDS: "all_shards", + /** + * The client should execute the command on several shards. + * The client should split the inputs according to the hash slots of its input key name arguments. + * For example, the command DEL {foo} {foo}1 bar should be split to DEL {foo} {foo}1 and DEL bar. + * If the keys are hashed to more than a single slot, + * the command must be split even if all the slots are managed by the same shard. + * Examples for such commands include MSET, MGET and DEL. + * However, note that SUNIONSTORE isn't considered as multi_shard because all of its keys must belong to the same hash slot. + */ + MULTI_SHARD: "multi_shard", + /** + * Indicates a non-trivial form of the client's request policy, such as the SCAN command. + */ + SPECIAL: "special", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 1. The command doesn't accept key name arguments: + * the client can execute the command on an arbitrary shard. + */ + DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 2. For commands that accept one or more key name arguments: + * the client should route the command to a single shard, + * as determined by the hash slot of the input keys. + */ + DEFAULT_KEYED: "default-keyed" +} as const; + +export type RequestPolicyWithDefaults = typeof REQUEST_POLICIES_WITH_DEFAULTS[keyof typeof REQUEST_POLICIES_WITH_DEFAULTS]; + +export const RESPONSE_POLICIES_WITH_DEFAULTS = { + /** + * The client should return success if at least one shard didn't reply with an error. + * The client should reply with the first non-error reply it obtains. + * If all shards return an error, the client can reply with any one of these. + * Example: SCRIPT KILL command that's sent to all shards. + */ + ONE_SUCCEEDED: "one_succeeded", + /** + * The client should return successfully only if there are no error replies. + * Even a single error reply should disqualify the aggregate and be returned. + * Otherwise, the client should return one of the non-error replies. + * Examples: CONFIG SET, SCRIPT FLUSH and SCRIPT LOAD commands. + */ + ALL_SUCCEEDED: "all_succeeded", + /** + * The client should return the result of a logical AND operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + * Example: SCRIPT EXISTS command returns 1 only when all shards report that a given script SHA1 sum is in their cache. + */ + AGG_LOGICAL_AND: "agg_logical_and", + /** + * The client should return the result of a logical OR operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + */ + AGG_LOGICAL_OR: "agg_logical_or", + /** + * The client should return the minimal value from the replies. + * Only applies to numerical replies. + * Example: WAIT command should return the minimal number of synchronized replicas from all shards. + */ + AGG_MIN: "agg_min", + /** + * The client should return the maximal value from the replies. + * Only applies to numerical replies. + */ + AGG_MAX: "agg_max", + /** + * The client should return the sum of replies. + * Only applies to numerical replies. + * Example: DBSIZE command. + */ + AGG_SUM: "agg_sum", + /** + * Indicates a non-trivial form of reply policy. + * Example: INFO command with complex aggregation logic. + */ + SPECIAL: "special", + /** + * The default behavior for commands without a response_policy tip that don't accept key name arguments: + * the client can aggregate all replies within a single nested data structure. + * Example: KEYS command replies should be packed in a single array in no particular order. + */ + DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior for commands without a response_policy tip that accept one or more key name arguments: + * the client needs to retain the same order of replies as the input key names. + * Example: MGET's aggregated reply should maintain key order. + */ + DEFAULT_KEYED: "default-keyed" +} as const; + +export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof typeof RESPONSE_POLICIES_WITH_DEFAULTS]; + +export interface CommandPolicies { + readonly request: RequestPolicyWithDefaults; + readonly response: ResponsePolicyWithDefaults; + readonly subcommands?: Record; + readonly isKeyless: boolean; + /** + * 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 new file mode 100644 index 00000000000..e3ecd8abf29 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -0,0 +1,2928 @@ +// 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/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 new file mode 100644 index 00000000000..4efc5c0254c --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -0,0 +1,97 @@ +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( + policies: ModulePolicyRecords = POLICIES, + fallbackResolver?: PolicyResolver + ) { + this.policies = lowercaseModulePolicies(policies); + this.fallbackResolver = fallbackResolver || null; + } + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new StaticPolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): StaticPolicyResolver { + return new StaticPolicyResolver(this.policies, fallbackResolver); + } + + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { + const parts = commandIdentifier.command.toLowerCase().split('.'); + + if (parts.length > 2) { + return { ok: false, error: 'wrong-command-or-module-name' }; + } + + const [moduleName, commandName] = parts.length === 1 + ? ['std', parts[0]] + : parts; + + if (!this.policies[moduleName]) { + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(commandIdentifier); + } + + // For std module commands, return 'unknown-command' instead of 'unknown-module' + // to provide better UX for single-word commands + if (moduleName === 'std') { + return { ok: false, error: 'unknown-command' }; + } + return { ok: false, error: 'unknown-module' }; + } + + if (!this.policies[moduleName][commandName]) { + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(commandIdentifier); + } + return { ok: false, error: 'unknown-command' }; + } + + const policy = this.policies[moduleName][commandName]; + + 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 + }; + } +} 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..739d892ecbe --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -0,0 +1,25 @@ +import testUtils, { GLOBAL } from '../../test-utils'; +import RediSearch from '@redis/search'; + +import RedisBloomModules from '@redis/bloom'; +import RedisJSON from '@redis/json'; +import RedisTimeSeries from '@redis/time-series'; + +describe('Cluster Request-Response Policies', () => { + + testUtils.testWithCluster('should resolve policies correctly', async cluster => { + + await cluster.ft.SUGADD('index', 'string', 1); + + }, { + ...GLOBAL.CLUSTERS.OPEN, + clusterConfiguration: { + modules: { + ft: RediSearch, + // ...RedisBloomModules, + // json: RedisJSON, + // ts: RedisTimeSeries + }, + } + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts new file mode 100644 index 00000000000..027746710a6 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -0,0 +1,29 @@ +import { CommandIdentifier } from '../../client/parser'; +import type { CommandPolicies } from './policies-constants'; + +export type Either = + | { readonly ok: true; readonly value: TOk } + | { readonly ok: false; readonly error: TError }; + +export type PolicyResult = Either; + + +export interface PolicyResolver { + /** + * The response of the COMMAND command uses "." to separate the module name from the command name. + */ + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult; + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new PolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): PolicyResolver; +} + +export type CommandPolicyRecords = Record; +// The response of the COMMAND command uses "." to separate the module name from the command name. +// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. +export type ModulePolicyRecords = Record; diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 860ffc30685..6646dffd76a 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,17 +1,319 @@ -// import { strict as assert } from 'node:assert'; -// import testUtils, { GLOBAL } from '../test-utils'; -// import { transformArguments } from './COMMAND'; -// import { assertPingCommand } from './COMMAND_INFO.spec'; - -// describe('COMMAND', () => { -// it('transformArguments', () => { -// assert.deepEqual( -// transformArguments(), -// ['COMMAND'] -// ); -// }); - -// testUtils.testWithClient('client.command', async client => { -// assertPingCommand((await client.command()).find(command => command.name === 'ping')); -// }, GLOBAL.SERVERS.OPEN); -// }); +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { parseArgs, transformCommandReply, transformKeySpec, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; +import COMMAND from './COMMAND'; + +describe('COMMAND', () => { + it('transformArguments', () => { + assert.deepEqual( + parseArgs(COMMAND), + ['COMMAND'] + ); + }); + + 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, + keySpecs: [], + subcommands: [] + } + }, + { + name: 'with valid policies', + input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], [], []] satisfies CommandRawReply, + expected: { + name: 'dbsize', + arity: 1, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true, + keySpecs: [], + subcommands: [] + } + }, + { + name: 'with invalid policies', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: undefined }, + isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], + subcommands: [] + } + }, + { + name: 'with request policy only', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], + 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, + 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, + keySpecs: [], + 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, + keySpecs: [], + 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, + keySpecs: [], + 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, + keySpecs: [], + 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, + keySpecs: [], + subcommands: [] + } + } + ]; + + testCases.forEach(testCase => { + it(testCase.name, () => { + assert.deepEqual( + transformCommandReply(testCase.input), + testCase.expected + ); + }); + }); + }); + + 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(); + + 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'); + + 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 82479e6c1fb..b4dfe9e9ed5 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'; @@ -342,9 +343,23 @@ export type CommandRawReply = [ firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Array + categories: Array, + tips: 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, arity: number, @@ -352,13 +367,132 @@ export type CommandReply = { firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Set + 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]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply ): CommandReply { + + + // 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); + return { name, arity, @@ -366,7 +500,14 @@ export function transformCommandReply( firstKeyIndex, lastKeyIndex, step, - categories: new Set(categories) + categories: new Set(categories), + policies: { + request: requestPolicy, + response: responsePolicy + }, + isKeyless: keySpecifications.length === 0, + keySpecs: keySpecifications.map(transformKeySpec), + subcommands }; } 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..c0b3fd5252c --- /dev/null +++ b/packages/client/scripts/static-policies-overrides.ts @@ -0,0 +1,63 @@ +/** + * 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 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: 'special', + response: 'default-keyless', + 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 7837e029152..8c41328d472 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -38,5 +38,17 @@ export const GLOBAL = { } } } + }, + CLUSTERS: { + OPEN: { + numberOfMasters: 3, + serverArguments: [], + clusterConfiguration: { + RESP: 3 as const, + modules: { + ft: RediSearch + } + } + } } }; 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}`); + } +}