Skip to content

Commit e055e98

Browse files
authored
feat(redis): implement COMMAND (INFO / COUNT / LIST / DOCS / GETKEYS) (#607)
## Motivation `COMMAND` is issued at connect time by ~every modern Redis client (go-redis, redis-py >= 4, ioredis, node-redis) for capability probing and key-routing inference. elastickv previously rejected it as `unsupported command`, inflating the "unknown" error bucket and breaking client initialization in strict libraries that actually consult `COMMAND INFO` output. Companion to #594 (bounded unsupported-command metric) and #601 (`HELLO`). After this + `HELLO` land, those two commands should cover >95% of the previously-"unknown" error traffic. ## Subcommand matrix Implemented: - `COMMAND` — flat array of per-command info (6-element shape) - `COMMAND COUNT` — integer count of routed commands - `COMMAND LIST` — flat array of command names - `COMMAND INFO [name ...]` — per-command info array, `nil` per unknown - `COMMAND DOCS [name ...]` — minimal map-shaped doc entry per command - `COMMAND GETKEYS <command> <args>` — positional key extraction via per-command first_key/last_key/step Explicitly rejected (returns `ERR unsupported`): - `COMMAND GETKEYSANDFLAGS` — Redis 7 key-specs shape we do not emit - `COMMAND LIST FILTERBY MODULE|ACLCAT|PATTERN` — elastickv has no modules and no ACL categories ## Metadata table Single source of truth: `adapter/redis_command_info.go`. One row per routed command holding `(arity, flags, first_key, last_key, step)`. Adding a new command handler now takes three steps: 1. Register the handler in `RedisServer.route` (`adapter/redis.go`). 2. Add an `argsLen` entry (`adapter/redis.go`). 3. Add a row to `redisCommandTable` in `redis_command_info.go`. Forgetting step 3 is caught at CI time by `TestCommand_RouteMatchesTable`. In production the runtime falls through to a zero-metadata row and emits a single deduplicated log warning per missing command name, so the command still appears in `COMMAND` output (degraded) rather than vanishing entirely. Flags follow a three-value taxonomy: `readonly` / `write` / `admin`. `denyoom` / `pubsub` / `stale` etc. are intentionally not emitted — no real client consults them for routing decisions. Wire count, LIST length, and the bare-`COMMAND` reply are driven off `argsLen` (the 1:1 route-keyed set) rather than the table, so the three subcommands stay mutually consistent even during the brief window when a new route has been added but the table row is pending. ## Test plan - [x] `COMMAND COUNT` returns `len(argsLen)` and equals `len(redisCommandTable)` - [x] `COMMAND` (no args) returns an array of that length - [x] `COMMAND INFO GET` — name/arity/flags/positions match spec - [x] `COMMAND INFO SET` — arity=-3, flags contain "write" - [x] `COMMAND INFO nosuchcommand` — nil entry, not an error - [x] `COMMAND INFO GET NOSUCH SET` — 3 entries, middle is nil - [x] `COMMAND GETKEYS SET foo bar` → `["foo"]` - [x] `COMMAND GETKEYS DEL k1 k2 k3` → `["k1","k2","k3"]` - [x] `redisCommandGetKeys` MSET-shape helper test (step=2) - [x] `COMMAND GETKEYS NOSUCH` → error - [x] `COMMAND LIST` — sorted names, length = count - [x] `COMMAND LIST FILTERBY MODULE foo` — rejected - [x] `COMMAND DOCS GET` — 4-element map-shaped entry - [x] `COMMAND BADSUB` — "Unknown COMMAND subcommand" - [x] `COMMAND GETKEYSANDFLAGS` — rejected - [x] Route-wiring test confirms `COMMAND` dispatches to the handler - [x] `TestCommand_RouteMatchesTable` — invariant that every routed command has a metadata row - [x] `golangci-lint run ./adapter/... ./monitoring/...` is clean - [x] `go test -race -run 'TestCommand|TestRedisCommand' ./adapter/... ./monitoring/...` passes
2 parents 8a9bab4 + ccde868 commit e055e98

6 files changed

Lines changed: 1102 additions & 169 deletions

File tree

adapter/redis.go

Lines changed: 9 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
const (
3131
cmdBZPopMin = "BZPOPMIN"
3232
cmdClient = "CLIENT"
33+
cmdCommand = "COMMAND"
3334
cmdDBSize = "DBSIZE"
3435
cmdDel = "DEL"
3536
cmdDiscard = "DISCARD"
@@ -164,91 +165,9 @@ var txnApplyHandlers = map[string]txnCommandHandler{
164165
cmdPExpire: (*txnContext).applyExpireMilliseconds,
165166
}
166167

167-
//nolint:mnd
168-
var argsLen = map[string]int{
169-
cmdBZPopMin: -3,
170-
cmdClient: -2,
171-
cmdDBSize: 1,
172-
cmdDel: -2,
173-
cmdDiscard: 1,
174-
cmdEval: -3,
175-
cmdEvalSHA: -3,
176-
cmdExec: 1,
177-
cmdExists: -2,
178-
cmdExpire: -3,
179-
cmdFlushAll: 1,
180-
cmdFlushDB: 1,
181-
cmdFlushLegacy: 1,
182-
cmdGet: 2,
183-
cmdGetDel: 2,
184-
cmdHDel: -3,
185-
cmdHExists: 3,
186-
cmdHGet: 3,
187-
cmdHGetAll: 2,
188-
cmdHIncrBy: 4,
189-
cmdHLen: 2,
190-
cmdHMGet: -3,
191-
cmdHMSet: -4,
192-
cmdHSet: -4,
193-
cmdHello: -1,
194-
cmdInfo: -1,
195-
cmdIncr: 2,
196-
cmdKeys: 2,
197-
cmdLIndex: 3,
198-
cmdLLen: 2,
199-
cmdLPop: 2,
200-
cmdLPush: -3,
201-
cmdLPos: -3,
202-
cmdLRange: 4,
203-
cmdLRem: 4,
204-
cmdLSet: 4,
205-
cmdLTrim: 4,
206-
cmdMulti: 1,
207-
cmdPExpire: -3,
208-
cmdPFAdd: -3,
209-
cmdPFCount: -2,
210-
cmdPing: -1,
211-
cmdPTTL: 2,
212-
cmdPublish: 3,
213-
cmdPubSub: -2,
214-
cmdQuit: 1,
215-
cmdRename: 3,
216-
cmdRPop: 2,
217-
cmdRPopLPush: 3,
218-
cmdRPush: -3,
219-
cmdSAdd: -3,
220-
cmdSCard: 2,
221-
cmdScan: -2,
222-
cmdSelect: 2,
223-
cmdSet: -3,
224-
cmdSetEx: 4,
225-
cmdSetNX: 3,
226-
cmdSIsMember: 3,
227-
cmdSMembers: 2,
228-
cmdSRem: -3,
229-
cmdSubscribe: -2,
230-
cmdTTL: 2,
231-
cmdType: 2,
232-
cmdXAdd: -5,
233-
cmdXLen: 2,
234-
cmdXRead: -4,
235-
cmdXRange: -4,
236-
cmdXRevRange: -4,
237-
cmdXTrim: -4,
238-
cmdZAdd: -4,
239-
cmdZCard: 2,
240-
cmdZCount: 4,
241-
cmdZIncrBy: 4,
242-
cmdZRange: -4,
243-
cmdZRangeByScore: -4,
244-
cmdZRem: -3,
245-
cmdZRemRangeByScore: 4,
246-
cmdZRemRangeByRank: 4,
247-
cmdZPopMin: -2,
248-
cmdZRevRange: -4,
249-
cmdZRevRangeByScore: -4,
250-
cmdZScore: 3,
251-
}
168+
// argsLen is derived from redisCommandSpecs in adapter/redis_command_specs.go.
169+
// See that file for the canonical row list and the rationale for the
170+
// single source of truth.
252171

253172
type RedisServer struct {
254173
listen net.Listener
@@ -432,90 +351,11 @@ func NewRedisServer(listen net.Listener, redisAddr string, store store.MVCCStore
432351
}
433352
r.relay.Bind(r.publishLocal)
434353

435-
r.route = map[string]func(conn redcon.Conn, cmd redcon.Command){
436-
cmdBZPopMin: r.bzpopmin,
437-
cmdClient: r.client,
438-
cmdDBSize: r.dbsize,
439-
cmdDel: r.del,
440-
cmdDiscard: r.discard,
441-
cmdEval: r.eval,
442-
cmdEvalSHA: r.evalsha,
443-
cmdExec: r.exec,
444-
cmdExists: r.exists,
445-
cmdExpire: r.expire,
446-
cmdFlushAll: r.flushall,
447-
cmdFlushDB: r.flushdb,
448-
cmdFlushLegacy: r.flushlegacy,
449-
cmdGet: r.get,
450-
cmdGetDel: r.getdel,
451-
cmdHDel: r.hdel,
452-
cmdHExists: r.hexists,
453-
cmdHGet: r.hget,
454-
cmdHGetAll: r.hgetall,
455-
cmdHIncrBy: r.hincrby,
456-
cmdHLen: r.hlen,
457-
cmdHMGet: r.hmget,
458-
cmdHMSet: r.hmset,
459-
cmdHSet: r.hset,
460-
cmdHello: r.hello,
461-
cmdInfo: r.info,
462-
cmdIncr: r.incr,
463-
cmdKeys: r.keys,
464-
cmdLIndex: r.lindex,
465-
cmdLLen: r.llen,
466-
cmdLPop: r.lpop,
467-
cmdLPos: r.lpos,
468-
cmdLPush: r.lpush,
469-
cmdLRange: r.lrange,
470-
cmdLRem: r.lrem,
471-
cmdLSet: r.lset,
472-
cmdLTrim: r.ltrim,
473-
cmdMulti: r.multi,
474-
cmdPExpire: r.pexpire,
475-
cmdPFAdd: r.pfadd,
476-
cmdPFCount: r.pfcount,
477-
cmdPing: r.ping,
478-
cmdPTTL: r.pttl,
479-
cmdPublish: r.publish,
480-
cmdPubSub: r.pubsubCmd,
481-
cmdQuit: r.quit,
482-
cmdRename: r.rename,
483-
cmdRPop: r.rpop,
484-
cmdRPopLPush: r.rpoplpush,
485-
cmdRPush: r.rpush,
486-
cmdSAdd: r.sadd,
487-
cmdSCard: r.scard,
488-
cmdScan: r.scan,
489-
cmdSelect: r.selectDB,
490-
cmdSet: r.set,
491-
cmdSetEx: r.setex,
492-
cmdSetNX: r.setnx,
493-
cmdSIsMember: r.sismember,
494-
cmdSMembers: r.smembers,
495-
cmdSRem: r.srem,
496-
cmdSubscribe: r.subscribe,
497-
cmdTTL: r.ttl,
498-
cmdType: r.typeCmd,
499-
cmdXAdd: r.xadd,
500-
cmdXLen: r.xlen,
501-
cmdXRead: r.xread,
502-
cmdXRange: r.xrange,
503-
cmdXRevRange: r.xrevrange,
504-
cmdXTrim: r.xtrim,
505-
cmdZAdd: r.zadd,
506-
cmdZCard: r.zcard,
507-
cmdZCount: r.zcount,
508-
cmdZIncrBy: r.zincrby,
509-
cmdZRange: r.zrange,
510-
cmdZRangeByScore: r.zrangebyscore,
511-
cmdZRem: r.zrem,
512-
cmdZRemRangeByScore: r.zremrangebyscore,
513-
cmdZRemRangeByRank: r.zremrangebyrank,
514-
cmdZPopMin: r.zpopmin,
515-
cmdZRevRange: r.zrevrange,
516-
cmdZRevRangeByScore: r.zrevrangebyscore,
517-
cmdZScore: r.zscore,
518-
}
354+
// route, argsLen, and redisCommandTable all derive from the single
355+
// redisCommandSpecs slice (adapter/redis_command_specs.go) so adding
356+
// a command is a one-row diff there and the three views can never
357+
// drift. See buildRouteMap for the per-server bind.
358+
r.route = r.buildRouteMap()
519359
for _, opt := range opts {
520360
if opt != nil {
521361
opt(r)

adapter/redis_command_info.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package adapter
2+
3+
// redis_command_info.go holds the COMMAND-family helpers. The metadata
4+
// table itself (redisCommandTable) and the routed-set source of truth
5+
// (argsLen) both live in adapter/redis_command_specs.go and are derived
6+
// from the canonical redisCommandSpecs slice — adding a new command is
7+
// a single row there, with no risk of drifting between r.route /
8+
// argsLen / redisCommandTable the way HELLO did when it was added to
9+
// the route + arity check but missed the metadata table.
10+
//
11+
// Shape notes (Redis reference):
12+
// - arity: exact positive arity, or negative meaning "at least |arity|"
13+
// - flags: one of "readonly" | "write" | "admin". We do NOT currently
14+
// emit "denyoom" / "pubsub" / "loading" / "stale" / "fast" etc. —
15+
// real Redis clients only consume this field for coarse routing.
16+
// - first_key / last_key / step describe the key positions inside the
17+
// argv. first_key=0 means the command operates on zero keys (pure
18+
// connection / server commands). last_key=-1 means "all remaining
19+
// args are keys" (MSET-shaped). step=1 means keys are consecutive;
20+
// step=2 is used by MSET-like key/value pairs.
21+
22+
import (
23+
"log"
24+
"sort"
25+
"strings"
26+
"sync"
27+
)
28+
29+
// redisCommandFlag values are string constants so the raw strings are not
30+
// duplicated across the table.
31+
const (
32+
redisCmdFlagReadonly = "readonly"
33+
redisCmdFlagWrite = "write"
34+
redisCmdFlagAdmin = "admin"
35+
)
36+
37+
// redisCommandMeta is a single row in the COMMAND table.
38+
type redisCommandMeta struct {
39+
// Name is the lowercase command name as reported by Redis. Keyed in the
40+
// table by uppercase for dispatch-time lookup; the lowercase form is
41+
// what goes onto the wire in COMMAND INFO.
42+
Name string
43+
Arity int
44+
Flags []string
45+
FirstKey int
46+
LastKey int
47+
Step int
48+
}
49+
50+
// redisCommandFallbackWarnedOnce deduplicates the "missing metadata" log so
51+
// that a hostile or buggy client probing the same unknown-but-routed name
52+
// cannot generate unbounded log spam. The fallback is a safety net for
53+
// commands that get added to the route but where the table row is
54+
// forgotten; the unit test `TestCommand_RouteMatchesTable` is the hard
55+
// gate, but in production we prefer a degraded reply + one log line over
56+
// a silently-missing command.
57+
var (
58+
redisCommandFallbackWarnedOnceMu sync.Mutex
59+
redisCommandFallbackWarnedOnce = map[string]struct{}{}
60+
)
61+
62+
// routedRedisCommandMetas returns the metadata rows for every command
63+
// currently routed (keyed via argsLen, which is populated 1:1 with the
64+
// route map — see redis.go). Rows are returned in sorted UPPER-case order
65+
// so wire output is deterministic. Names present in redisCommandTable
66+
// produce their real row; names absent from the table but routed produce
67+
// a zero-metadata row and a one-shot log warning. This is the source of
68+
// truth for `COMMAND` (no args) and `COMMAND LIST`; `COMMAND INFO <name>`
69+
// goes through redisCommandTable directly so unknowns produce the nil
70+
// reply required by Redis semantics.
71+
func routedRedisCommandMetas() []redisCommandMeta {
72+
names := make([]string, 0, len(argsLen))
73+
for name := range argsLen {
74+
names = append(names, strings.ToUpper(name))
75+
}
76+
sort.Strings(names)
77+
metas := make([]redisCommandMeta, 0, len(names))
78+
for _, name := range names {
79+
if meta, ok := redisCommandTable[name]; ok {
80+
metas = append(metas, meta)
81+
continue
82+
}
83+
warnMissingRedisCommandMeta(name)
84+
metas = append(metas, redisCommandMeta{
85+
Name: strings.ToLower(name),
86+
Arity: -1,
87+
Flags: nil,
88+
FirstKey: 0,
89+
LastKey: 0,
90+
Step: 0,
91+
})
92+
}
93+
return metas
94+
}
95+
96+
// warnMissingRedisCommandMeta emits a one-shot warning when a routed
97+
// command has no entry in redisCommandTable. Subsequent calls for the
98+
// same name are silent so a hot dispatch path does not produce log spam.
99+
func warnMissingRedisCommandMeta(upper string) {
100+
redisCommandFallbackWarnedOnceMu.Lock()
101+
_, warned := redisCommandFallbackWarnedOnce[upper]
102+
if !warned {
103+
redisCommandFallbackWarnedOnce[upper] = struct{}{}
104+
}
105+
redisCommandFallbackWarnedOnceMu.Unlock()
106+
if !warned {
107+
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)
108+
}
109+
}
110+
111+
// redisCommandGetKeys extracts the key positions from a full command-form
112+
// argv (argv[0] is the command name, argv[1:] are its arguments).
113+
// Returns an error when the command is unknown; returns an empty slice when
114+
// the command is routed but has no keys.
115+
//
116+
// Semantics mirror Redis's own COMMAND GETKEYS:
117+
// - first_key=0: no keys (empty slice).
118+
// - last_key=-1: "all args after first_key are keys". step controls spacing
119+
// (step=1 → every arg; step=2 → every other arg, as in MSET).
120+
// - last_key=-N (N>1): last key index is len(argv)-N. Commands like
121+
// BZPOPMIN use -2 to exclude a trailing timeout arg that is NOT a key;
122+
// treating every negative as "to end" would wrongly expose the timeout
123+
// via COMMAND GETKEYS and break client key-routing decisions.
124+
// - otherwise: args in [first_key .. last_key] at `step` stride.
125+
//
126+
// This is *positional*. It does not understand option prefixes (e.g. the
127+
// `EX`/`PX` flags of SET); clients that need option-aware parsing would
128+
// look at Redis 7's key-specs shape, which we explicitly do not emit. For
129+
// the commands elastickv supports the naive positional scheme is correct.
130+
func redisCommandGetKeys(meta redisCommandMeta, argv [][]byte) [][]byte {
131+
if meta.FirstKey <= 0 {
132+
return nil
133+
}
134+
if meta.Step <= 0 {
135+
return nil
136+
}
137+
if meta.FirstKey >= len(argv) {
138+
return nil
139+
}
140+
last := meta.LastKey
141+
if last < 0 {
142+
// Negative last_key is an offset from the end: -1 means the
143+
// final arg, -2 means the second-to-last, and so on. Use
144+
// len(argv)+last so BZPOPMIN (-2) excludes its trailing
145+
// timeout argument instead of claiming the timeout as a key.
146+
last = len(argv) + last
147+
}
148+
if last >= len(argv) {
149+
last = len(argv) - 1
150+
}
151+
if last < meta.FirstKey {
152+
return nil
153+
}
154+
keys := make([][]byte, 0, (last-meta.FirstKey)/meta.Step+1)
155+
for i := meta.FirstKey; i <= last; i += meta.Step {
156+
keys = append(keys, argv[i])
157+
}
158+
return keys
159+
}

0 commit comments

Comments
 (0)