1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-24 07:59:20 +03:00
Files
go-redis/search_resp_test.go
Nedyalko Dyakov c5cad058c7 test(tests): TCP-probe before Ping in skip gates (#4001)
Tests that skip when Redis is absent gated on a client Ping to a
hardcoded/helper address. A Ping to an addr with nothing listening does
not fail fast: it pays the dial-retry (DialerRetries, default 5) plus
command-retry budget first, ~1.7s per call. A recent RE integration
nightly spent ~1.6 min across 68 such skips.

Probe the address with a short-timeout TCP dial first, which fails in
microseconds on connection-refused, then keep the existing Ping so a
reachable-but-unusable server still skips rather than proceeds.
2026-08-31 14:18:10 +03:00

414 lines
14 KiB
Go

package redis_test
import (
"context"
"testing"
"time"
"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 := probeRedis("localhost:6379"); err != nil {
t.Skipf("Redis not available: %v", err)
}
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
// Hermetic keyspace: earlier subtests drop only the index, and their
// leftover hashes would be re-indexed here (a third doc with its own
// score made the aggregate row sets protocol-timing-dependent).
if err := client2.FlushDB(ctx).Err(); err != nil {
t.Fatalf("FlushDB failed: %v", err)
}
_, 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
// Hermetic keyspace: earlier subtests drop only the index, and their
// leftover hashes would be re-indexed here (a third doc with its own
// score made the aggregate row sets protocol-timing-dependent).
if err := client2.FlushDB(ctx).Err(); err != nil {
t.Fatalf("FlushDB failed: %v", err)
}
_, 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
// Hermetic keyspace: earlier subtests drop only the index, and their
// leftover hashes would be re-indexed here (a third doc with its own
// score made the aggregate row sets protocol-timing-dependent).
if err := client2.FlushDB(ctx).Err(); err != nil {
t.Fatalf("FlushDB failed: %v", err)
}
_, 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
// Hermetic keyspace: earlier subtests drop only the index, and their
// leftover hashes would be re-indexed here (a third doc with its own
// score made the aggregate row sets protocol-timing-dependent).
if err := client2.FlushDB(ctx).Err(); err != nil {
t.Fatalf("FlushDB failed: %v", err)
}
_, 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
// Hermetic keyspace: earlier subtests drop only the index, and their
// leftover hashes would be re-indexed here (a third doc with its own
// score made the aggregate row sets protocol-timing-dependent).
if err := client2.FlushDB(ctx).Err(); err != nil {
t.Fatalf("FlushDB failed: %v", err)
}
_, 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)
}
// Exact count AND ingestion settled: the subtests query RESP2 and
// RESP3 back to back and compare, so a doc landing (or a reindex
// finishing) between the two queries skews one side — seen on a slow
// CI runner as an extra transient empty row in the RESP3 aggregate.
// The loop also SLEPT nowhere before, burning its 100 tries in
// microseconds while the indexer had not even started.
if info.NumDocs == expectedDocs && info.Indexing == 0 {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("Timeout waiting for indexing")
}