1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00

fix(probabilistic): tolerate new fields in *.INFO replies (#4010)

* fix(probabilistic): tolerate new fields in *.INFO replies

Redis 8.12 adds "cell size" to CMS.INFO (the new CELL_SIZE option for
CMS.INITBYDIM / CMS.INITBYPROB). The parser rejected any unknown key,
so CMSInfo failed against the unstable image with
"redis: CMS.INFO unexpected key cell size".

Expose the new value as CMSInfo.CellSize and, as XINFO already does,
drain and skip unknown map fields instead of erroring. Apply the same
forward-compatible default to BF.INFO, CF.INFO, TOPK.INFO and
TDIGEST.INFO. BF.INFO with an explicit unknown attribute still errors,
since that is a caller mistake.

* Changes field name to updated value
This commit is contained in:
Elena Kolevska
2026-09-11 09:25:32 +01:00
committed by GitHub
parent 8010edc761
commit 48acccde4d
3 changed files with 127 additions and 8 deletions

View File

@@ -350,10 +350,15 @@ func (cmd *BFInfoCmd) readReply(rd *proto.Reader) (err error) {
"EXPANSION": &result.ExpansionRate,
}
// Helper function to read and assign a value based on the key
readAndAssignValue := func(key string) error {
// Helper function to read and assign a value based on the key.
// Unknown keys are drained and skipped when skipUnknown is set so that
// fields added by newer servers don't break the parser.
readAndAssignValue := func(key string, skipUnknown bool) error {
fieldPtr, exists := respMapping[key]
if !exists {
if skipUnknown {
return rd.DiscardNext()
}
return fmt.Errorf("redis: BLOOM.INFO unexpected key %s", key)
}
@@ -377,7 +382,7 @@ func (cmd *BFInfoCmd) readReply(rd *proto.Reader) (err error) {
return err
}
if key, ok := cmd.args[2].(string); ok && n == 1 {
if err := readAndAssignValue(key); err != nil {
if err := readAndAssignValue(key, false); err != nil {
return err
}
} else {
@@ -393,7 +398,7 @@ func (cmd *BFInfoCmd) readReply(rd *proto.Reader) (err error) {
if err != nil {
return err
}
if err := readAndAssignValue(key); err != nil {
if err := readAndAssignValue(key, true); err != nil {
return err
}
}
@@ -706,7 +711,8 @@ func (cmd *CFInfoCmd) readReply(rd *proto.Reader) (err error) {
result.MaxIteration, err = rd.ReadInt()
default:
return fmt.Errorf("redis: CF.INFO unexpected key %s", key)
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
@@ -809,6 +815,10 @@ type CMSInfo struct {
Width int64
Depth int64
Count int64
// CellSize is the size in bytes of each counter (1, 2, 4 or 8).
// Reported since Redis 8.12, alongside the CELL_SIZE option of
// CMS.INITBYDIM / CMS.INITBYPROB; zero on older servers.
CellSize int64
}
type CMSInfoCmd struct {
@@ -867,8 +877,11 @@ func (cmd *CMSInfoCmd) readReply(rd *proto.Reader) (err error) {
result.Depth, err = rd.ReadInt()
case "count":
result.Count, err = rd.ReadInt()
case "cell_size":
result.CellSize, err = rd.ReadInt()
default:
return fmt.Errorf("redis: CMS.INFO unexpected key %s", key)
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
@@ -1074,7 +1087,8 @@ func (cmd *TopKInfoCmd) readReply(rd *proto.Reader) (err error) {
case "decay":
result.Decay, err = rd.ReadFloat()
default:
return fmt.Errorf("redis: topk.info unexpected key %s", key)
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
@@ -1342,7 +1356,8 @@ func (cmd *TDigestInfoCmd) readReply(rd *proto.Reader) (err error) {
case "Memory usage":
result.MemoryUsage, err = rd.ReadInt()
default:
return fmt.Errorf("redis: tdigest.info unexpected key %s", key)
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {

View File

@@ -482,6 +482,17 @@ var _ = Describe("Probabilistic commands", Label("probabilistic"), func() {
Expect(info.Depth).To(BeEquivalentTo(int64(10)))
})
It("should report CMSInfo cell size", Label("cms", "cmsinfo"), func() {
SkipBeforeRedisVersion("8.11", "CMS.INFO reports cell_size since Redis 8.12 (8.11 pre-release)")
err := client.CMSInitByDim(ctx, "testcms1", 5, 10).Err()
Expect(err).NotTo(HaveOccurred())
info, err := client.CMSInfo(ctx, "testcms1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(info.CellSize).To(BeEquivalentTo(int64(4)))
})
It("should CMSInitByProb", Label("cms", "cmsinitbyprob"), func() {
err := client.CMSInitByProb(ctx, "testcms1", 0.002, 0.01).Err()
Expect(err).NotTo(HaveOccurred())

View File

@@ -0,0 +1,93 @@
package redis
import (
"context"
"strings"
"testing"
"github.com/redis/go-redis/v9/internal/proto"
)
// The probabilistic *.INFO replies are open-ended maps; newer servers add
// fields (e.g. CMS.INFO "cell_size" in Redis 8.12). Unknown fields must be
// drained so the reader stays aligned with the following reply instead of
// failing the whole command.
func TestCMSInfoCmdParsesCellSizeAndIgnoresUnknownField(t *testing.T) {
reply := "%5\r\n" +
"+width\r\n:2000\r\n" +
"+depth\r\n:5\r\n" +
"+count\r\n:0\r\n" +
"+cell_size\r\n:4\r\n" +
"+new-cms-field\r\n$5\r\nhello\r\n" // unknown field, string value
cmd := NewCMSInfoCmd(context.Background())
rd := proto.NewReader(strings.NewReader(reply + "+NEXT\r\n"))
if err := cmd.readReply(rd); err != nil {
t.Fatalf("readReply() returned unexpected error: %v", err)
}
want := CMSInfo{Width: 2000, Depth: 5, Count: 0, CellSize: 4}
if got := cmd.Val(); got != want {
t.Fatalf("CMSInfo = %+v, want %+v", got, want)
}
if next, err := rd.ReadString(); err != nil || next != "NEXT" {
t.Fatalf("stream desynced: next=%q err=%v", next, err)
}
}
func TestProbabilisticInfoCmdsIgnoreUnknownField(t *testing.T) {
// unknown field with a nested (array) value, to exercise a full discard
unknown := "+new-field\r\n*2\r\n:1\r\n$3\r\nfoo\r\n"
tests := []struct {
name string
reply string
cmd interface {
Cmder
readReply(rd *proto.Reader) error
}
}{
{
name: "BF.INFO",
reply: "%2\r\n+Capacity\r\n:100\r\n" + unknown,
cmd: NewBFInfoCmd(context.Background(), "bf.info", "key"),
},
{
name: "CF.INFO",
reply: "%2\r\n+Size\r\n:100\r\n" + unknown,
cmd: NewCFInfoCmd(context.Background()),
},
{
name: "TOPK.INFO",
reply: "%2\r\n+k\r\n:3\r\n" + unknown,
cmd: NewTopKInfoCmd(context.Background()),
},
{
name: "TDIGEST.INFO",
reply: "%2\r\n+Compression\r\n:100\r\n" + unknown,
cmd: NewTDigestInfoCmd(context.Background()),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rd := proto.NewReader(strings.NewReader(tc.reply + "+NEXT\r\n"))
if err := tc.cmd.readReply(rd); err != nil {
t.Fatalf("readReply() returned unexpected error: %v", err)
}
if next, err := rd.ReadString(); err != nil || next != "NEXT" {
t.Fatalf("stream desynced: next=%q err=%v", next, err)
}
})
}
}
// Asking BF.INFO for a specific unknown attribute is a caller error and must
// still be reported rather than silently skipped.
func TestBFInfoCmdSingleUnknownAttributeStillErrors(t *testing.T) {
cmd := NewBFInfoCmd(context.Background(), "bf.info", "key", "BOGUS")
rd := proto.NewReader(strings.NewReader("*1\r\n:1\r\n"))
if err := cmd.readReply(rd); err == nil {
t.Fatal("readReply() = nil, want error for unknown attribute")
}
}