1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/search_collect_integration_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

260 lines
8.9 KiB
Go

package redis_test
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"testing"
"github.com/redis/go-redis/v9"
)
// collectTestAddr is the standalone Redis used by the COLLECT integration
// test. Defaults to :6379 (the repo's standard test server) and can be
// overridden, e.g. when a Redis 8.8 + search-enable-unstable-features instance
// runs on a different port:
//
// REDIS_COLLECT_TEST_ADDR=localhost:6399 go test -run TestFTAggregateCollect_Integration
func collectTestAddr() string {
if a := os.Getenv("REDIS_COLLECT_TEST_ADDR"); a != "" {
return a
}
return ":6379"
}
// TestFTAggregateCollect_Integration exercises the COLLECT reducer end-to-end
// against a live server, through both the options-struct (NewCollectReducer)
// and fluent-builder (AggregateBuilder.Collect) surfaces, and decodes the
// reply with AggregateRow.Collect under both RESP2 and RESP3.
//
// COLLECT requires Redis 8.8+ with search-enable-unstable-features enabled.
// The test skips if the server is unreachable, lacks the search module, or
// does not allow enabling unstable features.
func TestFTAggregateCollect_Integration(t *testing.T) {
// COLLECT reducer ships in Redis 8.8. Older search builds accept the
// search-enable-unstable-features config but still reject the reducer
// ("No such reducer: COLLECT"), so gate on the live server's version
// (INFO server redis_version) rather than on the config toggle or the
// REDIS_VERSION env variable (whose float form cannot tell 8.10 from 8.1
// and defaults when unset).
probeCtx := context.Background()
if err := probeRedis(collectTestAddr()); err != nil {
t.Skipf("redis not reachable at %s: %v", collectTestAddr(), err)
}
probe := redis.NewClient(&redis.Options{Addr: collectTestAddr()})
t.Cleanup(func() { _ = probe.Close() })
if err := probe.Ping(probeCtx).Err(); err != nil {
t.Skipf("redis not reachable at %s: %v", collectTestAddr(), err)
}
major, minor, err := collectServerVersion(probeCtx, probe)
if err != nil {
t.Fatalf("cannot determine server version: %v", err)
}
if major < 8 || (major == 8 && minor < 8) {
t.Skipf("COLLECT requires Redis 8.8+ (server is %d.%d)", major, minor)
}
for _, proto := range []int{2, 3} {
proto := proto
t.Run(fmt.Sprintf("RESP%d", proto), func(t *testing.T) {
ctx := context.Background()
opt := &redis.Options{Addr: collectTestAddr(), Protocol: proto}
if proto == 3 {
opt.UnstableResp3 = true
}
client := redis.NewClient(opt)
t.Cleanup(func() { _ = client.Close() })
if err := client.Ping(ctx).Err(); err != nil {
t.Skipf("redis not reachable at %s: %v", collectTestAddr(), err)
}
// COLLECT is gated behind unstable features; skip if we cannot
// enable it. Remember the prior value and restore it on cleanup so
// the test does not leak config changes into the shared server.
const unstableCfg = "search-enable-unstable-features"
prevCfg, err := client.ConfigGet(ctx, unstableCfg).Result()
if err != nil {
t.Skipf("cannot read %s (need Redis 8.8+ with search): %v", unstableCfg, err)
}
if err := client.ConfigSet(ctx, unstableCfg, "yes").Err(); err != nil {
t.Skipf("cannot enable search unstable features (need Redis 8.8+ with search): %v", err)
}
t.Cleanup(func() {
if prev, ok := prevCfg[unstableCfg]; ok {
_ = client.ConfigSet(ctx, unstableCfg, prev).Err()
}
})
// Clear only this test's keys (never FlushDB: the server is shared).
docKeys := []string{"fruit:1", "fruit:2", "fruit:3", "fruit:4"}
if err := client.Del(ctx, docKeys...).Err(); err != nil {
t.Fatalf("Del %v: %v", docKeys, err)
}
const idx = "idx_collect_it"
if err := client.Do(ctx,
"FT.CREATE", idx, "ON", "HASH", "PREFIX", 1, "fruit:",
"SCHEMA", "color", "TAG", "name", "TEXT", "sweetness", "NUMERIC", "SORTABLE",
).Err(); err != nil {
if strings.Contains(strings.ToUpper(err.Error()), "UNKNOWN COMMAND") {
t.Skipf("search module not loaded: %v", err)
}
t.Fatalf("FT.CREATE: %v", err)
}
// Drop the index and its documents (DD) on cleanup.
t.Cleanup(func() {
_ = client.FTDropIndexWithArgs(ctx, idx,
&redis.FTDropIndexOptions{DeleteDocs: true}).Err()
})
docs := []struct {
key, color, name string
sweetness interface{}
}{
{"fruit:1", "red", "apple", 4},
{"fruit:2", "red", "strawberry", 3},
{"fruit:3", "yellow", "banana", 4},
{"fruit:4", "yellow", "lemon", nil}, // sparse: no sweetness
}
for _, d := range docs {
vals := []interface{}{"color", d.color, "name", d.name}
if d.sweetness != nil {
vals = append(vals, "sweetness", d.sweetness)
}
if err := client.HSet(ctx, d.key, vals...).Err(); err != nil {
t.Fatalf("HSet %s: %v", d.key, err)
}
}
// --- Surface 1: options-struct via NewCollectReducer ---
reducer, err := redis.NewCollectReducer(redis.FTAggregateCollect{
Fields: []string{"name", "sweetness"}, // no @ prefix: must be normalized
SortBy: []redis.FTAggregateSortBy{{FieldName: "sweetness", Desc: true}},
As: "fruits",
})
if err != nil {
t.Fatalf("NewCollectReducer: %v", err)
}
res, err := client.FTAggregateWithArgs(ctx, idx, "*", &redis.FTAggregateOptions{
GroupBy: []redis.FTAggregateGroupBy{{
Fields: []interface{}{"@color"},
Reduce: []redis.FTAggregateReducer{reducer},
}},
}).Result()
if err != nil {
t.Fatalf("FTAggregateWithArgs COLLECT: %v", err)
}
assertCollectGroups(t, res)
// --- Surface 2: fluent builder ---
res2, err := client.NewAggregateBuilder(ctx, idx, "*").
GroupBy("@color").
Collect(redis.FTAggregateCollect{
FieldsAll: true,
SortBy: []redis.FTAggregateSortBy{{FieldName: "sweetness", Desc: true}},
As: "fruits",
}).
Run()
if err != nil {
t.Fatalf("builder COLLECT: %v", err)
}
// FIELDS * projects pipeline fields (color group key + name + sweetness).
if len(res2.Rows) != 2 {
t.Fatalf("builder: got %d groups, want 2", len(res2.Rows))
}
for _, row := range res2.Rows {
col, err := row.Collect("fruits")
if err != nil {
t.Fatalf("builder row.Collect: %v", err)
}
if len(col) == 0 {
t.Fatalf("builder: empty collect column for row %v", row.Fields)
}
}
})
}
}
// assertCollectGroups verifies the two color groups, ordering within a group,
// and the sparse entry (lemon has no sweetness).
func assertCollectGroups(t *testing.T, res *redis.FTAggregateResult) {
t.Helper()
if len(res.Rows) != 2 {
t.Fatalf("got %d groups, want 2 (red, yellow)", len(res.Rows))
}
byColor := map[string]redis.CollectColumn{}
for _, row := range res.Rows {
color, _ := row.Fields["color"].(string)
col, err := row.Collect("fruits")
if err != nil {
t.Fatalf("row.Collect(fruits) for color=%q: %v", color, err)
}
byColor[color] = col
}
red := byColor["red"]
if len(red) != 2 {
t.Fatalf("red group: got %d entries, want 2", len(red))
}
// SORTBY sweetness DESC: apple(4) before strawberry(3).
if got := red[0]["name"]; got != "apple" {
t.Fatalf("red[0].name = %v, want apple (DESC sweetness order)", got)
}
if got := red[1]["name"]; got != "strawberry" {
t.Fatalf("red[1].name = %v, want strawberry", got)
}
yellow := byColor["yellow"]
if len(yellow) != 2 {
t.Fatalf("yellow group: got %d entries, want 2", len(yellow))
}
// Sparse: the lemon entry must omit sweetness (no NULL placeholder).
names := []string{yellow[0]["name"].(string), yellow[1]["name"].(string)}
sort.Strings(names)
if names[0] != "banana" || names[1] != "lemon" {
t.Fatalf("yellow names = %v, want [banana lemon]", names)
}
for _, e := range yellow {
if e["name"] == "lemon" {
if _, ok := e["sweetness"]; ok {
t.Fatalf("lemon entry must be sparse (no sweetness), got %v", e)
}
}
}
}
// collectServerVersion returns the major and minor components of the live
// server's redis_version, taken from INFO server. Unlike the float-typed
// RedisVersion (parsed from the REDIS_VERSION env variable), integer
// major/minor comparison keeps 8.10 > 8.8 and reflects the server actually
// answering, not the environment.
func collectServerVersion(ctx context.Context, client *redis.Client) (major, minor int, err error) {
info, err := client.Info(ctx, "server").Result()
if err != nil {
return 0, 0, err
}
for _, line := range strings.Split(info, "\n") {
v, ok := strings.CutPrefix(strings.TrimSpace(line), "redis_version:")
if !ok {
continue
}
parts := strings.SplitN(v, ".", 3)
if len(parts) < 2 {
return 0, 0, fmt.Errorf("unexpected redis_version %q in INFO server", v)
}
major, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("unexpected redis_version %q in INFO server: %w", v, err)
}
minor, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("unexpected redis_version %q in INFO server: %w", v, err)
}
return major, minor, nil
}
return 0, 0, fmt.Errorf("redis_version not found in INFO server reply")
}