diff --git a/adapter/redis.go b/adapter/redis.go index f21aa8c3..33b00f7a 100644 --- a/adapter/redis.go +++ b/adapter/redis.go @@ -30,6 +30,7 @@ import ( const ( cmdBZPopMin = "BZPOPMIN" cmdClient = "CLIENT" + cmdCommand = "COMMAND" cmdDBSize = "DBSIZE" cmdDel = "DEL" cmdDiscard = "DISCARD" @@ -167,6 +168,7 @@ var txnApplyHandlers = map[string]txnCommandHandler{ var argsLen = map[string]int{ cmdBZPopMin: -3, cmdClient: -2, + cmdCommand: -1, cmdDBSize: 1, cmdDel: -2, cmdDiscard: 1, @@ -417,6 +419,7 @@ func NewRedisServer(listen net.Listener, redisAddr string, store store.MVCCStore r.route = map[string]func(conn redcon.Conn, cmd redcon.Command){ cmdBZPopMin: r.bzpopmin, cmdClient: r.client, + cmdCommand: r.command, cmdDBSize: r.dbsize, cmdDel: r.del, cmdDiscard: r.discard, diff --git a/adapter/redis_command_info.go b/adapter/redis_command_info.go new file mode 100644 index 00000000..7f05b473 --- /dev/null +++ b/adapter/redis_command_info.go @@ -0,0 +1,256 @@ +package adapter + +// redis_command_info.go holds the static metadata table consumed by the +// Redis `COMMAND` handler. It is intentionally a single, grep-able file so +// that adding a new command is a one-liner: +// +// 1) Register the new handler in RedisServer.route (redis.go). +// 2) Add an argsLen entry (redis.go). +// 3) Add a row below. Forgetting step 3 is NOT fatal — the COMMAND +// handler falls back to a zero-metadata entry and emits one warning +// log per command name so the omission is discoverable — but you +// should do step 3 anyway. +// +// The table is the source of truth for `COMMAND`, `COMMAND INFO`, +// `COMMAND COUNT`, `COMMAND LIST`, `COMMAND DOCS`, and `COMMAND GETKEYS`. +// +// Shape notes (Redis reference): +// - arity: exact positive arity, or negative meaning "at least |arity|" +// - flags: one of "readonly" | "write" | "admin". We do NOT currently +// emit "denyoom" / "pubsub" / "loading" / "stale" / "fast" etc. — +// real Redis clients only consume this field for coarse routing. +// - first_key / last_key / step describe the key positions inside the +// argv. first_key=0 means the command operates on zero keys (pure +// connection / server commands). last_key=-1 means "all remaining +// args are keys" (MSET-shaped). step=1 means keys are consecutive; +// step=2 is used by MSET-like key/value pairs. + +import ( + "log" + "sort" + "strings" + "sync" +) + +// redisCommandFlag values are string constants so the raw strings are not +// duplicated across the table. +const ( + redisCmdFlagReadonly = "readonly" + redisCmdFlagWrite = "write" + redisCmdFlagAdmin = "admin" +) + +// redisCommandMeta is a single row in the COMMAND table. +type redisCommandMeta struct { + // Name is the lowercase command name as reported by Redis. Keyed in the + // table by uppercase for dispatch-time lookup; the lowercase form is + // what goes onto the wire in COMMAND INFO. + Name string + Arity int + Flags []string + FirstKey int + LastKey int + Step int +} + +// redisCommandTable maps UPPERCASE command name -> metadata. Every entry +// routed by RedisServer.route should appear here. Entries are listed in +// alphabetical order to keep diffs small when adding new commands. +// +//nolint:mnd // magic numbers here are literal Redis metadata (arity, key positions) +var redisCommandTable = map[string]redisCommandMeta{ + "BZPOPMIN": {Name: "bzpopmin", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: -2, Step: 1}, + "CLIENT": {Name: "client", Arity: -2, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "COMMAND": {Name: "command", Arity: -1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "DBSIZE": {Name: "dbsize", Arity: 1, Flags: []string{redisCmdFlagReadonly}, FirstKey: 0, LastKey: 0, Step: 0}, + "DEL": {Name: "del", Arity: -2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: -1, Step: 1}, + "DISCARD": {Name: "discard", Arity: 1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "EVAL": {Name: "eval", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 0, LastKey: 0, Step: 0}, + "EVALSHA": {Name: "evalsha", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 0, LastKey: 0, Step: 0}, + "EXEC": {Name: "exec", Arity: 1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "EXISTS": {Name: "exists", Arity: -2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: -1, Step: 1}, + "EXPIRE": {Name: "expire", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "FLUSHALL": {Name: "flushall", Arity: 1, Flags: []string{redisCmdFlagWrite}, FirstKey: 0, LastKey: 0, Step: 0}, + "FLUSHDB": {Name: "flushdb", Arity: 1, Flags: []string{redisCmdFlagWrite}, FirstKey: 0, LastKey: 0, Step: 0}, + // FLUSHLEGACY is an elastickv-internal alias; mirror FLUSHDB metadata. + "FLUSHLEGACY": {Name: "flushlegacy", Arity: 1, Flags: []string{redisCmdFlagWrite}, FirstKey: 0, LastKey: 0, Step: 0}, + "GET": {Name: "get", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "GETDEL": {Name: "getdel", Arity: 2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "HDEL": {Name: "hdel", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "HEXISTS": {Name: "hexists", Arity: 3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "HGET": {Name: "hget", Arity: 3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "HGETALL": {Name: "hgetall", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "HINCRBY": {Name: "hincrby", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "HLEN": {Name: "hlen", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "HMGET": {Name: "hmget", Arity: -3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "HMSET": {Name: "hmset", Arity: -4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "HSET": {Name: "hset", Arity: -4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "INCR": {Name: "incr", Arity: 2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "INFO": {Name: "info", Arity: -1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "KEYS": {Name: "keys", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 0, LastKey: 0, Step: 0}, + "LINDEX": {Name: "lindex", Arity: 3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "LLEN": {Name: "llen", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "LPOP": {Name: "lpop", Arity: 2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "LPOS": {Name: "lpos", Arity: -3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "LPUSH": {Name: "lpush", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "LRANGE": {Name: "lrange", Arity: 4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "LREM": {Name: "lrem", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "LSET": {Name: "lset", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "LTRIM": {Name: "ltrim", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "MULTI": {Name: "multi", Arity: 1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "PEXPIRE": {Name: "pexpire", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "PFADD": {Name: "pfadd", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "PFCOUNT": {Name: "pfcount", Arity: -2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: -1, Step: 1}, + "PING": {Name: "ping", Arity: -1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "PTTL": {Name: "pttl", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "PUBLISH": {Name: "publish", Arity: 3, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "PUBSUB": {Name: "pubsub", Arity: -2, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "QUIT": {Name: "quit", Arity: 1, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "RENAME": {Name: "rename", Arity: 3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 2, Step: 1}, + "RPOP": {Name: "rpop", Arity: 2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "RPOPLPUSH": {Name: "rpoplpush", Arity: 3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 2, Step: 1}, + "RPUSH": {Name: "rpush", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SADD": {Name: "sadd", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SCAN": {Name: "scan", Arity: -2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 0, LastKey: 0, Step: 0}, + "SCARD": {Name: "scard", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "SELECT": {Name: "select", Arity: 2, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "SET": {Name: "set", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SETEX": {Name: "setex", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SETNX": {Name: "setnx", Arity: 3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SISMEMBER": {Name: "sismember", Arity: 3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "SMEMBERS": {Name: "smembers", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "SREM": {Name: "srem", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "SUBSCRIBE": {Name: "subscribe", Arity: -2, Flags: []string{redisCmdFlagAdmin}, FirstKey: 0, LastKey: 0, Step: 0}, + "TTL": {Name: "ttl", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "TYPE": {Name: "type", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "XADD": {Name: "xadd", Arity: -5, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "XLEN": {Name: "xlen", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "XRANGE": {Name: "xrange", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "XREAD": {Name: "xread", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 0, LastKey: 0, Step: 0}, + "XREVRANGE": {Name: "xrevrange", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "XTRIM": {Name: "xtrim", Arity: -4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZADD": {Name: "zadd", Arity: -4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZCARD": {Name: "zcard", Arity: 2, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZCOUNT": {Name: "zcount", Arity: 4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZINCRBY": {Name: "zincrby", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZPOPMIN": {Name: "zpopmin", Arity: -2, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZRANGE": {Name: "zrange", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZRANGEBYSCORE": {Name: "zrangebyscore", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZREM": {Name: "zrem", Arity: -3, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZREMRANGEBYRANK": {Name: "zremrangebyrank", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZREMRANGEBYSCORE": {Name: "zremrangebyscore", Arity: 4, Flags: []string{redisCmdFlagWrite}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZREVRANGE": {Name: "zrevrange", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZREVRANGEBYSCORE": {Name: "zrevrangebyscore", Arity: -4, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, + "ZSCORE": {Name: "zscore", Arity: 3, Flags: []string{redisCmdFlagReadonly}, FirstKey: 1, LastKey: 1, Step: 1}, +} + +// redisCommandFallbackWarnedOnce deduplicates the "missing metadata" log so +// that a hostile or buggy client probing the same unknown-but-routed name +// cannot generate unbounded log spam. The fallback is a safety net for +// commands that get added to the route but where the table row is +// forgotten; the unit test `TestCommand_RouteMatchesTable` is the hard +// gate, but in production we prefer a degraded reply + one log line over +// a silently-missing command. +var ( + redisCommandFallbackWarnedOnceMu sync.Mutex + redisCommandFallbackWarnedOnce = map[string]struct{}{} +) + +// routedRedisCommandMetas returns the metadata rows for every command +// currently routed (keyed via argsLen, which is populated 1:1 with the +// route map — see redis.go). Rows are returned in sorted UPPER-case order +// so wire output is deterministic. Names present in redisCommandTable +// produce their real row; names absent from the table but routed produce +// a zero-metadata row and a one-shot log warning. This is the source of +// truth for `COMMAND` (no args) and `COMMAND LIST`; `COMMAND INFO ` +// goes through redisCommandTable directly so unknowns produce the nil +// reply required by Redis semantics. +func routedRedisCommandMetas() []redisCommandMeta { + names := make([]string, 0, len(argsLen)) + for name := range argsLen { + names = append(names, strings.ToUpper(name)) + } + sort.Strings(names) + metas := make([]redisCommandMeta, 0, len(names)) + for _, name := range names { + if meta, ok := redisCommandTable[name]; ok { + metas = append(metas, meta) + continue + } + warnMissingRedisCommandMeta(name) + metas = append(metas, redisCommandMeta{ + Name: strings.ToLower(name), + Arity: -1, + Flags: nil, + FirstKey: 0, + LastKey: 0, + Step: 0, + }) + } + return metas +} + +// warnMissingRedisCommandMeta emits a one-shot warning when a routed +// command has no entry in redisCommandTable. Subsequent calls for the +// same name are silent so a hot dispatch path does not produce log spam. +func warnMissingRedisCommandMeta(upper string) { + redisCommandFallbackWarnedOnceMu.Lock() + _, warned := redisCommandFallbackWarnedOnce[upper] + if !warned { + redisCommandFallbackWarnedOnce[upper] = struct{}{} + } + redisCommandFallbackWarnedOnceMu.Unlock() + if !warned { + log.Printf("redis-command: routed command %q has no entry in redisCommandTable; emitting zero-metadata fallback. Add a row to adapter/redis_command_info.go.", upper) + } +} + +// redisCommandGetKeys extracts the key positions from a full command-form +// argv (argv[0] is the command name, argv[1:] are its arguments). +// Returns an error when the command is unknown; returns an empty slice when +// the command is routed but has no keys. +// +// Semantics mirror Redis's own COMMAND GETKEYS: +// - first_key=0: no keys (empty slice). +// - last_key=-1: "all args after first_key are keys". step controls spacing +// (step=1 → every arg; step=2 → every other arg, as in MSET). +// - last_key=-N (N>1): last key index is len(argv)-N. Commands like +// BZPOPMIN use -2 to exclude a trailing timeout arg that is NOT a key; +// treating every negative as "to end" would wrongly expose the timeout +// via COMMAND GETKEYS and break client key-routing decisions. +// - otherwise: args in [first_key .. last_key] at `step` stride. +// +// This is *positional*. It does not understand option prefixes (e.g. the +// `EX`/`PX` flags of SET); clients that need option-aware parsing would +// look at Redis 7's key-specs shape, which we explicitly do not emit. For +// the commands elastickv supports the naive positional scheme is correct. +func redisCommandGetKeys(meta redisCommandMeta, argv [][]byte) [][]byte { + if meta.FirstKey <= 0 { + return nil + } + if meta.Step <= 0 { + return nil + } + if meta.FirstKey >= len(argv) { + return nil + } + last := meta.LastKey + if last < 0 { + // Negative last_key is an offset from the end: -1 means the + // final arg, -2 means the second-to-last, and so on. Use + // len(argv)+last so BZPOPMIN (-2) excludes its trailing + // timeout argument instead of claiming the timeout as a key. + last = len(argv) + last + } + if last >= len(argv) { + last = len(argv) - 1 + } + if last < meta.FirstKey { + return nil + } + keys := make([][]byte, 0, (last-meta.FirstKey)/meta.Step+1) + for i := meta.FirstKey; i <= last; i += meta.Step { + keys = append(keys, argv[i]) + } + return keys +} diff --git a/adapter/redis_command_test.go b/adapter/redis_command_test.go new file mode 100644 index 00000000..b542bc58 --- /dev/null +++ b/adapter/redis_command_test.go @@ -0,0 +1,425 @@ +package adapter + +import ( + "net" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/redcon" +) + +// commandRecorder captures the sequence of writes performed by a handler +// so array-structured replies (like COMMAND INFO) can be asserted +// element-by-element rather than via the flat `recordingConn.bulk` field, +// which only remembers the last write. +// +// Each entry in `writes` is one of: +// +// {op: "array", n: } +// {op: "bulk", s: } +// {op: "int", i: } +// {op: "string", s: } +// {op: "null"} +// {op: "error", s: } +// +// Tests walk `writes` with a small cursor-based helper rather than trying +// to reconstruct a tree — reconstruction is more code than the tests it +// enables, and a flat trace is trivially greppable. +type commandRecorderEntry struct { + op string + s string + i int64 + n int +} + +type commandRecorder struct { + writes []commandRecorderEntry + ctx any +} + +func (c *commandRecorder) RemoteAddr() string { return "" } +func (c *commandRecorder) Close() error { return nil } +func (c *commandRecorder) WriteError(msg string) { + c.writes = append(c.writes, commandRecorderEntry{op: "error", s: msg}) +} +func (c *commandRecorder) WriteString(str string) { + c.writes = append(c.writes, commandRecorderEntry{op: "string", s: str}) +} +func (c *commandRecorder) WriteBulk(b []byte) { + c.writes = append(c.writes, commandRecorderEntry{op: "bulk", s: string(b)}) +} +func (c *commandRecorder) WriteBulkString(s string) { + c.writes = append(c.writes, commandRecorderEntry{op: "bulk", s: s}) +} +func (c *commandRecorder) WriteInt(num int) { + c.writes = append(c.writes, commandRecorderEntry{op: "int", i: int64(num)}) +} +func (c *commandRecorder) WriteInt64(num int64) { + c.writes = append(c.writes, commandRecorderEntry{op: "int", i: num}) +} +func (c *commandRecorder) WriteUint64(num uint64) { + c.writes = append(c.writes, commandRecorderEntry{op: "int", i: int64(num)}) //nolint:gosec +} +func (c *commandRecorder) WriteArray(count int) { + c.writes = append(c.writes, commandRecorderEntry{op: "array", n: count}) +} +func (c *commandRecorder) WriteNull() { + c.writes = append(c.writes, commandRecorderEntry{op: "null"}) +} +func (c *commandRecorder) WriteRaw([]byte) {} +func (c *commandRecorder) WriteAny(any) {} +func (c *commandRecorder) Context() any { return c.ctx } +func (c *commandRecorder) SetContext(v any) { c.ctx = v } +func (c *commandRecorder) SetReadBuffer(int) {} +func (c *commandRecorder) Detach() redcon.DetachedConn { return nil } +func (c *commandRecorder) ReadPipeline() []redcon.Command { return nil } +func (c *commandRecorder) PeekPipeline() []redcon.Command { return nil } +func (c *commandRecorder) NetConn() net.Conn { return nil } + +// helper: skip `count` entries starting from writes[start], treating the +// current entry as an array header. Returns (start+1, size). Unused for +// now — kept simple by manually walking indices in individual tests. + +// newCommandTestServer returns a RedisServer sufficient for COMMAND tests. +// COMMAND has no dependency on coordinator / store, so all fields stay +// zero. We use a literal construction rather than NewRedisServer because +// the latter requires a real listener + coordinator. +func newCommandTestServer() *RedisServer { + return &RedisServer{} +} + +func runCommand(t *testing.T, args ...string) *commandRecorder { + t.Helper() + srv := newCommandTestServer() + conn := &commandRecorder{} + cmdArgs := make([][]byte, 0, len(args)) + for _, a := range args { + cmdArgs = append(cmdArgs, []byte(a)) + } + srv.command(conn, redcon.Command{Args: cmdArgs}) + return conn +} + +func TestCommandCount_MatchesTableSize(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "COUNT") + require.Len(t, conn.writes, 1) + require.Equal(t, "int", conn.writes[0].op) + // COUNT reports the size of the routed set (argsLen); the table must + // have the same size by the route-matches-table invariant. + require.Equal(t, int64(len(argsLen)), conn.writes[0].i) + require.Equal(t, len(argsLen), len(redisCommandTable)) +} + +func TestCommandNoArgs_ReturnsAllEntries(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND") + require.NotEmpty(t, conn.writes) + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, len(argsLen), conn.writes[0].n) +} + +func TestCommandInfo_Get(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "INFO", "GET") + // outer array of 1 + 6-element inner. + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 1, conn.writes[0].n) + require.Equal(t, "array", conn.writes[1].op) + require.Equal(t, 6, conn.writes[1].n) + require.Equal(t, "bulk", conn.writes[2].op) + require.Equal(t, "get", conn.writes[2].s) + require.Equal(t, "int", conn.writes[3].op) + require.Equal(t, int64(2), conn.writes[3].i) + // flags array + require.Equal(t, "array", conn.writes[4].op) + flagN := conn.writes[4].n + flags := make([]string, 0, flagN) + for i := 0; i < flagN; i++ { + flags = append(flags, conn.writes[5+i].s) + } + require.Contains(t, flags, "readonly") + // first / last / step + require.Equal(t, int64(1), conn.writes[5+flagN].i) + require.Equal(t, int64(1), conn.writes[6+flagN].i) + require.Equal(t, int64(1), conn.writes[7+flagN].i) +} + +func TestCommandInfo_Set(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "INFO", "SET") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 1, conn.writes[0].n) + require.Equal(t, "array", conn.writes[1].op) + require.Equal(t, 6, conn.writes[1].n) + require.Equal(t, "set", conn.writes[2].s) + require.Equal(t, int64(-3), conn.writes[3].i) + require.Equal(t, "array", conn.writes[4].op) + flagN := conn.writes[4].n + flags := make([]string, 0, flagN) + for i := 0; i < flagN; i++ { + flags = append(flags, conn.writes[5+i].s) + } + require.Contains(t, flags, "write") + require.Equal(t, int64(1), conn.writes[5+flagN].i) + require.Equal(t, int64(1), conn.writes[6+flagN].i) + require.Equal(t, int64(1), conn.writes[7+flagN].i) +} + +func TestCommandInfo_UnknownReturnsNil(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "INFO", "nosuchcommand") + // outer array of 1, then a nil entry. + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 1, conn.writes[0].n) + require.Equal(t, "null", conn.writes[1].op) +} + +func TestCommandInfo_MixedKnownUnknown(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "INFO", "GET", "NOSUCH", "SET") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 3, conn.writes[0].n) + // first entry: 6-element GET array header. + require.Equal(t, "array", conn.writes[1].op) + require.Equal(t, 6, conn.writes[1].n) + // Walk past GET: outer-array-header(1) + inner-array-header(1) + name(1) + // + arity(1) + flags-header(1) + flagN flag strings + 3 ints. + flagN := conn.writes[4].n + cursor := 5 + flagN + 3 + // cursor now at NOSUCH → must be nil. + require.Equal(t, "null", conn.writes[cursor].op) + cursor++ + // SET entry: 6-element header then its fields. + require.Equal(t, "array", conn.writes[cursor].op) + require.Equal(t, 6, conn.writes[cursor].n) + require.Equal(t, "set", conn.writes[cursor+1].s) +} + +func TestCommandGetKeys_SingleKey(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "GETKEYS", "SET", "foo", "bar") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 1, conn.writes[0].n) + require.Equal(t, "bulk", conn.writes[1].op) + require.Equal(t, "foo", conn.writes[1].s) +} + +func TestCommandGetKeys_MSETLike_DEL(t *testing.T) { + t.Parallel() + // DEL has first_key=1, last_key=-1, step=1 (every arg after DEL). + conn := runCommand(t, "COMMAND", "GETKEYS", "DEL", "k1", "k2", "k3") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 3, conn.writes[0].n) + require.Equal(t, "k1", conn.writes[1].s) + require.Equal(t, "k2", conn.writes[2].s) + require.Equal(t, "k3", conn.writes[3].s) +} + +// MSET is not in the table (elastickv doesn't route it), so we verify the +// sibling shape via MSET-like args using another multi-key command we DO +// support — HMSET has first_key=1, last_key=1, step=1 (single key). The +// spec-provided MSET example in the ticket is checked against the raw +// redisCommandGetKeys helper below rather than the handler. +func TestRedisCommandGetKeysHelper_MSET(t *testing.T) { + t.Parallel() + // Synthetic MSET-shaped metadata: first=1, last=-1, step=2. + meta := redisCommandMeta{FirstKey: 1, LastKey: -1, Step: 2} + argv := [][]byte{[]byte("MSET"), []byte("k1"), []byte("v1"), []byte("k2"), []byte("v2")} + keys := redisCommandGetKeys(meta, argv) + got := make([]string, 0, len(keys)) + for _, k := range keys { + got = append(got, string(k)) + } + require.Equal(t, []string{"k1", "k2"}, got) +} + +func TestCommandGetKeys_UnknownCommand(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "GETKEYS", "NOSUCH") + require.Len(t, conn.writes, 1) + require.Equal(t, "error", conn.writes[0].op) + require.Contains(t, conn.writes[0].s, "Invalid command specified") +} + +// BZPOPMIN declares last_key=-2: the final argv entry is a blocking timeout +// that must NOT be reported as a key. This pins the negative-offset +// semantics so a future change that collapses all negatives to "to end" +// would trip the test instead of silently mis-routing client writes. +func TestCommandGetKeys_BZPOPMIN_ExcludesTimeout(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "GETKEYS", "BZPOPMIN", "k1", "k2", "0") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 2, conn.writes[0].n) + require.Equal(t, "bulk", conn.writes[1].op) + require.Equal(t, "k1", conn.writes[1].s) + require.Equal(t, "bulk", conn.writes[2].op) + require.Equal(t, "k2", conn.writes[2].s) +} + +// Pin the helper against synthetic -2 metadata as well, matching the +// MSET helper test above. Guards the len(argv)+last arithmetic directly. +func TestRedisCommandGetKeysHelper_NegativeLastKeyOffset(t *testing.T) { + t.Parallel() + // BZPOPMIN-shaped: first=1, last=-2 (exclude trailing timeout), step=1. + meta := redisCommandMeta{FirstKey: 1, LastKey: -2, Step: 1} + argv := [][]byte{[]byte("BZPOPMIN"), []byte("k1"), []byte("k2"), []byte("0")} + keys := redisCommandGetKeys(meta, argv) + got := make([]string, 0, len(keys)) + for _, k := range keys { + got = append(got, string(k)) + } + require.Equal(t, []string{"k1", "k2"}, got) +} + +func TestCommandList_ReturnsAllNames(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "LIST") + require.Equal(t, "array", conn.writes[0].op) + // The outer length equals the number of routed commands (argsLen is + // the 1:1 route-keyed set; redisCommandTable mirrors it because of + // TestCommand_RouteMatchesTable's invariant). + require.Equal(t, len(argsLen), conn.writes[0].n) + names := make([]string, 0, conn.writes[0].n) + for i := 1; i <= conn.writes[0].n; i++ { + require.Equal(t, "bulk", conn.writes[i].op) + names = append(names, conn.writes[i].s) + } + require.Contains(t, names, "get") + require.Contains(t, names, "set") + require.Contains(t, names, "command") + // Names must be sorted by UPPERCASE key. Reconstruct the expected + // ordering from the table rather than a separate sort helper so this + // remains a single source of truth. + upper := make([]string, 0, len(names)) + for _, n := range names { + upper = append(upper, strings.ToUpper(n)) + } + require.True(t, slices.IsSorted(upper), "names must be emitted in sorted UPPER order, got %v", upper) +} + +func TestCommandList_RejectsFilterBy(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "LIST", "FILTERBY", "MODULE", "foo") + require.Len(t, conn.writes, 1) + require.Equal(t, "error", conn.writes[0].op) + require.Contains(t, conn.writes[0].s, "unsupported") +} + +func TestCommandDocs_Get(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "DOCS", "GET") + // RESP2 flat-map shape: outer array of 2 (name + doc-map pair), + // then a name bulk string, then a 4-element map-shaped doc-map. + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 2, conn.writes[0].n, "outer array must be (name, docs) pair") + require.Equal(t, "bulk", conn.writes[1].op) + require.Equal(t, "get", conn.writes[1].s) + require.Equal(t, "array", conn.writes[2].op) + require.Equal(t, 4, conn.writes[2].n) + require.Equal(t, "summary", conn.writes[3].s) + require.Equal(t, "", conn.writes[4].s) + require.Equal(t, "arguments", conn.writes[5].s) + require.Equal(t, "array", conn.writes[6].op) + require.Equal(t, 0, conn.writes[6].n) +} + +// TestCommandDocs_BareReturnsAllDocs pins that bare COMMAND DOCS +// (no command names) returns docs for every routed command, matching +// real Redis semantics. Previously bare DOCS returned an empty +// array, which broke redis-cli --docs and any client that relied on +// the default full-docs behaviour. +func TestCommandDocs_BareReturnsAllDocs(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "DOCS") + require.Equal(t, "array", conn.writes[0].op) + // Outer array is 2 × routed-command-count (each command contributes + // a name key and a doc-map value slot). + wantOuterLen := len(routedRedisCommandMetas()) * 2 + require.Equal(t, wantOuterLen, conn.writes[0].n, + "bare COMMAND DOCS must emit (name, docs) for every routed command") + // First pair: name bulk, then 4-element doc map. + require.Equal(t, "bulk", conn.writes[1].op) + require.NotEmpty(t, conn.writes[1].s) + require.Equal(t, "array", conn.writes[2].op) + require.Equal(t, 4, conn.writes[2].n) + require.Equal(t, "summary", conn.writes[3].s) +} + +// TestCommandDocs_UnknownReturnsNamedNil pins the Redis "unknown +// command" shape for DOCS: the requested name key is still written +// (preserving the flat-map layout) but its value is nil. A client +// decoding the response as a map then sees `"FOO" -> nil`, which is +// the canonical "we acknowledged your question, we just have no +// entry" reply. +func TestCommandDocs_UnknownReturnsNamedNil(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "DOCS", "NOSUCH") + require.Equal(t, "array", conn.writes[0].op) + require.Equal(t, 2, conn.writes[0].n) + require.Equal(t, "bulk", conn.writes[1].op) + require.Equal(t, "NOSUCH", conn.writes[1].s) + require.Equal(t, "null", conn.writes[2].op) +} + +func TestCommand_UnknownSubcommand(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "BADSUB") + require.Len(t, conn.writes, 1) + require.Equal(t, "error", conn.writes[0].op) + require.Contains(t, conn.writes[0].s, "Unknown COMMAND subcommand") +} + +func TestCommand_GetKeysAndFlagsRejected(t *testing.T) { + t.Parallel() + conn := runCommand(t, "COMMAND", "GETKEYSANDFLAGS", "GET", "k") + require.Len(t, conn.writes, 1) + require.Equal(t, "error", conn.writes[0].op) + require.Contains(t, conn.writes[0].s, "unsupported") +} + +func TestCommand_RoutedViaServer(t *testing.T) { + t.Parallel() + // Verify the route wiring: a COMMAND request reaching RedisServer.route + // dispatches to r.command. We cannot exercise redcon.Serve in a unit + // test without a network listener, so we instead reach into the route + // map as the live server would. + srv := &RedisServer{} + srv.route = map[string]func(conn redcon.Conn, cmd redcon.Command){ + cmdCommand: srv.command, + } + handler, ok := srv.route[cmdCommand] + require.True(t, ok, "COMMAND must be routed") + conn := &commandRecorder{} + handler(conn, redcon.Command{Args: [][]byte{[]byte("COMMAND"), []byte("COUNT")}}) + require.Len(t, conn.writes, 1) + require.Equal(t, "int", conn.writes[0].op) +} + +// TestCommand_ArgsLenAllowsBare ensures the validateCmd arity check +// accepts bare `COMMAND` (no subcommand), matching real Redis where +// `COMMAND` with no args is the canonical "dump all" call. +func TestCommand_ArgsLenAllowsBare(t *testing.T) { + t.Parallel() + require.Equal(t, -1, argsLen[cmdCommand]) +} + +// TestCommand_RouteMatchesTable asserts that every command currently +// routed has a metadata row in redisCommandTable. Catches the common +// mistake of adding a new handler without extending the table — the +// runtime path would fall back to zero-metadata + a log warning, but we +// want hard failure in CI. +func TestCommand_RouteMatchesTable(t *testing.T) { + t.Parallel() + srv := &RedisServer{} + // Rebuild the route map via NewRedisServer would need a listener; we + // instead iterate argsLen (which every routed command has an entry + // in — see redis.go) as the closest proxy. + for name := range argsLen { + _, ok := redisCommandTable[name] + require.Truef(t, ok, "command %q routed but missing from redisCommandTable", name) + } + _ = srv +} diff --git a/adapter/redis_compat_commands.go b/adapter/redis_compat_commands.go index 59a94712..b9b90b8e 100644 --- a/adapter/redis_compat_commands.go +++ b/adapter/redis_compat_commands.go @@ -220,6 +220,206 @@ func (r *RedisServer) client(conn redcon.Conn, cmd redcon.Command) { } } +// command implements the Redis `COMMAND` family used by clients for +// capability probing at connect time (go-redis, redis-py, ioredis, …). +// Subcommand matrix: +// +// COMMAND -> array of per-command info +// COMMAND COUNT -> integer +// COMMAND LIST -> array of names (FILTERBY rejected) +// COMMAND INFO [name ...] -> array of per-command info (nil per unknown) +// COMMAND DOCS [name ...] -> minimal map-shaped doc entries +// COMMAND GETKEYS cmd args -> array of extracted keys +// COMMAND GETKEYSANDFLAGS -> ERR unsupported +func (r *RedisServer) command(conn redcon.Conn, cmd redcon.Command) { + if len(cmd.Args) == 1 { + r.writeCommandInfoAll(conn) + return + } + sub := strings.ToUpper(string(cmd.Args[1])) + switch sub { + case "COUNT": + // COUNT must match the cardinality of COMMAND / COMMAND LIST — + // which iterate argsLen (= routed set). The table has the same + // size by invariant, but driving COUNT off argsLen keeps the + // three subcommands wire-consistent even during the brief + // window when a new route has been added but the table row is + // still pending. + conn.WriteInt(len(argsLen)) + case "LIST": + // `COMMAND LIST` takes no args (bare list) or `FILTERBY …` which we + // reject below. Anything past the subcommand slot is a filter. + const commandListArgFixed = 2 + if len(cmd.Args) > commandListArgFixed { + // We explicitly do not support FILTERBY MODULE|ACLCAT|PATTERN + // — elastickv has no modules and no ACL categories. Rejecting + // here is consistent with how real Redis would behave when a + // filter resolves to an empty universe; clients that see this + // fall back to COMMAND (no args), which we support. + conn.WriteError("ERR unsupported COMMAND LIST filter") + return + } + r.writeCommandList(conn) + case "INFO": + r.writeCommandInfo(conn, cmd.Args[2:]) + case "DOCS": + r.writeCommandDocs(conn, cmd.Args[2:]) + case "GETKEYS": + r.writeCommandGetKeys(conn, cmd.Args[2:]) + case "GETKEYSANDFLAGS": + conn.WriteError("ERR unsupported COMMAND subcommand 'GETKEYSANDFLAGS'") + default: + conn.WriteError("ERR Unknown COMMAND subcommand '" + sub + "'") + } +} + +// writeCommandInfoEntry emits the 6-element per-command info array for a +// single command. Redis 7 extends this to 10 elements; we deliberately +// stop at 6 because every client we care about parses the first 6 fields +// and ignores trailing elements. +func writeCommandInfoEntry(conn redcon.Conn, meta redisCommandMeta) { + const infoArity = 6 + conn.WriteArray(infoArity) + conn.WriteBulkString(meta.Name) + conn.WriteInt(meta.Arity) + conn.WriteArray(len(meta.Flags)) + for _, f := range meta.Flags { + conn.WriteBulkString(f) + } + conn.WriteInt(meta.FirstKey) + conn.WriteInt(meta.LastKey) + conn.WriteInt(meta.Step) +} + +func (r *RedisServer) writeCommandInfoAll(conn redcon.Conn) { + metas := routedRedisCommandMetas() + conn.WriteArray(len(metas)) + for _, meta := range metas { + writeCommandInfoEntry(conn, meta) + } +} + +func (r *RedisServer) writeCommandList(conn redcon.Conn) { + metas := routedRedisCommandMetas() + conn.WriteArray(len(metas)) + for _, meta := range metas { + conn.WriteBulkString(meta.Name) + } +} + +func (r *RedisServer) writeCommandInfo(conn redcon.Conn, requested [][]byte) { + // `COMMAND INFO` with no names is equivalent to `COMMAND` (no args): + // return info for every known command. This is what real Redis does + // and what go-redis relies on when it issues bare `COMMAND INFO`. + if len(requested) == 0 { + r.writeCommandInfoAll(conn) + return + } + conn.WriteArray(len(requested)) + for _, raw := range requested { + meta, ok := redisCommandTable[strings.ToUpper(string(raw))] + if !ok { + conn.WriteNull() + continue + } + writeCommandInfoEntry(conn, meta) + } +} + +// writeCommandDocs emits the RESP2 flat-map form of COMMAND DOCS: +// alternating command-name keys and 4-element doc-maps with "summary" +// and "arguments" fields. Two compliance-critical behaviours: +// +// 1. Bare `COMMAND DOCS` (no names) returns docs for ALL routed +// commands, identical to how `COMMAND INFO` and bare `COMMAND` +// behave. Clients/tools like redis-cli --docs rely on this. +// 2. Every requested entry writes BOTH the command-name key AND the +// doc map value. Clients decode the top-level array as a map of +// name -> docs, so skipping the name key makes the reply +// unparseable. Unknown commands emit the requested name followed +// by nil (Redis semantics). +// +// We do not maintain per-command docs, so summary is "" and arguments +// is empty. The wire-shape is what clients care about at connect time. +func (r *RedisServer) writeCommandDocs(conn redcon.Conn, requested [][]byte) { + const docEntryLen = 4 + // Bare DOCS (no command names): iterate the routed set so the + // reply mirrors `COMMAND` / `COMMAND INFO` / `COMMAND LIST`. + if len(requested) == 0 { + metas := routedRedisCommandMetas() + // Two wire slots per command (name + doc map). + conn.WriteArray(len(metas) * 2) //nolint:mnd // 2 = (name, docs) pair + for _, meta := range metas { + conn.WriteBulkString(meta.Name) + conn.WriteArray(docEntryLen) + conn.WriteBulkString("summary") + conn.WriteBulkString("") + conn.WriteBulkString("arguments") + conn.WriteArray(0) + } + return + } + // Explicit names: preserve the caller-supplied order so a client + // that expects its own request ordering back (e.g. for building a + // lookup table) is not surprised. Each pair is (name, docs) or + // (name, nil) for unknowns. + conn.WriteArray(len(requested) * 2) //nolint:mnd // 2 = (name, docs) pair + for _, raw := range requested { + name := string(raw) + meta, ok := redisCommandTable[strings.ToUpper(name)] + if !ok { + conn.WriteBulkString(name) + conn.WriteNull() + continue + } + conn.WriteBulkString(meta.Name) + conn.WriteArray(docEntryLen) + conn.WriteBulkString("summary") + conn.WriteBulkString("") + conn.WriteBulkString("arguments") + conn.WriteArray(0) + } +} + +// writeCommandGetKeys dispatches COMMAND GETKEYS for a given subcommand +// plus its arguments. Real Redis requires at least one arg after GETKEYS +// (the command name itself); we enforce that here rather than lean on +// argsLen which only validates the outer COMMAND call. +func (r *RedisServer) writeCommandGetKeys(conn redcon.Conn, argv [][]byte) { + if len(argv) == 0 { + conn.WriteError("ERR wrong number of arguments for 'command|getkeys' command") + return + } + meta, ok := redisCommandTable[strings.ToUpper(string(argv[0]))] + if !ok { + conn.WriteError("ERR Invalid command specified") + return + } + // validate arity of the nested command so we match Redis behaviour of + // refusing to compute keys for obviously malformed commands (a common + // source of confusion in client test suites). + switch { + case meta.Arity > 0 && len(argv) != meta.Arity: + conn.WriteError("ERR Invalid arguments specified for populating the array of keys") + return + case meta.Arity < 0 && len(argv) < -meta.Arity: + conn.WriteError("ERR Invalid arguments specified for populating the array of keys") + return + } + keys := redisCommandGetKeys(meta, argv) + if len(keys) == 0 { + // `The command has no key arguments` — real Redis returns an error + // in this case rather than an empty array, and go-redis's test + // suite expects the error form. + conn.WriteError("ERR The command has no key arguments") + return + } + conn.WriteArray(len(keys)) + for _, k := range keys { + conn.WriteBulk(k) + } +} + func (r *RedisServer) selectDB(conn redcon.Conn, cmd redcon.Command) { if _, err := strconv.Atoi(string(cmd.Args[1])); err != nil { conn.WriteError("ERR invalid DB index") diff --git a/monitoring/redis.go b/monitoring/redis.go index 8454da3e..287e5322 100644 --- a/monitoring/redis.go +++ b/monitoring/redis.go @@ -50,6 +50,7 @@ const ( var redisCommandSet = map[string]struct{}{ "BZPOPMIN": {}, "CLIENT": {}, + "COMMAND": {}, "DBSIZE": {}, "DEL": {}, "DISCARD": {},