Files
go-redis/unit_test.go
Nedyalko Dyakov a8c360cc45 fix(command): set key position for 16 commands (#4048)
* fix(command): set key position for 16 commands

The shared key resolver routes Ring shards, cluster slots, client-side
caching and full-duplex engines. For typed commands whose key is not
args[1] it fell back to args[1], or, on a warm cluster cache, to the
COMMAND first key, which is 0 for container and numkeys commands:

  BITOP (all BitOp*), MIGRATE           op / host instead of the key
  OBJECT ENCODING/FREQ/IDLETIME/REFCOUNT, XINFO STREAM/GROUPS/CONSUMERS
                                        subcommand instead of the key
  LMPOP, SINTERCARD, ZINTERCARD, ZMPOP  numkeys instead of the key
  BLMPOP, BZMPOP                        timeout instead of the key

Ring sent them to the wrong shard; cluster recovered through MOVED.
The constructors now set the key position, as XGroup*, ZUnion and FCall
already do. A raw FCALL/FCALL_RO also resolves its key after numkeys,
like EVAL, instead of hashing the function name.

* fix(command): route raw forms by their key

The typed constructors of the commands whose key is not args[1] set the
key position; a raw Do / NewCmd of the same commands does not, so the
shared resolver fell back to args[1]: raw XREAD hashed "streams" and raw
LMPOP its numkeys, which picks the wrong Ring shard, the wrong
full-duplex engine, or a cold cluster slot.

The resolver now finds the key for the raw forms: XREAD and XREADGROUP
after STREAMS (XREADGROUP scans past the group and consumer names),
OBJECT and XINFO (HELP is keyless), BITOP, LMPOP, ZMPOP, SINTERCARD,
ZINTERCARD, BLMPOP, BZMPOP, and MIGRATE, including its KEYS form.

* fix(command): walk MIGRATE options for KEYS

The raw MIGRATE KEYS form was located by scanning for the first "keys"
token after the timeout, so an AUTH or AUTH2 operand that reads "keys"
was taken for the clause and the resolver routed by the token after it.
It now walks the option grammar: COPY and REPLACE take no operand,
AUTH takes one and AUTH2 two, and the key follows KEYS.
2026-10-01 14:19:35 +03:00

152 lines
5.3 KiB
Go

package redis
import (
"context"
"testing"
)
// mockCmdable is a mock implementation of cmdable that records the last command.
// This is used for unit testing command construction without requiring a Redis server.
type mockCmdable struct {
lastCmd Cmder
returnErr error
}
func (m *mockCmdable) call(_ context.Context, cmd Cmder) error {
m.lastCmd = cmd
if m.returnErr != nil {
cmd.SetErr(m.returnErr)
}
return m.returnErr
}
func (m *mockCmdable) asCmdable() cmdable {
return func(ctx context.Context, cmd Cmder) error {
return m.call(ctx, cmd)
}
}
// TestCmdFirstKeyPosWithInfo_UsesCommandInfoWhenWarm checks that once the
// cmdsInfoCache has been populated, cmdFirstKeyPosWithInfo returns
// CommandInfo.FirstKeyPos rather than the hardcoded fallback of 1.
// Uses an injected cache fn so no Redis server is required.
func TestCmdFirstKeyPosWithInfo_UsesCommandInfoWhenWarm(t *testing.T) {
// "mymodule.cmd" is not in keylessCommands and has no firstKeyPos set,
// so cold-cache behaviour falls back to the default of 1.
const name = "mymodule.cmd"
ctx := context.Background()
cmd := NewCmd(ctx, name, "arg1")
if got := cmdFirstKeyPosWithInfo(cmd, nil); got != 1 {
t.Fatalf("cold cache: got %d, want 1", got)
}
// With a warm CommandInfo saying FirstKeyPos=0 (e.g. a module keyless command
// not in our hand-maintained table), the function must return 0.
info := &CommandInfo{Name: name, FirstKeyPos: 0}
if got := cmdFirstKeyPosWithInfo(cmd, info); got != 0 {
t.Fatalf("warm cache FirstKeyPos=0: got %d, want 0", got)
}
info2 := &CommandInfo{Name: name, FirstKeyPos: 2}
if got := cmdFirstKeyPosWithInfo(cmd, info2); got != 2 {
t.Fatalf("warm cache FirstKeyPos=2: got %d, want 2", got)
}
// End-to-end: Peek returns nil before Get, the real entry after.
cache := newCmdsInfoCache(func(_ context.Context) (map[string]*CommandInfo, error) {
return map[string]*CommandInfo{name: {Name: name, FirstKeyPos: 0}}, nil
})
if cache.Peek() != nil {
t.Fatal("Peek() must return nil before Get()")
}
if _, err := cache.Get(ctx); err != nil {
t.Fatalf("cache.Get: %v", err)
}
warmInfo := cache.Peek()[name]
if warmInfo == nil {
t.Fatalf("Peek()[%q] is nil after Get()", name)
}
if pos := cmdFirstKeyPosWithInfo(cmd, warmInfo); pos != 0 {
t.Fatalf("post-Get: got %d, want 0", pos)
}
}
// TestCmdFirstKeyPosWithInfo_RawFCall checks that a raw FCALL/FCALL_RO, which
// has no key position set by a constructor, resolves its key after numkeys
// (position 3) the way EVAL does, instead of hashing the function name. Ring
// shard selection, cluster slot routing and full-duplex engine routing all
// use this resolver.
func TestCmdFirstKeyPosWithInfo_RawFCall(t *testing.T) {
ctx := context.Background()
for _, name := range []string{"fcall", "fcall_ro"} {
if got := cmdFirstKeyPosWithInfo(NewCmd(ctx, name, "myfn", "1", "k", "arg"), nil); got != 3 {
t.Fatalf("%s with one key: got %d, want 3", name, got)
}
if got := cmdFirstKeyPosWithInfo(NewCmd(ctx, name, "myfn", 1, "k"), nil); got != 3 {
t.Fatalf("%s with an int numkeys: got %d, want 3", name, got)
}
if got := cmdFirstKeyPosWithInfo(NewCmd(ctx, name, "myfn", "0", "arg"), nil); got != 0 {
t.Fatalf("%s with no keys: got %d, want 0 (keyless)", name, got)
}
}
}
// TestCmdFirstKeyPosWithInfo_PolicyTableKeyless checks that a module command
// registered keyless in the static policy table routes as keyless even on a
// cold command-info cache, and that key-carrying or specially-routed module
// commands are not affected.
func TestCmdFirstKeyPosWithInfo_PolicyTableKeyless(t *testing.T) {
ctx := context.Background()
// Keyless per the policy table: the index argument must not be treated
// as a key while the command-info cache is cold.
for _, name := range []string{"ft.aliaslist", "ft.aliasadd", "ft.search", "ft.spellcheck"} {
cmd := NewCmd(ctx, name, "idx")
if got := cmdFirstKeyPosWithInfo(cmd, nil); got != 0 {
t.Errorf("%s cold cache: got %d, want 0 (keyless)", name, got)
}
}
// Slot-from-key (RespDefaultHashSlot) and special request routing
// (ReqSpecial) keep the hardcoded fallback.
for _, name := range []string{"ft.suglen", "ft.sugdel", "ft.cursor"} {
cmd := NewCmd(ctx, name, "key")
if got := cmdFirstKeyPosWithInfo(cmd, nil); got != 1 {
t.Errorf("%s cold cache: got %d, want 1", name, got)
}
}
// Unregistered module commands are untouched.
cmd := NewCmd(ctx, "mymodule.cmd", "arg1")
if got := cmdFirstKeyPosWithInfo(cmd, nil); got != 1 {
t.Errorf("unregistered module cmd: got %d, want 1", got)
}
}
// TestCmdFirstKeyPosWithInfo_MsetEx checks that msetex's first key is
// resolved to position 2 (after the command name and the numkeys arg),
// unlike mset/msetnx where the first key sits at position 1 immediately
// after the command name.
func TestCmdFirstKeyPosWithInfo_MsetEx(t *testing.T) {
ctx := context.Background()
msetex := NewCmd(ctx, "msetex", 2, "key1", "val1", "key2", "val2", "ex", 10)
if got := cmdFirstKeyPosWithInfo(msetex, nil); got != 2 {
t.Errorf("msetex cold cache: got %d, want 2", got)
}
mset := NewCmd(ctx, "mset", "key1", "val1", "key2", "val2")
if got := cmdFirstKeyPosWithInfo(mset, nil); got != 1 {
t.Errorf("mset cold cache: got %d, want 1", got)
}
msetnx := NewCmd(ctx, "msetnx", "key1", "val1", "key2", "val2")
if got := cmdFirstKeyPosWithInfo(msetnx, nil); got != 1 {
t.Errorf("msetnx cold cache: got %d, want 1", got)
}
}