mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
A null attribute (|-1) is not valid RESP3, but ReadAttributeLen consumes the header and reports proto.Nil. peekCommandInfoReplyType passed that error on, and the COMMAND readers treat a Nil error as "the value was already drained", so the wrapped reply stayed on the wire and the connection went back to the pool out of step with the stream. Unwrap it as an empty attribute instead. Refs #4097 Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com>
690 lines
25 KiB
Go
690 lines
25 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/redis/go-redis/v9/internal/proto"
|
|
)
|
|
|
|
func commandInfoTestBulk(s string) string {
|
|
return fmt.Sprintf("$%d\r\n%s\r\n", len(s), s)
|
|
}
|
|
|
|
func commandInfoTestInt(v int64) string {
|
|
return fmt.Sprintf(":%d\r\n", v)
|
|
}
|
|
|
|
func commandInfoTestArray(values ...string) string {
|
|
return fmt.Sprintf("*%d\r\n%s", len(values), strings.Join(values, ""))
|
|
}
|
|
|
|
func commandInfoTestSet(values ...string) string {
|
|
return fmt.Sprintf("~%d\r\n%s", len(values), strings.Join(values, ""))
|
|
}
|
|
|
|
// COMMAND encodes maps as field/value arrays in RESP2.
|
|
func commandInfoTestMap(pairs ...string) string {
|
|
return commandInfoTestArray(pairs...)
|
|
}
|
|
|
|
func commandInfoTestRESP3Map(pairs ...string) string {
|
|
return fmt.Sprintf("%%%d\r\n%s", len(pairs)/2, strings.Join(pairs, ""))
|
|
}
|
|
|
|
func commandInfoTestEntry6(name string) string {
|
|
return commandInfoTestArray(
|
|
commandInfoTestBulk(name),
|
|
commandInfoTestInt(-2),
|
|
commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
)
|
|
}
|
|
|
|
func commandInfoTestEntry7(name string) string {
|
|
return commandInfoTestArray(
|
|
commandInfoTestBulk(name),
|
|
commandInfoTestInt(-2),
|
|
commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestArray(commandInfoTestBulk("@read")),
|
|
)
|
|
}
|
|
|
|
func commandInfoTestEntry10(
|
|
name string,
|
|
flags, tips, keySpecs, subcommands string,
|
|
) string {
|
|
return commandInfoTestArray(
|
|
commandInfoTestBulk(name),
|
|
commandInfoTestInt(-2),
|
|
flags,
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestInt(1),
|
|
commandInfoTestArray(),
|
|
tips,
|
|
keySpecs,
|
|
subcommands,
|
|
)
|
|
}
|
|
|
|
func commandInfoTestRangeKeySpec(
|
|
flagsValue, beginTypeValue, beginIndexValue string,
|
|
) string {
|
|
return commandInfoTestMap(
|
|
commandInfoTestBulk("flags"), flagsValue,
|
|
commandInfoTestBulk("begin_search"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), beginTypeValue,
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("index"), beginIndexValue,
|
|
),
|
|
),
|
|
commandInfoTestBulk("find_keys"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("range"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(0),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("limit"), commandInfoTestInt(0),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
func commandInfoTestValidRangeKeySpec() string {
|
|
return commandInfoTestRangeKeySpec(
|
|
commandInfoTestArray(commandInfoTestBulk("RO")),
|
|
commandInfoTestBulk("index"),
|
|
commandInfoTestInt(1),
|
|
)
|
|
}
|
|
|
|
func commandInfoTestReadReply(t *testing.T, raw string) *CommandsInfoCmd {
|
|
t.Helper()
|
|
cmd := NewCommandsInfoCmd(context.Background(), "command")
|
|
if err := cmd.readReply(proto.NewReader(strings.NewReader(raw))); err != nil {
|
|
t.Fatalf("readReply: %v", err)
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func TestCommandsInfoMalformedRecordDoesNotAbortReply(t *testing.T) {
|
|
validSpec := commandInfoTestValidRangeKeySpec()
|
|
validFlags := commandInfoTestArray(commandInfoTestBulk("readonly"))
|
|
empty := commandInfoTestArray()
|
|
|
|
tests := []struct {
|
|
name string
|
|
bad string
|
|
}{
|
|
{
|
|
name: "integer tip",
|
|
bad: commandInfoTestEntry10("bad", validFlags,
|
|
commandInfoTestArray(commandInfoTestInt(1)), commandInfoTestArray(validSpec), empty),
|
|
},
|
|
{
|
|
name: "integer command flag",
|
|
bad: commandInfoTestEntry10("bad", commandInfoTestArray(commandInfoTestInt(1)),
|
|
empty, commandInfoTestArray(validSpec), empty),
|
|
},
|
|
{
|
|
name: "null flags collection",
|
|
bad: commandInfoTestEntry10("bad", "$-1\r\n",
|
|
empty, commandInfoTestArray(validSpec), empty),
|
|
},
|
|
{
|
|
name: "null tips collection",
|
|
bad: commandInfoTestEntry10("bad", validFlags,
|
|
"_\r\n", commandInfoTestArray(validSpec), empty),
|
|
},
|
|
{
|
|
name: "scalar key spec flags",
|
|
bad: commandInfoTestEntry10("bad", validFlags, empty,
|
|
commandInfoTestArray(commandInfoTestRangeKeySpec(
|
|
commandInfoTestBulk("RO"), commandInfoTestBulk("index"), commandInfoTestInt(1),
|
|
)), empty),
|
|
},
|
|
{
|
|
name: "aggregate begin search type",
|
|
bad: commandInfoTestEntry10("bad", validFlags, empty,
|
|
commandInfoTestArray(commandInfoTestRangeKeySpec(
|
|
commandInfoTestArray(commandInfoTestBulk("RO")), empty, commandInfoTestInt(1),
|
|
)), empty),
|
|
},
|
|
{
|
|
name: "aggregate numeric spec value",
|
|
bad: commandInfoTestEntry10("bad", validFlags, empty,
|
|
commandInfoTestArray(commandInfoTestRangeKeySpec(
|
|
commandInfoTestArray(commandInfoTestBulk("RO")), commandInfoTestBulk("index"),
|
|
commandInfoTestArray(commandInfoTestInt(1)),
|
|
)), empty),
|
|
},
|
|
{
|
|
name: "odd RESP2 key spec map",
|
|
bad: commandInfoTestEntry10("bad", validFlags, empty,
|
|
commandInfoTestArray(commandInfoTestArray(
|
|
commandInfoTestBulk("flags"), empty, commandInfoTestBulk("dangling"),
|
|
)), empty),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
raw := commandInfoTestArray(
|
|
commandInfoTestEntry6("before"),
|
|
tt.bad,
|
|
commandInfoTestEntry6("after"),
|
|
)
|
|
cmd := commandInfoTestReadReply(t, raw)
|
|
if cmd.val["before"] == nil || cmd.val["after"] == nil {
|
|
t.Fatalf("valid siblings were lost: %#v", cmd.val)
|
|
}
|
|
bad, exists := cmd.val["bad"]
|
|
if !exists || bad != nil {
|
|
t.Fatalf("malformed record = %#v, exists=%v; want nil tombstone", bad, exists)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoNilTokensAreSkipped(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
null string
|
|
collection func(...string) string
|
|
}{
|
|
{"RESP2", "$-1\r\n", commandInfoTestArray},
|
|
{"RESP3", "_\r\n", commandInfoTestSet},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
entry := func(null string) string {
|
|
collection := func(token string) string {
|
|
values := []string{commandInfoTestBulk(token)}
|
|
if null != "" {
|
|
values = []string{null, values[0], null}
|
|
}
|
|
return tc.collection(values...)
|
|
}
|
|
spec := commandInfoTestRangeKeySpec(collection("RO"), commandInfoTestBulk("index"), commandInfoTestInt(1))
|
|
return commandInfoTestArray(commandInfoTestBulk("optional"), commandInfoTestInt(2),
|
|
collection("readonly"), commandInfoTestInt(1), commandInfoTestInt(1), commandInfoTestInt(1),
|
|
collection("@read"), collection("request_policy:all_shards"), tc.collection(spec), tc.collection())
|
|
}
|
|
clean := commandInfoTestReadReply(t, commandInfoTestArray(entry(""))).val["optional"]
|
|
parsed := commandInfoTestReadReply(t, commandInfoTestArray(entry(tc.null), commandInfoTestEntry6("after")))
|
|
info := parsed.val["optional"]
|
|
if info == nil || !reflect.DeepEqual(info, clean) || parsed.val["after"] == nil {
|
|
t.Fatalf("null tokens changed metadata or lost the next record: got=%+v want=%+v", info, clean)
|
|
}
|
|
view := buildCommandMetadataViewForServer(parsed.val, nil, "redis", "8.10.0")
|
|
cmd := NewCmd(context.Background(), "optional", "key")
|
|
if _, ok := cscEligibleMeta(view, cmd); !ok {
|
|
t.Fatal("null tokens disabled otherwise valid CSC metadata")
|
|
}
|
|
if pos, ok := routingFirstKeyPos(view.routingTable["optional"], cmd); !ok || pos != 1 {
|
|
t.Fatalf("null tokens disabled valid routing metadata: key=%d ok=%v", pos, ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoDuplicateNameFailsClosed(t *testing.T) {
|
|
valid := commandInfoTestEntry6("duplicate")
|
|
malformed := commandInfoTestEntry10(
|
|
"duplicate",
|
|
commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(commandInfoTestInt(1)),
|
|
commandInfoTestArray(commandInfoTestValidRangeKeySpec()),
|
|
commandInfoTestArray(),
|
|
)
|
|
for _, entries := range [][]string{{malformed, valid}, {valid, malformed}} {
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(
|
|
entries[0], entries[1], commandInfoTestEntry6("after"),
|
|
))
|
|
if got, exists := cmd.val["duplicate"]; !exists || got != nil {
|
|
t.Fatalf("duplicate record = %#v, exists=%v; want sticky tombstone", got, exists)
|
|
}
|
|
if cmd.val["after"] == nil {
|
|
t.Fatal("duplicate record desynchronized the following record")
|
|
}
|
|
}
|
|
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(valid, valid))
|
|
if cmd.val["duplicate"] == nil {
|
|
t.Fatal("two valid COMMAND INFO duplicates were tombstoned")
|
|
}
|
|
|
|
conflicting := commandInfoTestEntry10(
|
|
"duplicate", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(commandInfoTestBulk("dont_cache")),
|
|
commandInfoTestArray(commandInfoTestValidRangeKeySpec()), commandInfoTestArray(),
|
|
)
|
|
cmd = commandInfoTestReadReply(t, commandInfoTestArray(valid, conflicting, commandInfoTestEntry6("after")))
|
|
if info, ok := cmd.val["duplicate"]; !ok || info != nil {
|
|
t.Fatalf("conflicting duplicate=%+v present=%v, want tombstone", info, ok)
|
|
}
|
|
if cmd.val["after"] == nil {
|
|
t.Fatal("conflicting duplicate desynchronized the following record")
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoUnknownKeySpecAlgorithmsArePreservedFailClosed(t *testing.T) {
|
|
for _, algorithm := range []string{"index", "range"} {
|
|
t.Run(algorithm, func(t *testing.T) {
|
|
spec := strings.Replace(commandInfoTestValidRangeKeySpec(),
|
|
commandInfoTestBulk(algorithm), commandInfoTestBulk("future"), 1)
|
|
entry := commandInfoTestEntry10("future", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(), commandInfoTestArray(spec), commandInfoTestArray())
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(entry, commandInfoTestEntry6("after")))
|
|
info := cmd.val["future"]
|
|
if info == nil || cmd.val["after"] == nil {
|
|
t.Fatal("unknown algorithm lost a well-formed record")
|
|
}
|
|
if got := cscDeriveMeta(info); got.extract != cscKeyExtractNone {
|
|
t.Fatalf("unknown algorithm enabled CSC extraction: %+v", got)
|
|
}
|
|
if got := deriveRoutingCommandMeta("future", info); got.keyState != routingKeysUnknown {
|
|
t.Fatalf("unknown algorithm produced routing keys: %+v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoUnknownMetadataFieldsAreDrained(t *testing.T) {
|
|
begin := commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("index"),
|
|
commandInfoTestBulk("future_begin"), commandInfoTestArray(commandInfoTestBulk("ignored")),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("index"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("future_index"), commandInfoTestMap(
|
|
commandInfoTestBulk("nested"), commandInfoTestBulk("ignored"),
|
|
),
|
|
),
|
|
)
|
|
find := commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("range"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(0),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("limit"), commandInfoTestInt(0),
|
|
),
|
|
)
|
|
spec := commandInfoTestMap(
|
|
commandInfoTestBulk("flags"), commandInfoTestArray(
|
|
commandInfoTestBulk("RO"), commandInfoTestBulk("future_key_flag"),
|
|
),
|
|
commandInfoTestBulk("future_key_spec"), commandInfoTestArray(commandInfoTestBulk("ignored")),
|
|
commandInfoTestBulk("begin_search"), begin,
|
|
commandInfoTestBulk("find_keys"), find,
|
|
)
|
|
entry := commandInfoTestEntry10(
|
|
"extended",
|
|
commandInfoTestArray(commandInfoTestBulk("readonly"), commandInfoTestBulk("future_flag")),
|
|
commandInfoTestArray(commandInfoTestBulk("future_tip")),
|
|
commandInfoTestArray(spec),
|
|
commandInfoTestArray(),
|
|
)
|
|
entry = strings.Replace(entry, "*10\r\n", "*12\r\n", 1) +
|
|
commandInfoTestRESP3Map(commandInfoTestBulk("future"), commandInfoTestArray(commandInfoTestInt(1))) +
|
|
commandInfoTestBulk("ignored")
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(entry, commandInfoTestEntry6("after")))
|
|
info := cmd.val["extended"]
|
|
if info == nil {
|
|
t.Fatal("well-typed extensions must not tombstone the record")
|
|
}
|
|
if len(info.KeySpecs) != 1 || info.KeySpecs[0].Index != 1 || info.KeySpecs[0].KeyStep != 1 {
|
|
t.Fatalf("known key metadata was not retained: %+v", info.KeySpecs)
|
|
}
|
|
if cmd.val["after"] == nil {
|
|
t.Fatal("unknown nested field desynchronized the following record")
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoTrailingFieldsPreserveLiveMetadata(t *testing.T) {
|
|
entry := commandInfoTestEntry10("get", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(), commandInfoTestArray(commandInfoTestValidRangeKeySpec()), commandInfoTestArray())
|
|
extended := strings.Replace(entry, "*10\r\n", "*11\r\n", 1) + commandInfoTestArray(commandInfoTestBulk("future"))
|
|
parsed := commandInfoTestReadReply(t, commandInfoTestArray(extended))
|
|
view := buildCommandMetadataViewForServer(parsed.val, nil, "redis", "8.10.0")
|
|
cmd := NewCmd(context.Background(), "get", "key")
|
|
if pos, ok := routingFirstKeyPos(view.routingTable["get"], cmd); !ok || pos != 1 {
|
|
t.Fatalf("extended live record lost routing metadata: key=%d ok=%v", pos, ok)
|
|
}
|
|
if _, ok := cscEligibleMeta(view, cmd); !ok {
|
|
t.Fatal("extended live record lost CSC eligibility")
|
|
}
|
|
if keys := cscExtractRedisKeys(view.cscTable["get"], cmd); len(keys) != 1 || keys[0] != "key" {
|
|
t.Fatalf("extended live record lost CSC metadata: keys=%v", keys)
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoKeySpecSectionsCannotOverwriteEachOther(t *testing.T) {
|
|
// A misplaced field must not overwrite the valid find_keys value.
|
|
spec := commandInfoTestMap(
|
|
commandInfoTestBulk("flags"), commandInfoTestArray(commandInfoTestBulk("RO")),
|
|
commandInfoTestBulk("find_keys"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("range"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(-1),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("limit"), commandInfoTestInt(0),
|
|
),
|
|
),
|
|
commandInfoTestBulk("begin_search"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("index"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("index"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(0),
|
|
),
|
|
),
|
|
)
|
|
entry := commandInfoTestEntry10(
|
|
"safe", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(), commandInfoTestArray(spec), commandInfoTestArray(),
|
|
)
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(entry))
|
|
info := cmd.val["safe"]
|
|
if info == nil || len(info.KeySpecs) != 1 || info.KeySpecs[0].LastKey != -1 {
|
|
t.Fatalf("cross-section field changed key range: %+v", info)
|
|
}
|
|
meta := cscDeriveMeta(info)
|
|
keys := cscExtractRedisKeys(meta, NewCmd(context.Background(), "safe", "one", "two"))
|
|
if len(keys) != 2 || keys[0] != "one" || keys[1] != "two" {
|
|
t.Fatalf("cross-section field caused partial extraction: %v (%+v)", keys, meta)
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoRESP3KeySpecMapsAndSevenFieldRecord(t *testing.T) {
|
|
resp3Spec := commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("flags"), commandInfoTestArray(commandInfoTestBulk("RO")),
|
|
commandInfoTestBulk("begin_search"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("index"),
|
|
commandInfoTestBulk("spec"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("index"), commandInfoTestInt(1),
|
|
),
|
|
),
|
|
commandInfoTestBulk("find_keys"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("range"),
|
|
commandInfoTestBulk("spec"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(0),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("limit"), commandInfoTestInt(0),
|
|
),
|
|
),
|
|
)
|
|
resp3 := commandInfoTestEntry10(
|
|
"resp3", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(), commandInfoTestArray(resp3Spec), commandInfoTestArray(),
|
|
)
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(commandInfoTestEntry7("redis6"), resp3))
|
|
|
|
legacy := cmd.val["redis6"]
|
|
if legacy == nil || legacy.Arity != -2 || len(legacy.ACLFlags) != 1 || legacy.ACLFlags[0] != "@read" {
|
|
t.Fatalf("seven-field record did not parse: %+v", legacy)
|
|
}
|
|
if _, ok := cmd.legacyRecords["redis6"]; !ok {
|
|
t.Fatal("seven-field record lost its legacy wire-shape provenance")
|
|
}
|
|
info := cmd.val["resp3"]
|
|
if info == nil || len(info.KeySpecs) != 1 || info.KeySpecs[0].Index != 1 || info.KeySpecs[0].KeyStep != 1 {
|
|
t.Fatalf("RESP3 key-spec maps did not parse: %+v", info)
|
|
}
|
|
if _, ok := cmd.legacyRecords["resp3"]; ok {
|
|
t.Fatal("ten-field record was incorrectly marked legacy")
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoRESP3CollectionsUseSets(t *testing.T) {
|
|
resp3Spec := commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("flags"), commandInfoTestSet(commandInfoTestBulk("RO"), commandInfoTestBulk("access")),
|
|
commandInfoTestBulk("begin_search"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("index"),
|
|
commandInfoTestBulk("spec"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("index"), commandInfoTestInt(1),
|
|
),
|
|
),
|
|
commandInfoTestBulk("find_keys"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("range"),
|
|
commandInfoTestBulk("spec"), commandInfoTestRESP3Map(
|
|
commandInfoTestBulk("lastkey"), commandInfoTestInt(0),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("limit"), commandInfoTestInt(0),
|
|
),
|
|
),
|
|
)
|
|
child := commandInfoTestEntry10(
|
|
"parent|child",
|
|
commandInfoTestSet(commandInfoTestBulk("readonly")),
|
|
commandInfoTestSet(),
|
|
commandInfoTestSet(resp3Spec),
|
|
commandInfoTestSet(),
|
|
)
|
|
parent := commandInfoTestEntry10(
|
|
"parent",
|
|
commandInfoTestSet(),
|
|
commandInfoTestSet(commandInfoTestBulk("future_tip")),
|
|
commandInfoTestSet(),
|
|
commandInfoTestSet(child),
|
|
)
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(parent, commandInfoTestEntry6("after")))
|
|
|
|
info := cmd.val["parent|child"]
|
|
if info == nil || !info.ReadOnly || len(info.KeySpecs) != 1 || info.KeySpecs[0].Index != 1 {
|
|
t.Fatalf("RESP3 set collections did not parse: %+v", info)
|
|
}
|
|
if cmd.val["parent"] == nil || cmd.val["after"] == nil {
|
|
t.Fatalf("RESP3 set collection desynchronized sibling records: %#v", cmd.val)
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoMalformedSubcommandIsIsolated(t *testing.T) {
|
|
empty := commandInfoTestArray()
|
|
validFlags := commandInfoTestArray(commandInfoTestBulk("readonly"))
|
|
badChild := commandInfoTestEntry10(
|
|
"parent|bad", validFlags, commandInfoTestArray(commandInfoTestInt(1)),
|
|
commandInfoTestArray(commandInfoTestValidRangeKeySpec()), empty,
|
|
)
|
|
parent := commandInfoTestEntry10(
|
|
"parent", commandInfoTestArray(), empty, empty,
|
|
commandInfoTestArray(
|
|
commandInfoTestEntry6("parent|before"),
|
|
badChild,
|
|
commandInfoTestEntry6("parent|after"),
|
|
),
|
|
)
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(parent))
|
|
for _, name := range []string{"parent", "parent|before", "parent|after"} {
|
|
if cmd.val[name] == nil {
|
|
t.Fatalf("valid record %q was lost", name)
|
|
}
|
|
}
|
|
if bad, exists := cmd.val["parent|bad"]; !exists || bad != nil {
|
|
t.Fatalf("malformed child = %#v, exists=%v; want nil tombstone", bad, exists)
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoUnknownEntryShapesFailClosed(t *testing.T) {
|
|
valid := commandInfoTestEntry6("bad")
|
|
for _, bad := range []string{
|
|
commandInfoTestBulk("not-an-entry"),
|
|
strings.Replace(valid, "*6", "~6", 1), // An unordered set cannot describe positional fields.
|
|
strings.Replace(valid, "*6", "*8", 1) + commandInfoTestArray() + commandInfoTestArray(),
|
|
} {
|
|
cmd := commandInfoTestReadReply(t, commandInfoTestArray(bad, commandInfoTestEntry6("after")))
|
|
if cmd.val["bad"] != nil || cmd.val["after"] == nil {
|
|
t.Fatalf("unknown record was accepted or lost its sibling: %#v", cmd.val)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoParserLimits(t *testing.T) {
|
|
for name, raw := range map[string]string{
|
|
"truncated record": "*1\r\n*2\r\n$3\r\nbad\r\n$5\r\nx",
|
|
"nested value": "*1\r\n" + strings.Repeat("*1\r\n", maxCommandInfoDepth+2) + "+ignored\r\n",
|
|
"nested attributes": strings.Repeat("|0\r\n", maxCommandInfoDepth+2) + commandInfoTestArray(commandInfoTestEntry6("get")),
|
|
"huge count": "*9223372036854775807\r\n",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cmd := NewCommandsInfoCmd(context.Background(), "command")
|
|
if err := cmd.readReply(proto.NewReader(strings.NewReader(raw))); err == nil {
|
|
t.Fatal("unsafe or truncated response returned no error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoTopLevelRedisErrorIsTypedAndConsumed(t *testing.T) {
|
|
rd := proto.NewReader(strings.NewReader(
|
|
"-NOPERM this user has no permissions to run the 'command' command\r\n" +
|
|
commandInfoTestArray(commandInfoTestEntry6("after")),
|
|
))
|
|
cmd := NewCommandsInfoCmd(context.Background(), "command")
|
|
err := cmd.readReply(rd)
|
|
if err == nil || !isRedisError(err) || !strings.Contains(err.Error(), "NOPERM") {
|
|
t.Fatalf("top-level error = %T %v, want typed NOPERM", err, err)
|
|
}
|
|
|
|
next := NewCommandsInfoCmd(context.Background(), "command")
|
|
if err := next.readReply(rd); err != nil {
|
|
t.Fatalf("next aligned COMMAND reply failed: %v", err)
|
|
}
|
|
if next.val["after"] == nil {
|
|
t.Fatal("next aligned COMMAND record was not parsed")
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoNullAttributeKeepsTheStreamAligned(t *testing.T) {
|
|
// A null attribute (|-1) is not valid RESP3, but a proxy can emit one.
|
|
// ReadAttributeLen consumes the header, so the parser must keep unwrapping:
|
|
// reporting proto.Nil instead makes the scalar readers treat the wrapped
|
|
// value as already consumed and leave it on the wire for the next command.
|
|
decorate := func(value string) string { return "|-1\r\n" + value }
|
|
cleanEntry := commandInfoTestArray(
|
|
commandInfoTestBulk("first"), commandInfoTestInt(-2),
|
|
commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestInt(1), commandInfoTestInt(1), commandInfoTestInt(1),
|
|
)
|
|
plain := commandInfoTestArray(cleanEntry, commandInfoTestEntry6("second"))
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
}{
|
|
{
|
|
name: "before the reply",
|
|
raw: decorate(plain),
|
|
},
|
|
{
|
|
name: "before a record",
|
|
raw: commandInfoTestArray(
|
|
decorate(cleanEntry), commandInfoTestEntry6("second"),
|
|
),
|
|
},
|
|
{
|
|
name: "before a field",
|
|
raw: commandInfoTestArray(
|
|
commandInfoTestArray(
|
|
commandInfoTestBulk("first"),
|
|
decorate(commandInfoTestInt(-2)),
|
|
commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestInt(1), commandInfoTestInt(1), commandInfoTestInt(1),
|
|
),
|
|
commandInfoTestEntry6("second"),
|
|
),
|
|
},
|
|
}
|
|
|
|
want := commandInfoTestReadReply(t, plain)
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rd := proto.NewReader(strings.NewReader(tc.raw +
|
|
commandInfoTestArray(commandInfoTestEntry6("after"))))
|
|
cmd := NewCommandsInfoCmd(context.Background(), "command")
|
|
if err := cmd.readReply(rd); err != nil {
|
|
t.Fatalf("readReply: %v", err)
|
|
}
|
|
if len(cmd.val) != 2 || !reflect.DeepEqual(cmd.val["first"], want.val["first"]) ||
|
|
!reflect.DeepEqual(cmd.val["second"], want.val["second"]) {
|
|
t.Fatalf("null attribute changed the reply: got=%+v want=%+v", cmd.val, want.val)
|
|
}
|
|
|
|
// The decorated reply must be fully consumed: the next command on the
|
|
// same connection reads its own reply, not the leftover frames.
|
|
next := NewCommandsInfoCmd(context.Background(), "command")
|
|
if err := next.readReply(rd); err != nil {
|
|
t.Fatalf("next aligned COMMAND reply failed: %v", err)
|
|
}
|
|
if next.val["after"] == nil {
|
|
t.Fatalf("next reply read out of step: got=%+v", next.val)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoNumericKeySpecFields(t *testing.T) {
|
|
indexRange := commandInfoTestValidRangeKeySpec()
|
|
keywordKeynum := commandInfoTestMap(
|
|
commandInfoTestBulk("flags"), commandInfoTestArray(commandInfoTestBulk("RO")),
|
|
commandInfoTestBulk("begin_search"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("keyword"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("keyword"), commandInfoTestBulk("KEYS"),
|
|
commandInfoTestBulk("startfrom"), commandInfoTestInt(2),
|
|
),
|
|
),
|
|
commandInfoTestBulk("find_keys"), commandInfoTestMap(
|
|
commandInfoTestBulk("type"), commandInfoTestBulk("keynum"),
|
|
commandInfoTestBulk("spec"), commandInfoTestMap(
|
|
commandInfoTestBulk("keynumidx"), commandInfoTestInt(0),
|
|
commandInfoTestBulk("firstkey"), commandInfoTestInt(1),
|
|
commandInfoTestBulk("keystep"), commandInfoTestInt(2),
|
|
),
|
|
),
|
|
)
|
|
entry := commandInfoTestEntry10(
|
|
"numeric", commandInfoTestArray(commandInfoTestBulk("readonly")),
|
|
commandInfoTestArray(), commandInfoTestArray(indexRange, keywordKeynum), commandInfoTestArray(),
|
|
)
|
|
info := commandInfoTestReadReply(t, commandInfoTestArray(entry)).val["numeric"]
|
|
if info == nil || len(info.KeySpecs) != 2 {
|
|
t.Fatalf("numeric record did not parse: %+v", info)
|
|
}
|
|
rangeSpec, keynumSpec := info.KeySpecs[0], info.KeySpecs[1]
|
|
if rangeSpec.Index != 1 || rangeSpec.LastKey != 0 || rangeSpec.KeyStep != 1 || rangeSpec.Limit != 0 {
|
|
t.Errorf("range fields = %+v", rangeSpec)
|
|
}
|
|
if keynumSpec.Keyword != "KEYS" || keynumSpec.StartFrom != 2 || keynumSpec.KeyNumIdx != 0 ||
|
|
keynumSpec.FirstKey != 1 || keynumSpec.KeyStep != 2 {
|
|
t.Errorf("keynum fields = %+v", keynumSpec)
|
|
}
|
|
}
|
|
|
|
func TestCommandsInfoClonePreservesMalformedTombstone(t *testing.T) {
|
|
cmd := &CommandsInfoCmd{val: map[string]*CommandInfo{
|
|
"bad": nil,
|
|
"good": {Name: "good"},
|
|
}, legacyRecords: map[string]struct{}{"good": {}}}
|
|
clone := cmd.Clone().(*CommandsInfoCmd)
|
|
if got, exists := clone.val["bad"]; !exists || got != nil {
|
|
t.Fatalf("cloned tombstone = %#v, exists=%v", got, exists)
|
|
}
|
|
if clone.val["good"] == nil || clone.val["good"] == cmd.val["good"] {
|
|
t.Fatal("valid record was not deep-cloned")
|
|
}
|
|
if _, ok := clone.legacyRecords["good"]; !ok {
|
|
t.Fatal("legacy wire-shape provenance was not cloned")
|
|
}
|
|
delete(clone.legacyRecords, "good")
|
|
if _, ok := cmd.legacyRecords["good"]; !ok {
|
|
t.Fatal("clone shares its legacy provenance map with the original")
|
|
}
|
|
}
|