feat(command): Populate FT.HYBRID timeout warnings (#3911)

This commit is contained in:
ofekshenawa authored and GitHub committed 2026-07-20 15:41:03 +03:00
1 parent 507eda5820
commit 5e8e30f5a3
4 files changed
+89 -8

No files matched your search

+3
View File
@@ -168,6 +168,9 @@ func shouldRetry(err error, retryTimeout bool) bool {
return true
}
// Other server errors are not retried. This includes the logical
// -SEARCH_TIMEOUT (search-on-timeout fail): retrying would just repeat the
// same expensive query.
return false
}
+14
View File
@@ -2,6 +2,7 @@ package redis_test
import (
"context"
"fmt"
"io"
"net"
@@ -49,6 +50,8 @@ var _ = Describe("error", func() {
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
proto.ParseErrorReply([]byte("-NOREPLICAS Not enough good replicas to write")): true,
proto.ParseErrorReply([]byte("-ERR other")): false,
// Logical search timeout (search-on-timeout fail): never retried.
proto.ParseErrorReply([]byte("-SEARCH_TIMEOUT Timeout limit was reached")): false,
}
for err, expected := range data {
@@ -66,4 +69,15 @@ var _ = Describe("error", func() {
Expect(redis.ShouldRetry(t2, true)).To(Equal(true))
Expect(redis.ShouldRetry(t2, false)).To(Equal(true))
})
It("should not retry a logical search timeout", func() {
// -SEARCH_TIMEOUT (search-on-timeout fail) is never retried, plain or wrapped.
err := proto.ParseErrorReply([]byte("-SEARCH_TIMEOUT Timeout limit was reached"))
Expect(redis.ShouldRetry(err, false)).To(BeFalse())
Expect(redis.ShouldRetry(err, true)).To(BeFalse())
wrapped := fmt.Errorf("query failed: %w", err)
Expect(redis.ShouldRetry(wrapped, false)).To(BeFalse())
Expect(redis.ShouldRetry(wrapped, true)).To(BeFalse())
})
})
+18 -8
View File
@@ -494,8 +494,10 @@ type FTSynDumpCmd struct {
// FTAggregateResult represents the result of an aggregate operation
// NOTE: For RESP3 Total is not reliable (before Redis 8.8)
type FTAggregateResult struct {
Total int
Rows []AggregateRow
Total int
Rows []AggregateRow
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict). RESP3 only; the fail policy returns an error instead.
Warnings []string
}
@@ -626,8 +628,10 @@ type SpellCheckSuggestion struct {
}
type FTSearchResult struct {
Total int
Docs []Document
Total int
Docs []Document
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict). RESP3 only; the fail policy returns an error instead.
Warnings []string
}
@@ -2897,8 +2901,10 @@ func (cmd *FTSearchCmd) Clone() Cmder {
// FTHybridResult represents the result of a hybrid search operation
type FTHybridResult struct {
TotalResults int
Results []map[string]interface{}
TotalResults int
Results []map[string]interface{}
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict), on RESP2 and RESP3; the fail policy returns an error.
Warnings []string
ExecutionTime float64
}
@@ -3041,9 +3047,13 @@ func parseFTHybrid(data []interface{}, withCursor bool) (FTHybridResult, *FTHybr
results = append(results, itemMap)
}
// Parse warnings (optional field)
// Optional warnings; accept both "warning" (as FT.SEARCH/FT.AGGREGATE) and "warnings".
var warnings []string
if warningsData, ok := resultMap["warnings"].([]interface{}); ok {
warningsData, ok := resultMap["warning"].([]interface{})
if !ok {
warningsData, ok = resultMap["warnings"].([]interface{})
}
if ok {
warnings = make([]string, 0, len(warningsData))
for _, w := range warningsData {
if ws, ok := w.(string); ok {
+54
View File
@@ -461,3 +461,57 @@ func TestFTHybridWithArgs_ShardKRatio(t *testing.T) {
}
})
}
// TestParseFTHybridWarnings checks that FT.HYBRID warnings populate from either
// the "warning" or "warnings" key.
func TestParseFTHybridWarnings(t *testing.T) {
tests := []struct {
name string
data []interface{}
expected []string
}{
{
name: "singular warning key",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
"warning", []interface{}{"Timeout limit was reached"},
},
expected: []string{"Timeout limit was reached"},
},
{
name: "plural warnings key",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
"warnings", []interface{}{"Timeout limit was reached"},
},
expected: []string{"Timeout limit was reached"},
},
{
name: "no warnings",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _, err := parseFTHybrid(tt.data, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Warnings) != len(tt.expected) {
t.Fatalf("len(Warnings) = %d, want %d (%v)", len(result.Warnings), len(tt.expected), result.Warnings)
}
for i, w := range result.Warnings {
if w != tt.expected[i] {
t.Errorf("Warnings[%d] = %q, want %q", i, w, tt.expected[i])
}
}
})
}
}