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

feat(search): parser resp3 responses for search (#3741)

* parse resp3 responses

* backwards compatibility

* doctests and format

* fix parsing

* remove unused functions

* debug test

* debug test x2 peek directly

* debug test

* remove debug and skip re build

* fix typo

* add tests and fix parsers for resp3

* add comment for the unreliable Total in RESP3 for aggregate

* fix build

* remove shadowing rawVal

* fix strange redis search float,int,string behaviour handling

* mark UnstableResp3 as Deprecated

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
This commit is contained in:
Nedyalko Dyakov
2026-05-21 14:18:35 +03:00
committed by GitHub
parent 540781831b
commit 14a9a85363
21 changed files with 2627 additions and 151 deletions

View File

@@ -10,6 +10,7 @@ permissions:
jobs:
build:
if: false # Temporarily disabled, should be enabled when we have a current and stable RE build
name: build
runs-on: ubuntu-latest
strategy:

View File

@@ -199,11 +199,11 @@ func ExampleClient_geoindex() {
// OK
// OK
// OK
// {1 [{product:46885 <nil> <nil> <nil> map[$:{"city":"Denver","description":"Navy Blue Slippers","location":"-104.991531, 39.742043","price":45.99}] <nil>}]}
// {1 [{product:46885 <nil> <nil> <nil> map[$:{"city":"Denver","description":"Navy Blue Slippers","location":"-104.991531, 39.742043","price":45.99}] <nil>}] []}
// OK
// OK
// OK
// OK
// OK
// {1 [{shape:4 <nil> <nil> <nil> map[$:[{"geom":"POINT (2 2)","name":"Purple Point"}]] <nil>}]}
// {1 [{shape:4 <nil> <nil> <nil> map[$:[{"geom":"POINT (2 2)","name":"Purple Point"}]] <nil>}] []}
}

View File

@@ -219,7 +219,7 @@ func ExampleClient_search_json() {
// STEP_END
// Output:
// {1 [{user:3 <nil> <nil> <nil> map[$:{"age":35,"city":"Tel Aviv","email":"paul.zamir@example.com","name":"Paul Zamir"}] <nil>}]}
// {1 [{user:3 <nil> <nil> <nil> map[$:{"age":35,"city":"Tel Aviv","email":"paul.zamir@example.com","name":"Paul Zamir"}] <nil>}] []}
// London
// Tel Aviv
// 0
@@ -329,5 +329,5 @@ func ExampleClient_search_hash() {
// STEP_END
// Output:
// {1 [{huser:3 <nil> <nil> <nil> map[age:35 city:Tel Aviv email:paul.zamir@example.com name:Paul Zamir] <nil>}]}
// {1 [{huser:3 <nil> <nil> <nil> map[age:35 city:Tel Aviv email:paul.zamir@example.com name:Paul Zamir] <nil>}] []}
}

View File

@@ -257,6 +257,6 @@ func ExampleClient_search_qs() {
// Output:
// Documents found: 10
// {1 [{bicycle:0 <nil> <nil> <nil> map[$:{"brand":"Velorim","condition":"new","description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","model":"Jigger","price":270}] <nil>}]}
// {1 [{bicycle:4 <nil> <nil> <nil> map[$:{"brand":"Noka Bikes","condition":"used","description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","model":"Kahuna","price":3200}] <nil>}]}
// {1 [{bicycle:0 <nil> <nil> <nil> map[$:{"brand":"Velorim","condition":"new","description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","model":"Jigger","price":270}] <nil>}] []}
// {1 [{bicycle:4 <nil> <nil> <nil> map[$:{"brand":"Noka Bikes","condition":"used","description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","model":"Kahuna","price":3200}] <nil>}] []}
}

View File

@@ -31,7 +31,7 @@ func main() {
// Example 2: TLS with CA certificate verification
fmt.Println("\nExample 2: TLS with CA certificate verification")
// Load CA certificate
caCert, err := os.ReadFile("path/to/ca.crt")
if err != nil {
@@ -58,7 +58,7 @@ func main() {
// Example 3: TLS with client certificate (mutual TLS)
fmt.Println("\nExample 3: TLS with client certificate (mutual TLS)")
// Load CA certificate
caCert, err = os.ReadFile("path/to/ca.crt")
if err != nil {
@@ -92,18 +92,18 @@ func main() {
// Example 4: Using rediss:// URL scheme
fmt.Println("\nExample 4: Using rediss:// URL scheme")
opt, err := redis.ParseURL("rediss://localhost:6666")
if err != nil {
fmt.Printf("Failed to parse URL: %v\n", err)
return
}
// Add InsecureSkipVerify for testing with self-signed certs
opt.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
}
client4 := redis.NewClient(opt)
defer client4.Close()
@@ -121,4 +121,3 @@ func main() {
fmt.Println("The certificate's CN (Common Name) field will be used as the Redis username")
fmt.Println("See tls_cert_auth_test.go for a complete working example")
}

View File

@@ -86,8 +86,8 @@ func TestManager_Listener_NilConn(t *testing.T) {
// Mock pooler for testing
type mockPooler struct{}
func (m *mockPooler) NewConn(ctx context.Context) (*pool.Conn, error) { return nil, nil }
func (m *mockPooler) CloseConn(context.Context, *pool.Conn, string, string) error { return nil }
func (m *mockPooler) NewConn(ctx context.Context) (*pool.Conn, error) { return nil, nil }
func (m *mockPooler) CloseConn(context.Context, *pool.Conn, string, string) error { return nil }
func (m *mockPooler) Get(ctx context.Context) (*pool.Conn, error) { return nil, nil }
func (m *mockPooler) Put(ctx context.Context, conn *pool.Conn) {}
func (m *mockPooler) Remove(ctx context.Context, conn *pool.Conn, reason error) {}

View File

@@ -2,6 +2,7 @@ package internal
import (
"context"
"math"
"net"
"strconv"
"strings"
@@ -10,6 +11,29 @@ import (
"github.com/redis/go-redis/v9/internal/util"
)
// String representations of special float values.
// Values are lowercase for consistency with Redis RESP2 protocol responses.
const (
NaN = "nan" // Not a Number
Inf = "inf" // Positive infinity
NInf = "-inf" // Negative infinity
)
// FormatFloat formats a float64 to string, normalizing special values
// (NaN, Inf) to lowercase for consistency with Redis RESP2 protocol.
func FormatFloat(f float64) string {
switch {
case math.IsNaN(f):
return NaN
case math.IsInf(f, 1):
return Inf
case math.IsInf(f, -1):
return NInf
default:
return strconv.FormatFloat(f, 'f', -1, 64)
}
}
func Sleep(ctx context.Context, dur time.Duration) error {
t := time.NewTimer(dur)
defer t.Stop()

View File

@@ -1,6 +1,7 @@
package internal
import (
"math"
"runtime"
"strings"
"testing"
@@ -106,3 +107,29 @@ func BenchmarkReplaceSpacesUseBuilder(b *testing.B) {
_ = ReplaceSpacesUseBuilder(version)
}
}
func TestFormatFloat(t *testing.T) {
tests := []struct {
name string
input float64
expected string
}{
{"NaN", math.NaN(), NaN},
{"positive infinity", math.Inf(1), Inf},
{"negative infinity", math.Inf(-1), NInf},
{"zero", 0, "0"},
{"positive integer", 42, "42"},
{"negative integer", -42, "-42"},
{"positive float", 3.14159, "3.14159"},
{"negative float", -3.14159, "-3.14159"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatFloat(tt.input)
if result != tt.expected {
t.Errorf("FormatFloat(%v) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package redis_test
import (
"context"
"encoding/json"
"fmt"
"time"
. "github.com/bsm/ginkgo/v2"
@@ -846,12 +847,12 @@ var _ = Describe("Go-Redis Advanced JSON and RediSearch Tests", func() {
getCmdRaw := client.JSONGet(ctx, "person:1", ".")
rawJSON, err := getCmdRaw.Result()
Expect(err).NotTo(HaveOccurred(), "JSON.GET (raw) failed")
GinkgoWriter.Printf("Raw JSON: %s\n", rawJSON)
fmt.Printf("Raw JSON: %s\n", rawJSON)
getCmdExpanded := client.JSONGet(ctx, "person:1", ".")
expandedJSON, err := getCmdExpanded.Expanded()
Expect(err).NotTo(HaveOccurred(), "JSON.GET (expanded) failed")
GinkgoWriter.Printf("Expanded JSON: %+v\n", expandedJSON)
fmt.Printf("Expanded JSON: %+v\n", expandedJSON)
Expect(rawJSON).To(MatchJSON(jsonMustMarshal(expandedJSON)))
@@ -897,7 +898,7 @@ var _ = Describe("Go-Redis Advanced JSON and RediSearch Tests", func() {
searchCmd := client.FTSearchWithArgs(ctx, "person_idx", "@contact_value:(alice\\@example\\.com alice_wonder)", &redis.FTSearchOptions{Return: []redis.FTSearchReturn{{FieldName: "$.person.name"}, {FieldName: "$.person.age"}, {FieldName: "$.person.address.city"}}})
searchResult, err := searchCmd.Result()
Expect(err).NotTo(HaveOccurred(), "FT.SEARCH failed")
GinkgoWriter.Printf("Advanced Search result: %+v\n", searchResult)
fmt.Printf("Advanced Search result: %+v\n", searchResult)
incrCmd := client.JSONNumIncrBy(ctx, "person:1", "$.person.age", 5)
incrResult, err := incrCmd.Result()

View File

@@ -292,6 +292,10 @@ type Options struct {
// UnstableResp3 enables Unstable mode for Redis Search module with RESP3.
// When unstable mode is enabled, the client will use RESP3 protocol and only be able to use RawResult
//
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// Push notifications are always enabled for RESP3 connections (Protocol: 3)

View File

@@ -164,6 +164,10 @@ type ClusterOptions struct {
IdentitySuffix string // Add suffix to client name. Default is empty.
// UnstableResp3 enables Unstable mode for Redis Search module with RESP3.
//
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.

View File

@@ -26,6 +26,14 @@ type Scanner = hscan.Scanner
// Nil reply returned by Redis when key does not exist.
const Nil = proto.Nil
// String representations of special float values.
// Values are lowercase for consistency with Redis RESP2 protocol responses.
const (
NaN = internal.NaN // Not a Number
Inf = internal.Inf // Positive infinity
NInf = internal.NInf // Negative infinity
)
// SetLogger set custom log
// Use with VoidLogger to disable logging.
// If logger is nil, the call is ignored and the existing logger is kept.
@@ -936,16 +944,9 @@ func classifyCommandError(err error) (errorType, statusCode string, isInternal b
}
func (c *baseClient) assertUnstableCommand(cmd Cmder) (bool, error) {
switch cmd.(type) {
case *AggregateCmd, *FTInfoCmd, *FTSpellCheckCmd, *FTSearchCmd, *FTSynDumpCmd:
if c.opt.UnstableResp3 {
return true, nil
} else {
return false, fmt.Errorf("RESP3 responses for this command are disabled because they may still change. Please set the flag UnstableResp3. See the README and the release notes for guidance")
}
default:
return false, nil
}
// All search commands (FTSearchCmd, AggregateCmd, FTInfoCmd, FTSpellCheckCmd, FTSynDumpCmd)
// now have stable RESP3 parsing. No commands require the UnstableResp3 flag anymore.
return false, nil
}
func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, *pool.Conn, error) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

372
search_resp_test.go Normal file
View File

@@ -0,0 +1,372 @@
package redis_test
import (
"context"
"testing"
"github.com/redis/go-redis/v9"
)
// TestSearchCommandsRESP2AndRESP3Equivalence tests that search commands
// return equivalent results for both RESP2 and RESP3 protocols.
func TestSearchCommandsRESP2AndRESP3Equivalence(t *testing.T) {
ctx := context.Background()
// RESP2 client
client2 := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Protocol: 2,
})
defer client2.Close()
// RESP3 client
client3 := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client3.Close()
// Check connection
if err := client2.Ping(ctx).Err(); err != nil {
t.Skipf("Redis not available: %v", err)
}
// Clean up before test
client2.FTDropIndex(ctx, "test-idx")
client2.Del(ctx, "doc1", "doc2", "doc3")
t.Run("FTInfo", func(t *testing.T) {
// Create index
_, err := client2.FTCreate(ctx, "test-idx",
&redis.FTCreateOptions{},
&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText, Sortable: true},
&redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric, Sortable: true},
).Result()
if err != nil {
t.Fatalf("FTCreate failed: %v", err)
}
defer client2.FTDropIndex(ctx, "test-idx")
// Add some documents
client2.HSet(ctx, "doc1", "title", "hello world", "score", 100)
client2.HSet(ctx, "doc2", "title", "foo bar", "score", 200)
// Wait for indexing
waitForIndexing(t, client2, "test-idx", 2)
// Get FTInfo from both protocols
info2, err := client2.FTInfo(ctx, "test-idx").Result()
if err != nil {
t.Fatalf("FTInfo RESP2 failed: %v", err)
}
info3, err := client3.FTInfo(ctx, "test-idx").Result()
if err != nil {
t.Fatalf("FTInfo RESP3 failed: %v", err)
}
// Compare key fields
if info2.IndexName != info3.IndexName {
t.Errorf("IndexName mismatch: RESP2=%q, RESP3=%q", info2.IndexName, info3.IndexName)
}
if info2.NumDocs != info3.NumDocs {
t.Errorf("NumDocs mismatch: RESP2=%d, RESP3=%d", info2.NumDocs, info3.NumDocs)
}
if len(info2.Attributes) != len(info3.Attributes) {
t.Errorf("Attributes length mismatch: RESP2=%d, RESP3=%d", len(info2.Attributes), len(info3.Attributes))
}
// Check IndexDefinition
if info2.IndexDefinition.KeyType != info3.IndexDefinition.KeyType {
t.Errorf("IndexDefinition.KeyType mismatch: RESP2=%q, RESP3=%q",
info2.IndexDefinition.KeyType, info3.IndexDefinition.KeyType)
}
if info2.IndexDefinition.DefaultScore != info3.IndexDefinition.DefaultScore {
t.Errorf("IndexDefinition.DefaultScore mismatch: RESP2=%v, RESP3=%v",
info2.IndexDefinition.DefaultScore, info3.IndexDefinition.DefaultScore)
}
// Check CursorStats
if info2.CursorStats != info3.CursorStats {
t.Errorf("CursorStats mismatch: RESP2=%+v, RESP3=%+v", info2.CursorStats, info3.CursorStats)
}
// Check IndexErrors
if info2.IndexErrors != info3.IndexErrors {
t.Errorf("IndexErrors mismatch: RESP2=%+v, RESP3=%+v", info2.IndexErrors, info3.IndexErrors)
}
// Check DialectStats
if len(info2.DialectStats) != len(info3.DialectStats) {
t.Errorf("DialectStats length mismatch: RESP2=%d, RESP3=%d", len(info2.DialectStats), len(info3.DialectStats))
}
for k, v := range info2.DialectStats {
if info3.DialectStats[k] != v {
t.Errorf("DialectStats[%s] mismatch: RESP2=%d, RESP3=%d", k, v, info3.DialectStats[k])
}
}
// Check GCStats
if info2.GCStats != info3.GCStats {
t.Errorf("GCStats mismatch: RESP2=%+v, RESP3=%+v", info2.GCStats, info3.GCStats)
}
// The four *_avg fields are emitted by RediSearch via REPLY_KVNUM
// (RedisModule_ReplyWithDouble): bulk strings in RESP2, native doubles
// in RESP3. The FTInfoResult fields are typed as string, so the RESP3
// path must stringify the double; if it does not the field is "" and
// the data is silently lost. Assert both that the RESP3 value is
// non-empty and that it matches the RESP2 value.
avgFields := []struct {
name string
resp2Val string
resp3Val string
}{
{"BytesPerRecordAvg", info2.BytesPerRecordAvg, info3.BytesPerRecordAvg},
{"RecordsPerDocAvg", info2.RecordsPerDocAvg, info3.RecordsPerDocAvg},
{"OffsetsPerTermAvg", info2.OffsetsPerTermAvg, info3.OffsetsPerTermAvg},
{"OffsetBitsPerRecordAvg", info2.OffsetBitsPerRecordAvg, info3.OffsetBitsPerRecordAvg},
}
for _, f := range avgFields {
if f.resp3Val == "" {
t.Errorf("%s is empty under RESP3 (RESP2=%q) - silent data loss", f.name, f.resp2Val)
}
if f.resp2Val != f.resp3Val {
t.Errorf("%s mismatch: RESP2=%q, RESP3=%q", f.name, f.resp2Val, f.resp3Val)
}
}
// total_indexing_time is also emitted via REPLY_KVNUM (double) but the
// FTInfoResult field is typed as int. Without explicit float-to-int
// handling in the RESP3 parser, this field is silently 0.
if info2.TotalIndexingTime != info3.TotalIndexingTime {
t.Errorf("TotalIndexingTime mismatch: RESP2=%d, RESP3=%d",
info2.TotalIndexingTime, info3.TotalIndexingTime)
}
// All gc_stats numeric fields are emitted via REPLY_KVNUM in RediSearch
// (see src/fork_gc/fork_gc.c statsCb) but typed as int in GCStats.
// Compare each individually so a regression surfaces with a precise message.
gcFields := []struct {
name string
resp2, resp3 int
}{
{"BytesCollected", info2.GCStats.BytesCollected, info3.GCStats.BytesCollected},
{"TotalMsRun", info2.GCStats.TotalMsRun, info3.GCStats.TotalMsRun},
{"TotalCycles", info2.GCStats.TotalCycles, info3.GCStats.TotalCycles},
{"LastRunTimeMs", info2.GCStats.LastRunTimeMs, info3.GCStats.LastRunTimeMs},
{"GCNumericTreesMissed", info2.GCStats.GCNumericTreesMissed, info3.GCStats.GCNumericTreesMissed},
{"GCBlocksDenied", info2.GCStats.GCBlocksDenied, info3.GCStats.GCBlocksDenied},
}
for _, f := range gcFields {
if f.resp2 != f.resp3 {
t.Errorf("GCStats.%s mismatch: RESP2=%d, RESP3=%d", f.name, f.resp2, f.resp3)
}
}
})
t.Run("FTSearch", func(t *testing.T) {
// Create index
_, err := client2.FTCreate(ctx, "test-idx",
&redis.FTCreateOptions{},
&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText},
&redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric},
).Result()
if err != nil {
t.Fatalf("FTCreate failed: %v", err)
}
defer client2.FTDropIndex(ctx, "test-idx")
// Add documents
client2.HSet(ctx, "doc1", "title", "hello world", "score", 100)
client2.HSet(ctx, "doc2", "title", "hello redis", "score", 200)
client2.HSet(ctx, "doc3", "title", "goodbye world", "score", 300)
// Wait for indexing
waitForIndexing(t, client2, "test-idx", 3)
// Search from both protocols
result2, err := client2.FTSearch(ctx, "test-idx", "hello").Result()
if err != nil {
t.Fatalf("FTSearch RESP2 failed: %v", err)
}
result3, err := client3.FTSearch(ctx, "test-idx", "hello").Result()
if err != nil {
t.Fatalf("FTSearch RESP3 failed: %v", err)
}
// Compare results
if result2.Total != result3.Total {
t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", result2.Total, result3.Total)
}
if len(result2.Docs) != len(result3.Docs) {
t.Errorf("Docs length mismatch: RESP2=%d, RESP3=%d", len(result2.Docs), len(result3.Docs))
}
t.Logf("RESP2 result: Total=%d, Docs=%d", result2.Total, len(result2.Docs))
t.Logf("RESP3 result: Total=%d, Docs=%d", result3.Total, len(result3.Docs))
for i, doc := range result2.Docs {
t.Logf("RESP2 Doc[%d]: ID=%s, Fields=%v", i, doc.ID, doc.Fields)
}
for i, doc := range result3.Docs {
t.Logf("RESP3 Doc[%d]: ID=%s, Fields=%v", i, doc.ID, doc.Fields)
}
})
t.Run("FTAggregate", func(t *testing.T) {
// Create index
_, err := client2.FTCreate(ctx, "test-idx",
&redis.FTCreateOptions{},
&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText},
&redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric, Sortable: true},
).Result()
if err != nil {
t.Fatalf("FTCreate failed: %v", err)
}
defer client2.FTDropIndex(ctx, "test-idx")
// Add documents
client2.HSet(ctx, "doc1", "title", "hello world", "score", 100)
client2.HSet(ctx, "doc2", "title", "hello redis", "score", 200)
// Wait for indexing
waitForIndexing(t, client2, "test-idx", 2)
// Aggregate from both protocols
options := &redis.FTAggregateOptions{
Load: []redis.FTAggregateLoad{{Field: "@score"}},
}
// Test that RawVal() is populated
cmd2 := client2.FTAggregateWithArgs(ctx, "test-idx", "*", options)
if cmd2.RawVal() == nil {
t.Error("RESP2 RawVal() should not be nil")
}
cmd3 := client3.FTAggregateWithArgs(ctx, "test-idx", "*", options)
if cmd3.RawVal() == nil {
t.Error("RESP3 RawVal() should not be nil")
}
result2, err := cmd2.Result()
if err != nil {
t.Fatalf("FTAggregate RESP2 failed: %v", err)
}
result3, err := cmd3.Result()
if err != nil {
t.Fatalf("FTAggregate RESP3 failed: %v", err)
}
// Compare results
// Note: FT.AGGREGATE total_results is unreliable from the server in RESP3
// It should only match when both are 0 (no results)
if result2.Total == 0 && result3.Total != 0 {
t.Errorf("Total mismatch when RESP2 is 0: RESP2=%d, RESP3=%d", result2.Total, result3.Total)
}
if len(result2.Rows) != len(result3.Rows) {
t.Errorf("Rows length mismatch: RESP2=%d, RESP3=%d", len(result2.Rows), len(result3.Rows))
}
t.Logf("RESP2 result: Total=%d, Rows=%d", result2.Total, len(result2.Rows))
t.Logf("RESP3 result: Total=%d, Rows=%d (note: total_results is unreliable in RESP3)", result3.Total, len(result3.Rows))
for i, row := range result2.Rows {
t.Logf("RESP2 Row[%d]: Fields=%v", i, row.Fields)
}
for i, row := range result3.Rows {
t.Logf("RESP3 Row[%d]: Fields=%v", i, row.Fields)
}
})
t.Run("FTSpellCheck", func(t *testing.T) {
// Create index
_, err := client2.FTCreate(ctx, "test-idx",
&redis.FTCreateOptions{},
&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText},
).Result()
if err != nil {
t.Fatalf("FTCreate failed: %v", err)
}
defer client2.FTDropIndex(ctx, "test-idx")
// Add documents
client2.HSet(ctx, "doc1", "title", "hello world")
client2.HSet(ctx, "doc2", "title", "hello redis")
// Wait for indexing
waitForIndexing(t, client2, "test-idx", 2)
// SpellCheck from both protocols
result2, err := client2.FTSpellCheck(ctx, "test-idx", "helo").Result()
if err != nil {
t.Fatalf("FTSpellCheck RESP2 failed: %v", err)
}
result3, err := client3.FTSpellCheck(ctx, "test-idx", "helo").Result()
if err != nil {
t.Fatalf("FTSpellCheck RESP3 failed: %v", err)
}
t.Logf("RESP2 result: %+v", result2)
t.Logf("RESP3 result: %+v", result3)
// Compare results
if len(result2) != len(result3) {
t.Errorf("Results length mismatch: RESP2=%d, RESP3=%d", len(result2), len(result3))
}
})
t.Run("FTSynDump", func(t *testing.T) {
// Create index
_, err := client2.FTCreate(ctx, "test-idx",
&redis.FTCreateOptions{},
&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText},
).Result()
if err != nil {
t.Fatalf("FTCreate failed: %v", err)
}
defer client2.FTDropIndex(ctx, "test-idx")
// Add synonyms
_, err = client2.FTSynUpdate(ctx, "test-idx", "group1", []interface{}{"hello", "hi", "hey"}).Result()
if err != nil {
t.Fatalf("FTSynUpdate failed: %v", err)
}
// SynDump from both protocols
result2, err := client2.FTSynDump(ctx, "test-idx").Result()
if err != nil {
t.Fatalf("FTSynDump RESP2 failed: %v", err)
}
result3, err := client3.FTSynDump(ctx, "test-idx").Result()
if err != nil {
t.Fatalf("FTSynDump RESP3 failed: %v", err)
}
t.Logf("RESP2 result: %+v", result2)
t.Logf("RESP3 result: %+v", result3)
// Compare results
if len(result2) != len(result3) {
t.Errorf("Results length mismatch: RESP2=%d, RESP3=%d", len(result2), len(result3))
}
})
}
func waitForIndexing(t *testing.T, client *redis.Client, index string, expectedDocs int) {
t.Helper()
ctx := context.Background()
for i := 0; i < 100; i++ {
info, err := client.FTInfo(ctx, index).Result()
if err != nil {
t.Fatalf("FTInfo failed: %v", err)
}
if info.NumDocs >= expectedDocs {
return
}
}
t.Fatalf("Timeout waiting for indexing")
}

View File

@@ -4401,7 +4401,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
Expect(client.Close()).NotTo(HaveOccurred())
})
It("should handle FTAggregate with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftaggregate"), func() {
It("should handle FTAggregate with RESP3", Label("search", "ftcreate", "ftaggregate"), func() {
text1 := &redis.FieldSchema{FieldName: "PrimaryKey", FieldType: redis.SearchFieldTypeText, Sortable: true}
num1 := &redis.FieldSchema{FieldName: "CreatedDateTimeUTC", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}
val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, text1, num1).Result()
@@ -4411,40 +4411,45 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
client.HSet(ctx, "doc1", "PrimaryKey", "9::362330", "CreatedDateTimeUTC", "637387878524969984")
client.HSet(ctx, "doc2", "PrimaryKey", "9::362329", "CreatedDateTimeUTC", "637387875859270016")
WaitForIndexing(client, "idx1")
options := &redis.FTAggregateOptions{Apply: []redis.FTAggregateApply{{Field: "@CreatedDateTimeUTC * 10", As: "CreatedDateTimeUTC"}}}
res, err := client.FTAggregateWithArgs(ctx, "idx1", "*", options).RawResult()
// Test with UnstableResp3 true - both RawResult and Result should work
cmd := client.FTAggregateWithArgs(ctx, "idx1", "*", options)
res, err := cmd.RawResult()
Expect(err).NotTo(HaveOccurred())
results := res.(map[interface{}]interface{})["results"].([]interface{})
Expect(results[0].(map[interface{}]interface{})["extra_attributes"].(map[interface{}]interface{})["CreatedDateTimeUTC"]).
To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
Expect(results[1].(map[interface{}]interface{})["extra_attributes"].(map[interface{}]interface{})["CreatedDateTimeUTC"]).
To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
rawVal := client.FTAggregateWithArgs(ctx, "idx1", "*", options).RawVal()
rawValResults := rawVal.(map[interface{}]interface{})["results"].([]interface{})
// Test Result() also works with UnstableResp3 true
result, err := client.FTAggregateWithArgs(ctx, "idx1", "*", options).Result()
Expect(err).NotTo(HaveOccurred())
Expect(rawValResults[0]).To(Or(BeEquivalentTo(results[0]), BeEquivalentTo(results[1])))
Expect(rawValResults[1]).To(Or(BeEquivalentTo(results[0]), BeEquivalentTo(results[1])))
Expect(result.Total).To(BeEquivalentTo(2))
Expect(result.Rows).To(HaveLen(2))
Expect(result.Rows[0].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
Expect(result.Rows[1].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
// Test with UnstableResp3 false - should return error instead of panic
// Test with UnstableResp3 false - should also work with stable RESP3 parsing
options = &redis.FTAggregateOptions{Apply: []redis.FTAggregateApply{{Field: "@CreatedDateTimeUTC * 10", As: "CreatedDateTimeUTC"}}}
rawRes, err := client2.FTAggregateWithArgs(ctx, "idx1", "*", options).RawResult()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled"))
Expect(rawRes).To(BeNil())
rawVal = client2.FTAggregateWithArgs(ctx, "idx1", "*", options).RawVal()
Expect(client2.FTAggregateWithArgs(ctx, "idx1", "*", options).Err()).To(HaveOccurred())
Expect(rawVal).To(BeNil())
result2, err := client2.FTAggregateWithArgs(ctx, "idx1", "*", options).Result()
Expect(err).NotTo(HaveOccurred())
Expect(result2.Total).To(BeEquivalentTo(2))
Expect(result2.Rows).To(HaveLen(2))
Expect(result2.Rows[0].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
Expect(result2.Rows[1].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416")))
})
It("should handle FTInfo with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftinfo"), func() {
It("should handle FTInfo with RESP3", Label("search", "ftcreate", "ftinfo"), func() {
val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText, Sortable: true, NoStem: true}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(val).To(BeEquivalentTo("OK"))
WaitForIndexing(client, "idx1")
// Test with UnstableResp3 true - both RawResult and Result should work
resInfo, err := client.FTInfo(ctx, "idx1").RawResult()
Expect(err).NotTo(HaveOccurred())
attributes := resInfo.(map[interface{}]interface{})["attributes"].([]interface{})
@@ -4456,18 +4461,22 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
flags = attributes[0].(map[interface{}]interface{})["flags"].([]interface{})
Expect(flags).To(ConsistOf("SORTABLE", "NOSTEM"))
// Test with UnstableResp3 false - should return error instead of panic
rawResInfo, err := client2.FTInfo(ctx, "idx1").RawResult()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled"))
Expect(rawResInfo).To(BeNil())
// Test Result() also works with UnstableResp3 true
result, err := client.FTInfo(ctx, "idx1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(result.IndexName).To(BeEquivalentTo("idx1"))
Expect(result.Attributes).To(HaveLen(1))
Expect(result.Attributes[0].Attribute).To(BeEquivalentTo("txt"))
rawValInfo := client2.FTInfo(ctx, "idx1").RawVal()
Expect(client2.FTInfo(ctx, "idx1").Err()).To(HaveOccurred())
Expect(rawValInfo).To(BeNil())
// Test with UnstableResp3 false - should also work with stable RESP3 parsing
result2, err := client2.FTInfo(ctx, "idx1").Result()
Expect(err).NotTo(HaveOccurred())
Expect(result2.IndexName).To(BeEquivalentTo("idx1"))
Expect(result2.Attributes).To(HaveLen(1))
Expect(result2.Attributes[0].Attribute).To(BeEquivalentTo("txt"))
})
It("should handle FTSpellCheck with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftspellcheck"), func() {
It("should handle FTSpellCheck with RESP3", Label("search", "ftcreate", "ftspellcheck"), func() {
text1 := &redis.FieldSchema{FieldName: "f1", FieldType: redis.SearchFieldTypeText}
text2 := &redis.FieldSchema{FieldName: "f2", FieldType: redis.SearchFieldTypeText}
val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, text1, text2).Result()
@@ -4478,6 +4487,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
client.HSet(ctx, "doc1", "f1", "some valid content", "f2", "this is sample text")
client.HSet(ctx, "doc2", "f1", "very important", "f2", "lorem ipsum")
// Test with UnstableResp3 true - both RawResult and Result should work
resSpellCheck, err := client.FTSpellCheck(ctx, "idx1", "impornant").RawResult()
valSpellCheck := client.FTSpellCheck(ctx, "idx1", "impornant").RawVal()
Expect(err).NotTo(HaveOccurred())
@@ -4485,24 +4495,34 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
results := resSpellCheck.(map[interface{}]interface{})["results"].(map[interface{}]interface{})
Expect(results["impornant"].([]interface{})[0].(map[interface{}]interface{})["important"]).To(BeEquivalentTo(0.5))
// Test with UnstableResp3 false - should return error instead of panic
rawResSpellCheck, err := client2.FTSpellCheck(ctx, "idx1", "impornant").RawResult()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled"))
Expect(rawResSpellCheck).To(BeNil())
// Test Result() also works with UnstableResp3 true
result, err := client.FTSpellCheck(ctx, "idx1", "impornant").Result()
Expect(err).NotTo(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Term).To(BeEquivalentTo("impornant"))
Expect(result[0].Suggestions).To(HaveLen(1))
Expect(result[0].Suggestions[0].Suggestion).To(BeEquivalentTo("important"))
Expect(result[0].Suggestions[0].Score).To(BeEquivalentTo(0.5))
rawValSpellCheck := client2.FTSpellCheck(ctx, "idx1", "impornant").RawVal()
Expect(client2.FTSpellCheck(ctx, "idx1", "impornant").Err()).To(HaveOccurred())
Expect(rawValSpellCheck).To(BeNil())
// Test with UnstableResp3 false - should also work with stable RESP3 parsing
result2, err := client2.FTSpellCheck(ctx, "idx1", "impornant").Result()
Expect(err).NotTo(HaveOccurred())
Expect(result2).To(HaveLen(1))
Expect(result2[0].Term).To(BeEquivalentTo("impornant"))
Expect(result2[0].Suggestions).To(HaveLen(1))
Expect(result2[0].Suggestions[0].Suggestion).To(BeEquivalentTo("important"))
Expect(result2[0].Suggestions[0].Score).To(BeEquivalentTo(0.5))
})
It("should handle FTSearch with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftsearch"), func() {
It("should handle FTSearch with RESP3", Label("search", "ftcreate", "ftsearch"), func() {
val, err := client.FTCreate(ctx, "txt", &redis.FTCreateOptions{StopWords: []interface{}{"foo", "bar", "baz"}}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(val).To(BeEquivalentTo("OK"))
WaitForIndexing(client, "txt")
client.HSet(ctx, "doc1", "txt", "foo baz")
client.HSet(ctx, "doc2", "txt", "hello world")
// Test with UnstableResp3 true - both RawResult and Result should work
res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{NoContent: true}).RawResult()
val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{NoContent: true}).RawVal()
Expect(err).NotTo(HaveOccurred())
@@ -4514,17 +4534,29 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
totalResults2 := res2.(map[interface{}]interface{})["total_results"]
Expect(totalResults2).To(BeEquivalentTo(int64(1)))
// Test with UnstableResp3 false - should return error instead of panic
rawRes2, err := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).RawResult()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled"))
Expect(rawRes2).To(BeNil())
// Test Result() also works with UnstableResp3 true
result, err := client.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(result.Total).To(BeEquivalentTo(1))
Expect(result.Docs).To(HaveLen(1))
Expect(result.Docs[0].ID).To(BeEquivalentTo("doc2"))
rawVal2 := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).RawVal()
Expect(client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Err()).To(HaveOccurred())
Expect(rawVal2).To(BeNil())
// Test with UnstableResp3 false - should also work with stable RESP3 parsing
result2, err := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(result2.Total).To(BeEquivalentTo(1))
Expect(result2.Docs).To(HaveLen(1))
Expect(result2.Docs[0].ID).To(BeEquivalentTo("doc2"))
// Test with content
result3, err := client2.FTSearchWithArgs(ctx, "txt", "hello world", &redis.FTSearchOptions{}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(result3.Total).To(BeEquivalentTo(1))
Expect(result3.Docs).To(HaveLen(1))
Expect(result3.Docs[0].ID).To(BeEquivalentTo("doc2"))
Expect(result3.Docs[0].Fields["txt"]).To(BeEquivalentTo("hello world"))
})
It("should handle FTSynDump with Unstable RESP3 Search Module and without stability", Label("search", "ftsyndump"), func() {
It("should handle FTSynDump with RESP3", Label("search", "ftsyndump"), func() {
text1 := &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}
text2 := &redis.FieldSchema{FieldName: "body", FieldType: redis.SearchFieldTypeText}
val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{OnHash: true}, text1, text2).Result()
@@ -4544,21 +4576,40 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() {
Expect(err).NotTo(HaveOccurred())
Expect(resSynUpdate).To(BeEquivalentTo("OK"))
// Test with UnstableResp3 true - both RawResult and Result should work
resSynDump, err := client.FTSynDump(ctx, "idx1").RawResult()
valSynDump := client.FTSynDump(ctx, "idx1").RawVal()
Expect(err).NotTo(HaveOccurred())
Expect(valSynDump).To(BeEquivalentTo(resSynDump))
Expect(resSynDump.(map[interface{}]interface{})["baby"]).To(BeEquivalentTo([]interface{}{"id1"}))
// Test with UnstableResp3 false - should return error instead of panic
rawResSynDump, err := client2.FTSynDump(ctx, "idx1").RawResult()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled"))
Expect(rawResSynDump).To(BeNil())
// Test Result() also works with UnstableResp3 true
result, err := client.FTSynDump(ctx, "idx1").Result()
Expect(err).NotTo(HaveOccurred())
// Find the "baby" term in results
var foundBaby bool
for _, r := range result {
if r.Term == "baby" {
foundBaby = true
Expect(r.Synonyms).To(ContainElement("id1"))
break
}
}
Expect(foundBaby).To(BeTrue())
rawValSynDump := client2.FTSynDump(ctx, "idx1").RawVal()
Expect(client2.FTSynDump(ctx, "idx1").Err()).To(HaveOccurred())
Expect(rawValSynDump).To(BeNil())
// Test with UnstableResp3 false - should also work with stable RESP3 parsing
result2, err := client2.FTSynDump(ctx, "idx1").Result()
Expect(err).NotTo(HaveOccurred())
// Find the "baby" term in results
foundBaby = false
for _, r := range result2 {
if r.Term == "baby" {
foundBaby = true
Expect(r.Synonyms).To(ContainElement("id1"))
break
}
}
Expect(foundBaby).To(BeTrue())
})
It("should test not affected Resp 3 Search method - FTExplain", Label("search", "ftexplain"), func() {

View File

@@ -160,6 +160,9 @@ type FailoverOptions struct {
// Only applies to failover cluster clients. Default is 15 seconds.
FailingTimeoutSeconds int
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.

View File

@@ -280,4 +280,3 @@ var _ = Describe("TLS Cluster", Label("NonRedisEnterprise"), func() {
})
})
})

View File

@@ -74,4 +74,3 @@ func TestTLSRedissURL(t *testing.T) {
t.Log("✅ TLS rediss:// URL test passed")
}

View File

@@ -33,9 +33,9 @@ func loadTLSConfig(certDir string) (*tls.Config, error) {
}
return &tls.Config{
RootCAs: caCertPool,
Certificates: []tls.Certificate{cert},
ServerName: "localhost",
RootCAs: caCertPool,
Certificates: []tls.Certificate{cert},
ServerName: "localhost",
InsecureSkipVerify: true,
}, nil
}
@@ -247,11 +247,11 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() {
It("should respect MinVersion setting", func() {
ctx := context.Background()
tlsConfigWithMinVersion := &tls.Config{
RootCAs: tlsConfig.RootCAs,
Certificates: tlsConfig.Certificates,
ServerName: "localhost",
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
RootCAs: tlsConfig.RootCAs,
Certificates: tlsConfig.Certificates,
ServerName: "localhost",
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
client := redis.NewClient(&redis.Options{
@@ -268,10 +268,10 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() {
It("should work with custom cipher suites", func() {
ctx := context.Background()
tlsConfigWithCiphers := &tls.Config{
RootCAs: tlsConfig.RootCAs,
Certificates: tlsConfig.Certificates,
ServerName: "localhost",
InsecureSkipVerify: true,
RootCAs: tlsConfig.RootCAs,
Certificates: tlsConfig.Certificates,
ServerName: "localhost",
InsecureSkipVerify: true,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
@@ -290,4 +290,3 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() {
})
})
})

View File

@@ -137,6 +137,9 @@ type UniversalOptions struct {
// Only applies to cluster clients. Default is 15 seconds.
FailingTimeoutSeconds int
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.