From b026cf813b961a5e87a463b9332959b471a8c8c7 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Mon, 24 Mar 2025 21:53:07 +0200 Subject: [PATCH 01/27] Bump version to 9.8.0-beta1 Update README.md --- README.md | 21 +++++++++++++++++++-- example/del-keys-without-ttl/go.mod | 2 +- example/hll/go.mod | 2 +- example/lua-scripting/go.mod | 2 +- example/otel/go.mod | 6 +++--- example/redis-bloom/go.mod | 2 +- example/scan-struct/go.mod | 2 +- extra/rediscensus/go.mod | 4 ++-- extra/rediscmd/go.mod | 2 +- extra/redisotel/go.mod | 4 ++-- extra/redisprometheus/go.mod | 2 +- version.go | 2 +- 12 files changed, 34 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 335d32dad7..e1d1c6adce 100644 --- a/README.md +++ b/README.md @@ -215,9 +215,26 @@ val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}) In the Redis-Search module, **the default dialect is 2**. If needed, you can explicitly specify a different dialect using the appropriate configuration in your queries. -## Contributing +**Important**: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by passing the desired dialect in the arguments of the command you want to execute. +For example: +``` + res2, err := rdb.FTSearchWithArgs(ctx, + "idx:bicycle", + "@pickup_zone:[CONTAINS $bike]", + &redis.FTSearchOptions{ + Params: map[string]interface{}{ + "bike": "POINT(-0.1278 51.5074)", + }, + DialectVersion: 3, + }, + ).Result() +``` +You can find further details in the [query dialect documentation](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/). -Please see [out contributing guidelines](CONTRIBUTING.md) to help us improve this library! +## Contributing +We welcome contributions to the go-redis library! If you have a bug fix, feature request, or improvement, please open an issue or pull request on GitHub. +We appreciate your help in making go-redis better for everyone. +If you are interested in contributing to the go-redis library, please check out our [contributing guidelines](CONTRIBUTING.md) for more information on how to get started. ## Look and feel diff --git a/example/del-keys-without-ttl/go.mod b/example/del-keys-without-ttl/go.mod index 0699671c59..727fbbd7fd 100644 --- a/example/del-keys-without-ttl/go.mod +++ b/example/del-keys-without-ttl/go.mod @@ -5,7 +5,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. require ( - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/v9 v9.8.0-beta.1 go.uber.org/zap v1.24.0 ) diff --git a/example/hll/go.mod b/example/hll/go.mod index 72ab52aaf2..775e3e7b19 100644 --- a/example/hll/go.mod +++ b/example/hll/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.7.3 +require github.com/redis/go-redis/v9 v9.8.0-beta.1 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/lua-scripting/go.mod b/example/lua-scripting/go.mod index e2bc161a4f..363c93c294 100644 --- a/example/lua-scripting/go.mod +++ b/example/lua-scripting/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.7.3 +require github.com/redis/go-redis/v9 v9.8.0-beta.1 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/otel/go.mod b/example/otel/go.mod index 299e4b02c2..5a060d99a7 100644 --- a/example/otel/go.mod +++ b/example/otel/go.mod @@ -11,8 +11,8 @@ replace github.com/redis/go-redis/extra/redisotel/v9 => ../../extra/redisotel replace github.com/redis/go-redis/extra/rediscmd/v9 => ../../extra/rediscmd require ( - github.com/redis/go-redis/extra/redisotel/v9 v9.7.3 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/extra/redisotel/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0-beta.1 github.com/uptrace/uptrace-go v1.21.0 go.opentelemetry.io/otel v1.22.0 ) @@ -25,7 +25,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.7.3 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.46.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect diff --git a/example/redis-bloom/go.mod b/example/redis-bloom/go.mod index 044945654c..3d7a4caaa8 100644 --- a/example/redis-bloom/go.mod +++ b/example/redis-bloom/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.7.3 +require github.com/redis/go-redis/v9 v9.8.0-beta.1 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/scan-struct/go.mod b/example/scan-struct/go.mod index f44536075a..33d3ef6d80 100644 --- a/example/scan-struct/go.mod +++ b/example/scan-struct/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/davecgh/go-spew v1.1.1 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/v9 v9.8.0-beta.1 ) require ( diff --git a/extra/rediscensus/go.mod b/extra/rediscensus/go.mod index 11230414ba..7033e805f6 100644 --- a/extra/rediscensus/go.mod +++ b/extra/rediscensus/go.mod @@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../.. replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd require ( - github.com/redis/go-redis/extra/rediscmd/v9 v9.7.3 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0-beta.1 go.opencensus.io v0.24.0 ) diff --git a/extra/rediscmd/go.mod b/extra/rediscmd/go.mod index d64ad57011..c1cff3e90d 100644 --- a/extra/rediscmd/go.mod +++ b/extra/rediscmd/go.mod @@ -7,7 +7,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/bsm/ginkgo/v2 v2.12.0 github.com/bsm/gomega v1.27.10 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/v9 v9.8.0-beta.1 ) require ( diff --git a/extra/redisotel/go.mod b/extra/redisotel/go.mod index 13899eb579..e5b442e614 100644 --- a/extra/redisotel/go.mod +++ b/extra/redisotel/go.mod @@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../.. replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd require ( - github.com/redis/go-redis/extra/rediscmd/v9 v9.7.3 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0-beta.1 go.opentelemetry.io/otel v1.22.0 go.opentelemetry.io/otel/metric v1.22.0 go.opentelemetry.io/otel/sdk v1.22.0 diff --git a/extra/redisprometheus/go.mod b/extra/redisprometheus/go.mod index fa3c43ae05..8bff000869 100644 --- a/extra/redisprometheus/go.mod +++ b/extra/redisprometheus/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/prometheus/client_golang v1.14.0 - github.com/redis/go-redis/v9 v9.7.3 + github.com/redis/go-redis/v9 v9.8.0-beta.1 ) require ( diff --git a/version.go b/version.go index a4832fc1e0..b547951687 100644 --- a/version.go +++ b/version.go @@ -2,5 +2,5 @@ package redis // Version is the current release version. func Version() string { - return "9.7.3" + return "9.8.0-beta.1" } From 53b3fd3e7666200ca1bb8e55442ad7dc398f4c78 Mon Sep 17 00:00:00 2001 From: Nikolay Dubina Date: Tue, 25 Mar 2025 15:28:25 +0800 Subject: [PATCH 02/27] Feature more prominently how to enable OpenTelemetry instrumentation (#3316) --- .github/wordlist.txt | 1 + README.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.github/wordlist.txt b/.github/wordlist.txt index 1fc34f733c..578616b9d0 100644 --- a/.github/wordlist.txt +++ b/.github/wordlist.txt @@ -29,6 +29,7 @@ Lua MSSQL namespace NoSQL +OpenTelemetry ORM Packagist PhpRedis diff --git a/README.md b/README.md index e1d1c6adce..6c42c0fc2c 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,24 @@ func ExampleClient() *redis.Client { ``` +### Instrument with OpenTelemetry + +```go +import ( + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/extra/redisotel/v9" + "errors" +) + +func main() { + ... + rdb := redis.NewClient(&redis.Options{...}) + + if err := errors.Join(redisotel.InstrumentTracing(rdb), redisotel.InstrumentMetrics(rdb)); err != nil { + log.Fatal(err) + } +``` + ### Advanced Configuration From 3bb8c965ab7d35bc729485aa8b2c2e1da57e76c7 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Tue, 25 Mar 2025 12:25:35 +0200 Subject: [PATCH 03/27] Sync master with v9.8.0-beta.1 (#3322) --- example/hset-struct/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/hset-struct/go.mod b/example/hset-struct/go.mod index f14f54df1f..33d3ef6d80 100644 --- a/example/hset-struct/go.mod +++ b/example/hset-struct/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/davecgh/go-spew v1.1.1 - github.com/redis/go-redis/v9 v9.6.2 + github.com/redis/go-redis/v9 v9.8.0-beta.1 ) require ( From 5af888954fe84b32c26c76ddbf955bcd76bd6f9f Mon Sep 17 00:00:00 2001 From: andy-stark-redis <164213578+andy-stark-redis@users.noreply.github.com> Date: Fri, 28 Mar 2025 21:05:36 +0000 Subject: [PATCH 04/27] DOC-4464 examples for llen, lpop, lpush, lrange, rpop, and rpush (#3234) * DOC-4464 examples for llen, lpop, lpush, lrange, rpop, and rpush * DOC-4464 improved variable names --------- Co-authored-by: Vladyslav Vildanov <117659936+vladvildanov@users.noreply.github.com> Co-authored-by: Nedyalko Dyakov --- doctests/cmds_list_test.go | 323 +++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 doctests/cmds_list_test.go diff --git a/doctests/cmds_list_test.go b/doctests/cmds_list_test.go new file mode 100644 index 0000000000..ee4a40a0c7 --- /dev/null +++ b/doctests/cmds_list_test.go @@ -0,0 +1,323 @@ +// EXAMPLE: cmds_list +// HIDE_START +package example_commands_test + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// HIDE_END + +func ExampleClient_cmd_llen() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START llen + lPushResult1, err := rdb.LPush(ctx, "mylist", "World").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPushResult1) // >>> 1 + + lPushResult2, err := rdb.LPush(ctx, "mylist", "Hello").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPushResult2) // >>> 2 + + lLenResult, err := rdb.LLen(ctx, "mylist").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lLenResult) // >>> 2 + // STEP_END + + // Output: + // 1 + // 2 + // 2 +} +func ExampleClient_cmd_lpop() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START lpop + RPushResult, err := rdb.RPush(ctx, + "mylist", "one", "two", "three", "four", "five", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(RPushResult) // >>> 5 + + lPopResult, err := rdb.LPop(ctx, "mylist").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPopResult) // >>> one + + lPopCountResult, err := rdb.LPopCount(ctx, "mylist", 2).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPopCountResult) // >>> [two three] + + lRangeResult, err := rdb.LRange(ctx, "mylist", 0, -1).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult) // >>> [four five] + // STEP_END + + // Output: + // 5 + // one + // [two three] + // [four five] +} + +func ExampleClient_cmd_lpush() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START lpush + lPushResult1, err := rdb.LPush(ctx, "mylist", "World").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPushResult1) // >>> 1 + + lPushResult2, err := rdb.LPush(ctx, "mylist", "Hello").Result() + + if err != nil { + panic(err) + } + + fmt.Println(lPushResult2) // >>> 2 + + lRangeResult, err := rdb.LRange(ctx, "mylist", 0, -1).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult) // >>> [Hello World] + // STEP_END + + // Output: + // 1 + // 2 + // [Hello World] +} + +func ExampleClient_cmd_lrange() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START lrange + RPushResult, err := rdb.RPush(ctx, "mylist", + "one", "two", "three", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(RPushResult) // >>> 3 + + lRangeResult1, err := rdb.LRange(ctx, "mylist", 0, 0).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult1) // >>> [one] + + lRangeResult2, err := rdb.LRange(ctx, "mylist", -3, 2).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult2) // >>> [one two three] + + lRangeResult3, err := rdb.LRange(ctx, "mylist", -100, 100).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult3) // >>> [one two three] + + lRangeResult4, err := rdb.LRange(ctx, "mylist", 5, 10).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult4) // >>> [] + // STEP_END + + // Output: + // 3 + // [one] + // [one two three] + // [one two three] + // [] +} + +func ExampleClient_cmd_rpop() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START rpop + rPushResult, err := rdb.RPush(ctx, "mylist", + "one", "two", "three", "four", "five", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(rPushResult) // >>> 5 + + rPopResult, err := rdb.RPop(ctx, "mylist").Result() + + if err != nil { + panic(err) + } + + fmt.Println(rPopResult) // >>> five + + rPopCountResult, err := rdb.RPopCount(ctx, "mylist", 2).Result() + + if err != nil { + panic(err) + } + + fmt.Println(rPopCountResult) // >>> [four three] + + lRangeResult, err := rdb.LRange(ctx, "mylist", 0, -1).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult) // >>> [one two] + // STEP_END + + // Output: + // 5 + // five + // [four three] + // [one two] +} + +func ExampleClient_cmd_rpush() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.Del(ctx, "mylist") + // REMOVE_END + + // STEP_START rpush + rPushResult1, err := rdb.RPush(ctx, "mylist", "Hello").Result() + + if err != nil { + panic(err) + } + + fmt.Println(rPushResult1) // >>> 1 + + rPushResult2, err := rdb.RPush(ctx, "mylist", "World").Result() + + if err != nil { + panic(err) + } + + fmt.Println(rPushResult2) // >>> 2 + + lRangeResult, err := rdb.LRange(ctx, "mylist", 0, -1).Result() + + if err != nil { + panic(err) + } + + fmt.Println(lRangeResult) // >>> [Hello World] + // STEP_END + + // Output: + // 1 + // 2 + // [Hello World] +} From e8b59be1c6b7e64a7d088f71781c1aa24ecb4f00 Mon Sep 17 00:00:00 2001 From: Liu Shuang Date: Thu, 3 Apr 2025 21:10:31 +0800 Subject: [PATCH 05/27] update pubsub.go (#3329) --- pubsub.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubsub.go b/pubsub.go index 72b18f49a7..20c085f1f8 100644 --- a/pubsub.go +++ b/pubsub.go @@ -432,7 +432,7 @@ func (c *PubSub) ReceiveTimeout(ctx context.Context, timeout time.Duration) (int return nil, err } - err = cn.WithReader(context.Background(), timeout, func(rd *proto.Reader) error { + err = cn.WithReader(ctx, timeout, func(rd *proto.Reader) error { return c.cmd.readReply(rd) }) From df49235b529d56303036482905beede7f5f25152 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Thu, 3 Apr 2025 16:10:51 +0300 Subject: [PATCH 06/27] use 8.0-RC1 (#3330) --- .github/actions/run-tests/action.yml | 2 +- .github/workflows/build.yml | 6 +++--- search_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index def48baf8f..2edb16d398 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -25,7 +25,7 @@ runs: # Mapping of redis version to redis testing containers declare -A redis_version_mapping=( - ["8.0-M05"]="8.0-M05-pre" + ["8.0-RC1"]="8.0-RC1-pre" ["7.4.2"]="rs-7.4.0-v2" ["7.2.7"]="rs-7.2.0-v14" ) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48bbdb7510..f88ca67224 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: redis-version: - - "8.0-M05" # 8.0 milestone 5 + - "8.0-RC1" # 8.0 RC1 - "7.4.2" # should use redis stack 7.4 go-version: - "1.23.x" @@ -43,7 +43,7 @@ jobs: # Mapping of redis version to redis testing containers declare -A redis_version_mapping=( - ["8.0-M05"]="8.0-M05-pre" + ["8.0-RC1"]="8.0-RC1-pre" ["7.4.2"]="rs-7.4.0-v2" ) if [[ -v redis_version_mapping[$REDIS_VERSION] ]]; then @@ -72,7 +72,7 @@ jobs: fail-fast: false matrix: redis-version: - - "8.0-M05" # 8.0 milestone 5 + - "8.0-RC1" # 8.0 RC1 - "7.4.2" # should use redis stack 7.4 - "7.2.7" # should redis stack 7.2 go-version: diff --git a/search_test.go b/search_test.go index 296f5bd8b4..4359b02fdf 100644 --- a/search_test.go +++ b/search_test.go @@ -381,7 +381,7 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() { // up until redis 8 the default scorer was TFIDF, in redis 8 it is BM25 // this test expect redis major version >= 8 It("should FTSearch WithScores", Label("search", "ftsearch"), func() { - SkipBeforeRedisVersion(7.9, "default scorer is not BM25") + SkipBeforeRedisVersion(7.9, "default scorer is not BM25STD") text1 := &redis.FieldSchema{FieldName: "description", FieldType: redis.SearchFieldTypeText} val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, text1).Result() From 8d270e4261e1ffac068cc197a3c6893a4876e0cc Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Thu, 3 Apr 2025 17:01:34 +0300 Subject: [PATCH 07/27] drop ft.profile that was never enabled (#3323) --- search_commands.go | 213 --------------------------------------------- search_test.go | 90 ------------------- 2 files changed, 303 deletions(-) diff --git a/search_commands.go b/search_commands.go index 8be39d2a19..85e1256146 100644 --- a/search_commands.go +++ b/search_commands.go @@ -2090,216 +2090,3 @@ func (c cmdable) FTTagVals(ctx context.Context, index string, field string) *Str _ = c(ctx, cmd) return cmd } - -// TODO: remove FTProfile -// type FTProfileResult struct { -// Results []interface{} -// Profile ProfileDetails -// } - -// type ProfileDetails struct { -// TotalProfileTime string -// ParsingTime string -// PipelineCreationTime string -// Warning string -// IteratorsProfile []IteratorProfile -// ResultProcessorsProfile []ResultProcessorProfile -// } - -// type IteratorProfile struct { -// Type string -// QueryType string -// Time interface{} -// Counter int -// Term string -// Size int -// ChildIterators []IteratorProfile -// } - -// type ResultProcessorProfile struct { -// Type string -// Time interface{} -// Counter int -// } - -// func parseFTProfileResult(data []interface{}) (FTProfileResult, error) { -// var result FTProfileResult -// if len(data) < 2 { -// return result, fmt.Errorf("unexpected data length") -// } - -// // Parse results -// result.Results = data[0].([]interface{}) - -// // Parse profile details -// profileData := data[1].([]interface{}) -// profileDetails := ProfileDetails{} -// for i := 0; i < len(profileData); i += 2 { -// switch profileData[i].(string) { -// case "Total profile time": -// profileDetails.TotalProfileTime = profileData[i+1].(string) -// case "Parsing time": -// profileDetails.ParsingTime = profileData[i+1].(string) -// case "Pipeline creation time": -// profileDetails.PipelineCreationTime = profileData[i+1].(string) -// case "Warning": -// profileDetails.Warning = profileData[i+1].(string) -// case "Iterators profile": -// profileDetails.IteratorsProfile = parseIteratorsProfile(profileData[i+1].([]interface{})) -// case "Result processors profile": -// profileDetails.ResultProcessorsProfile = parseResultProcessorsProfile(profileData[i+1].([]interface{})) -// } -// } - -// result.Profile = profileDetails -// return result, nil -// } - -// func parseIteratorsProfile(data []interface{}) []IteratorProfile { -// var iterators []IteratorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// iterator := IteratorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// iterator.Type = profile[i+1].(string) -// case "Query type": -// iterator.QueryType = profile[i+1].(string) -// case "Time": -// iterator.Time = profile[i+1] -// case "Counter": -// iterator.Counter = int(profile[i+1].(int64)) -// case "Term": -// iterator.Term = profile[i+1].(string) -// case "Size": -// iterator.Size = int(profile[i+1].(int64)) -// case "Child iterators": -// iterator.ChildIterators = parseChildIteratorsProfile(profile[i+1].([]interface{})) -// } -// } -// iterators = append(iterators, iterator) -// } -// return iterators -// } - -// func parseChildIteratorsProfile(data []interface{}) []IteratorProfile { -// var iterators []IteratorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// iterator := IteratorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// iterator.Type = profile[i+1].(string) -// case "Query type": -// iterator.QueryType = profile[i+1].(string) -// case "Time": -// iterator.Time = profile[i+1] -// case "Counter": -// iterator.Counter = int(profile[i+1].(int64)) -// case "Term": -// iterator.Term = profile[i+1].(string) -// case "Size": -// iterator.Size = int(profile[i+1].(int64)) -// } -// } -// iterators = append(iterators, iterator) -// } -// return iterators -// } - -// func parseResultProcessorsProfile(data []interface{}) []ResultProcessorProfile { -// var processors []ResultProcessorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// processor := ResultProcessorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// processor.Type = profile[i+1].(string) -// case "Time": -// processor.Time = profile[i+1] -// case "Counter": -// processor.Counter = int(profile[i+1].(int64)) -// } -// } -// processors = append(processors, processor) -// } -// return processors -// } - -// func NewFTProfileCmd(ctx context.Context, args ...interface{}) *FTProfileCmd { -// return &FTProfileCmd{ -// baseCmd: baseCmd{ -// ctx: ctx, -// args: args, -// }, -// } -// } - -// type FTProfileCmd struct { -// baseCmd -// val FTProfileResult -// } - -// func (cmd *FTProfileCmd) String() string { -// return cmdString(cmd, cmd.val) -// } - -// func (cmd *FTProfileCmd) SetVal(val FTProfileResult) { -// cmd.val = val -// } - -// func (cmd *FTProfileCmd) Result() (FTProfileResult, error) { -// return cmd.val, cmd.err -// } - -// func (cmd *FTProfileCmd) Val() FTProfileResult { -// return cmd.val -// } - -// func (cmd *FTProfileCmd) readReply(rd *proto.Reader) (err error) { -// data, err := rd.ReadSlice() -// if err != nil { -// return err -// } -// cmd.val, err = parseFTProfileResult(data) -// if err != nil { -// cmd.err = err -// } -// return nil -// } - -// // FTProfile - Executes a search query and returns a profile of how the query was processed. -// // The 'index' parameter specifies the index to search, the 'limited' parameter specifies whether to limit the results, -// // and the 'query' parameter specifies the search / aggreagte query. Please notice that you must either pass a SearchQuery or an AggregateQuery. -// // For more information, please refer to the Redis documentation: -// // [FT.PROFILE]: (https://redis.io/commands/ft.profile/) -// func (c cmdable) FTProfile(ctx context.Context, index string, limited bool, query interface{}) *FTProfileCmd { -// queryType := "" -// var argsQuery []interface{} - -// switch v := query.(type) { -// case AggregateQuery: -// queryType = "AGGREGATE" -// argsQuery = v -// case SearchQuery: -// queryType = "SEARCH" -// argsQuery = v -// default: -// panic("FT.PROFILE: query must be either AggregateQuery or SearchQuery") -// } - -// args := []interface{}{"FT.PROFILE", index, queryType} - -// if limited { -// args = append(args, "LIMITED") -// } -// args = append(args, "QUERY") -// args = append(args, argsQuery...) - -// cmd := NewFTProfileCmd(ctx, args...) -// _ = c(ctx, cmd) -// return cmd -// } diff --git a/search_test.go b/search_test.go index 4359b02fdf..4d8417d783 100644 --- a/search_test.go +++ b/search_test.go @@ -1694,96 +1694,6 @@ func _assert_geosearch_result(result *redis.FTSearchResult, expectedDocIDs []str Expect(result.Total).To(BeEquivalentTo(len(expectedDocIDs))) } -// It("should FTProfile Search and Aggregate", Label("search", "ftprofile"), func() { -// val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "t", FieldType: redis.SearchFieldTypeText}).Result() -// Expect(err).NotTo(HaveOccurred()) -// Expect(val).To(BeEquivalentTo("OK")) -// WaitForIndexing(client, "idx1") - -// client.HSet(ctx, "1", "t", "hello") -// client.HSet(ctx, "2", "t", "world") - -// // FTProfile Search -// query := redis.FTSearchQuery("hello|world", &redis.FTSearchOptions{NoContent: true}) -// res1, err := client.FTProfile(ctx, "idx1", false, query).Result() -// Expect(err).NotTo(HaveOccurred()) -// panic(res1) -// Expect(len(res1["results"].([]interface{}))).To(BeEquivalentTo(3)) -// resProfile := res1["profile"].(map[interface{}]interface{}) -// Expect(resProfile["Parsing time"].(float64) < 0.5).To(BeTrue()) -// iterProfile0 := resProfile["Iterators profile"].([]interface{})[0].(map[interface{}]interface{}) -// Expect(iterProfile0["Counter"]).To(BeEquivalentTo(2.0)) -// Expect(iterProfile0["Type"]).To(BeEquivalentTo("UNION")) - -// // FTProfile Aggregate -// aggQuery := redis.FTAggregateQuery("*", &redis.FTAggregateOptions{ -// Load: []redis.FTAggregateLoad{{Field: "t"}}, -// Apply: []redis.FTAggregateApply{{Field: "startswith(@t, 'hel')", As: "prefix"}}}) -// res2, err := client.FTProfile(ctx, "idx1", false, aggQuery).Result() -// Expect(err).NotTo(HaveOccurred()) -// Expect(len(res2["results"].([]interface{}))).To(BeEquivalentTo(2)) -// resProfile = res2["profile"].(map[interface{}]interface{}) -// iterProfile0 = resProfile["Iterators profile"].([]interface{})[0].(map[interface{}]interface{}) -// Expect(iterProfile0["Counter"]).To(BeEquivalentTo(2)) -// Expect(iterProfile0["Type"]).To(BeEquivalentTo("WILDCARD")) -// }) - -// It("should FTProfile Search Limited", Label("search", "ftprofile"), func() { -// val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "t", FieldType: redis.SearchFieldTypeText}).Result() -// Expect(err).NotTo(HaveOccurred()) -// Expect(val).To(BeEquivalentTo("OK")) -// WaitForIndexing(client, "idx1") - -// client.HSet(ctx, "1", "t", "hello") -// client.HSet(ctx, "2", "t", "hell") -// client.HSet(ctx, "3", "t", "help") -// client.HSet(ctx, "4", "t", "helowa") - -// // FTProfile Search -// query := redis.FTSearchQuery("%hell% hel*", &redis.FTSearchOptions{}) -// res1, err := client.FTProfile(ctx, "idx1", true, query).Result() -// Expect(err).NotTo(HaveOccurred()) -// resProfile := res1["profile"].(map[interface{}]interface{}) -// iterProfile0 := resProfile["Iterators profile"].([]interface{})[0].(map[interface{}]interface{}) -// Expect(iterProfile0["Type"]).To(BeEquivalentTo("INTERSECT")) -// Expect(len(res1["results"].([]interface{}))).To(BeEquivalentTo(3)) -// Expect(iterProfile0["Child iterators"].([]interface{})[0].(map[interface{}]interface{})["Child iterators"]).To(BeEquivalentTo("The number of iterators in the union is 3")) -// Expect(iterProfile0["Child iterators"].([]interface{})[1].(map[interface{}]interface{})["Child iterators"]).To(BeEquivalentTo("The number of iterators in the union is 4")) -// }) - -// It("should FTProfile Search query params", Label("search", "ftprofile"), func() { -// hnswOptions := &redis.FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2"} -// val, err := client.FTCreate(ctx, "idx1", -// &redis.FTCreateOptions{}, -// &redis.FieldSchema{FieldName: "v", FieldType: redis.SearchFieldTypeVector, VectorArgs: &redis.FTVectorArgs{HNSWOptions: hnswOptions}}).Result() -// Expect(err).NotTo(HaveOccurred()) -// Expect(val).To(BeEquivalentTo("OK")) -// WaitForIndexing(client, "idx1") - -// client.HSet(ctx, "a", "v", "aaaaaaaa") -// client.HSet(ctx, "b", "v", "aaaabaaa") -// client.HSet(ctx, "c", "v", "aaaaabaa") - -// // FTProfile Search -// searchOptions := &redis.FTSearchOptions{ -// Return: []redis.FTSearchReturn{{FieldName: "__v_score"}}, -// SortBy: []redis.FTSearchSortBy{{FieldName: "__v_score", Asc: true}}, -// DialectVersion: 2, -// Params: map[string]interface{}{"vec": "aaaaaaaa"}, -// } -// query := redis.FTSearchQuery("*=>[KNN 2 @v $vec]", searchOptions) -// res1, err := client.FTProfile(ctx, "idx1", false, query).Result() -// Expect(err).NotTo(HaveOccurred()) -// resProfile := res1["profile"].(map[interface{}]interface{}) -// iterProfile0 := resProfile["Iterators profile"].([]interface{})[0].(map[interface{}]interface{}) -// Expect(iterProfile0["Counter"]).To(BeEquivalentTo(2)) -// Expect(iterProfile0["Type"]).To(BeEquivalentTo(redis.SearchFieldTypeVector.String())) -// Expect(res1["total_results"]).To(BeEquivalentTo(2)) -// results0 := res1["results"].([]interface{})[0].(map[interface{}]interface{}) -// Expect(results0["id"]).To(BeEquivalentTo("a")) -// Expect(results0["extra_attributes"].(map[interface{}]interface{})["__v_score"]).To(BeEquivalentTo("0")) -// }) - var _ = Describe("RediSearch FT.Config with Resp2 and Resp3", Label("search", "NonRedisEnterprise"), func() { var clientResp2 *redis.Client From a149ab2735f11c1ca4bd6b97a94ffc077d7c0691 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 13:21:55 +0300 Subject: [PATCH 08/27] chore(deps): bump rojopolis/spellcheck-github-actions (#3336) Bumps [rojopolis/spellcheck-github-actions](https://github.com/rojopolis/spellcheck-github-actions) from 0.47.0 to 0.48.0. - [Release notes](https://github.com/rojopolis/spellcheck-github-actions/releases) - [Changelog](https://github.com/rojopolis/spellcheck-github-actions/blob/master/CHANGELOG.md) - [Commits](https://github.com/rojopolis/spellcheck-github-actions/compare/0.47.0...0.48.0) --- updated-dependencies: - dependency-name: rojopolis/spellcheck-github-actions dependency-version: 0.48.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/spellcheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index beefa6164f..4d0fc338d6 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -8,7 +8,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Check Spelling - uses: rojopolis/spellcheck-github-actions@0.47.0 + uses: rojopolis/spellcheck-github-actions@0.48.0 with: config_path: .github/spellcheck-settings.yml task_name: Markdown From c85be5cb7b5230e476a59e4bb833cb094bc6e384 Mon Sep 17 00:00:00 2001 From: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> Date: Tue, 15 Apr 2025 14:39:59 +0300 Subject: [PATCH 09/27] Fix FT.Search Limit argument and add CountOnly argument for limit 0 0 (#3338) * Fix Limit argument and add CountOnly argument * Add test and Documentation * Update search_commands.go --------- Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> --- search_commands.go | 15 +++++++++++---- search_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/search_commands.go b/search_commands.go index 85e1256146..4094262095 100644 --- a/search_commands.go +++ b/search_commands.go @@ -320,8 +320,11 @@ type FTSearchOptions struct { SortByWithCount bool LimitOffset int Limit int - Params map[string]interface{} - DialectVersion int + // CountOnly sets LIMIT 0 0 to get the count - number of documents in the result set without actually returning the result set. + // When using this option, the Limit and LimitOffset options are ignored. + CountOnly bool + Params map[string]interface{} + DialectVersion int } type FTSynDumpResult struct { @@ -1954,8 +1957,12 @@ func (c cmdable) FTSearchWithArgs(ctx context.Context, index string, query strin args = append(args, "WITHCOUNT") } } - if options.LimitOffset >= 0 && options.Limit > 0 { - args = append(args, "LIMIT", options.LimitOffset, options.Limit) + if options.CountOnly { + args = append(args, "LIMIT", 0, 0) + } else { + if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 { + args = append(args, "LIMIT", options.LimitOffset, options.Limit) + } } if options.Params != nil { args = append(args, "PARAMS", len(options.Params)*2) diff --git a/search_test.go b/search_test.go index 4d8417d783..3c4457a45d 100644 --- a/search_test.go +++ b/search_test.go @@ -1683,6 +1683,44 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() { Expect(resUint8.Docs[0].ID).To(BeEquivalentTo("doc1")) }) + It("should test ft.search with CountOnly param", Label("search", "ftsearch"), func() { + val, err := client.FTCreate(ctx, "txtIndex", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "txtIndex") + + _, err = client.HSet(ctx, "doc1", "txt", "hello world").Result() + Expect(err).NotTo(HaveOccurred()) + _, err = client.HSet(ctx, "doc2", "txt", "hello go").Result() + Expect(err).NotTo(HaveOccurred()) + _, err = client.HSet(ctx, "doc3", "txt", "hello redis").Result() + Expect(err).NotTo(HaveOccurred()) + + optsCountOnly := &redis.FTSearchOptions{ + CountOnly: true, + LimitOffset: 0, + Limit: 2, // even though we limit to 2, with count-only no docs are returned + DialectVersion: 2, + } + resCountOnly, err := client.FTSearchWithArgs(ctx, "txtIndex", "hello", optsCountOnly).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resCountOnly.Total).To(BeEquivalentTo(3)) + Expect(len(resCountOnly.Docs)).To(BeEquivalentTo(0)) + + optsLimit := &redis.FTSearchOptions{ + CountOnly: false, + LimitOffset: 0, + Limit: 2, // we expect to get 2 documents even though total count is 3 + DialectVersion: 2, + } + resLimit, err := client.FTSearchWithArgs(ctx, "txtIndex", "hello", optsLimit).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resLimit.Total).To(BeEquivalentTo(3)) + Expect(len(resLimit.Docs)).To(BeEquivalentTo(2)) + }) + }) func _assert_geosearch_result(result *redis.FTSearchResult, expectedDocIDs []string) { From acc059fca92de99bcbe97b51dc008e2f8fa8c60c Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:19:53 +0300 Subject: [PATCH 10/27] fix add missing command in interface (#3344) --- hash_commands.go | 1 + 1 file changed, 1 insertion(+) diff --git a/hash_commands.go b/hash_commands.go index 1f53f344d9..50d94bae73 100644 --- a/hash_commands.go +++ b/hash_commands.go @@ -13,6 +13,7 @@ type HashCmdable interface { HGetDel(ctx context.Context, key string, fields ...string) *StringSliceCmd HGetEX(ctx context.Context, key string, fields ...string) *StringSliceCmd HGetEXWithArgs(ctx context.Context, key string, options *HGetEXOptions, fields ...string) *StringSliceCmd + HIncrBy(ctx context.Context, key, field string, incr int64) *IntCmd HIncrByFloat(ctx context.Context, key, field string, incr float64) *FloatCmd HKeys(ctx context.Context, key string) *StringSliceCmd HLen(ctx context.Context, key string) *IntCmd From 72224499a0a1ba605684c49093374d721dfc4678 Mon Sep 17 00:00:00 2001 From: Bulat Khasanov Date: Tue, 15 Apr 2025 16:57:50 +0300 Subject: [PATCH 11/27] Use DB option in NewFailoverClusterClient (#3342) --- sentinel.go | 16 ++++++++++++++++ sentinel_test.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sentinel.go b/sentinel.go index a4c9f53c40..a132af2fe5 100644 --- a/sentinel.go +++ b/sentinel.go @@ -815,6 +815,22 @@ func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient { } opt := failoverOpt.clusterOptions() + if failoverOpt.DB != 0 { + onConnect := opt.OnConnect + + opt.OnConnect = func(ctx context.Context, cn *Conn) error { + if err := cn.Select(ctx, failoverOpt.DB).Err(); err != nil { + return err + } + + if onConnect != nil { + return onConnect(ctx, cn) + } + + return nil + } + } + opt.ClusterSlots = func(ctx context.Context) ([]ClusterSlot, error) { masterAddr, err := failover.MasterAddr(ctx) if err != nil { diff --git a/sentinel_test.go b/sentinel_test.go index b34706f89a..07c7628a06 100644 --- a/sentinel_test.go +++ b/sentinel_test.go @@ -200,6 +200,7 @@ var _ = Describe("NewFailoverClusterClient", func() { SentinelAddrs: sentinelAddrs, RouteRandomly: true, + DB: 1, }) Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) @@ -289,6 +290,20 @@ var _ = Describe("NewFailoverClusterClient", func() { }) }) + It("should sentinel cluster client db", func() { + err := client.ForEachShard(ctx, func(ctx context.Context, c *redis.Client) error { + return c.Ping(ctx).Err() + }) + Expect(err).NotTo(HaveOccurred()) + + _ = client.ForEachShard(ctx, func(ctx context.Context, c *redis.Client) error { + clientInfo, err := c.ClientInfo(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(clientInfo.DB).To(Equal(1)) + return nil + }) + }) + It("should sentinel cluster PROTO 3", func() { _ = client.ForEachShard(ctx, func(ctx context.Context, c *redis.Client) error { val, err := client.Do(ctx, "HELLO").Result() From 3bc85d8f0a556f0a5c2a9bddd1125c411b00e4fb Mon Sep 17 00:00:00 2001 From: andy-stark-redis <164213578+andy-stark-redis@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:55:44 +0100 Subject: [PATCH 12/27] DOC-5102 added CountOnly search example for docs (#3345) --- doctests/home_json_example_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doctests/home_json_example_test.go b/doctests/home_json_example_test.go index ec2843ad35..4ee93d79d6 100644 --- a/doctests/home_json_example_test.go +++ b/doctests/home_json_example_test.go @@ -152,6 +152,32 @@ func ExampleClient_search_json() { // >>> Tel Aviv // STEP_END + // STEP_START query2count_only + citiesResult2, err := rdb.FTSearchWithArgs( + ctx, + "idx:users", + "Paul", + &redis.FTSearchOptions{ + Return: []redis.FTSearchReturn{ + { + FieldName: "$.city", + As: "city", + }, + }, + CountOnly: true, + }, + ).Result() + + if err != nil { + panic(err) + } + + // The `Total` field has the correct number of docs found + // by the query but the `Docs` slice is empty. + fmt.Println(len(citiesResult2.Docs)) // >>> 0 + fmt.Println(citiesResult2.Total) // >>> 2 + // STEP_END + // STEP_START query3 aggOptions := redis.FTAggregateOptions{ GroupBy: []redis.FTAggregateGroupBy{ @@ -196,6 +222,8 @@ func ExampleClient_search_json() { // {1 [{user:3 map[$:{"age":35,"city":"Tel Aviv","email":"paul.zamir@example.com","name":"Paul Zamir"}]}]} // London // Tel Aviv + // 0 + // 2 // London - 1 // Tel Aviv - 2 } From 7df8ec4c9fcaec25dd0f6dd7b44e15a57e45818b Mon Sep 17 00:00:00 2001 From: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:59:20 +0300 Subject: [PATCH 13/27] Add integration tests for Redis 8 behavior changes in Redis Search (#3337) * Add integration tests for Redis 8 behavior changes in Redis Search * Undo changes in ft.search limit * Fix BM25 as the default scorer test * Add more tests and comments on deprecated params * Update search_commands.go * Remove deprication comment for nostopwords --------- Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> --- search_commands.go | 12 +- search_test.go | 477 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 4 deletions(-) diff --git a/search_commands.go b/search_commands.go index 4094262095..b31baaa760 100644 --- a/search_commands.go +++ b/search_commands.go @@ -114,6 +114,7 @@ type SpellCheckTerms struct { } type FTExplainOptions struct { + // Dialect 1,3 and 4 are deprecated since redis 8.0 Dialect string } @@ -261,7 +262,8 @@ type FTAggregateOptions struct { WithCursor bool WithCursorOptions *FTAggregateWithCursor Params map[string]interface{} - DialectVersion int + // Dialect 1,3 and 4 are deprecated since redis 8.0 + DialectVersion int } type FTSearchFilter struct { @@ -322,8 +324,9 @@ type FTSearchOptions struct { Limit int // CountOnly sets LIMIT 0 0 to get the count - number of documents in the result set without actually returning the result set. // When using this option, the Limit and LimitOffset options are ignored. - CountOnly bool - Params map[string]interface{} + CountOnly bool + Params map[string]interface{} + // Dialect 1,3 and 4 are deprecated since redis 8.0 DialectVersion int } @@ -440,7 +443,8 @@ type IndexDefinition struct { type FTSpellCheckOptions struct { Distance int Terms *FTSpellCheckTerms - Dialect int + // Dialect 1,3 and 4 are deprecated since redis 8.0 + Dialect int } type FTSpellCheckTerms struct { diff --git a/search_test.go b/search_test.go index 3c4457a45d..6bc8b11123 100644 --- a/search_test.go +++ b/search_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" . "github.com/bsm/ginkgo/v2" @@ -1683,6 +1684,389 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() { Expect(resUint8.Docs[0].ID).To(BeEquivalentTo("doc1")) }) + It("should fail when using a non-zero offset with a zero limit", Label("search", "ftsearch"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "testIdx", &redis.FTCreateOptions{}, &redis.FieldSchema{ + FieldName: "txt", + FieldType: redis.SearchFieldTypeText, + }).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "testIdx") + + client.HSet(ctx, "doc1", "txt", "hello world") + + // Attempt to search with a non-zero offset and zero limit. + _, err = client.FTSearchWithArgs(ctx, "testIdx", "hello", &redis.FTSearchOptions{ + LimitOffset: 5, + Limit: 0, + }).Result() + Expect(err).To(HaveOccurred()) + }) + + It("should evaluate exponentiation precedence in APPLY expressions correctly", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "txns", &redis.FTCreateOptions{}, &redis.FieldSchema{ + FieldName: "dummy", + FieldType: redis.SearchFieldTypeText, + }).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "txns") + + client.HSet(ctx, "doc1", "dummy", "dummy") + + correctOptions := &redis.FTAggregateOptions{ + Apply: []redis.FTAggregateApply{ + {Field: "(2*3^2)", As: "Value"}, + }, + Limit: 1, + LimitOffset: 0, + } + correctRes, err := client.FTAggregateWithArgs(ctx, "txns", "*", correctOptions).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(correctRes.Rows[0].Fields["Value"]).To(BeEquivalentTo("18")) + }) + + It("should return a syntax error when empty strings are used for numeric parameters", Label("search", "ftsearch"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "idx", &redis.FTCreateOptions{}, &redis.FieldSchema{ + FieldName: "n", + FieldType: redis.SearchFieldTypeNumeric, + }).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "idx") + + client.HSet(ctx, "doc1", "n", 0) + + _, err = client.FTSearchWithArgs(ctx, "idx", "*", &redis.FTSearchOptions{ + Filters: []redis.FTSearchFilter{{ + FieldName: "n", + Min: "", + Max: "", + }}, + DialectVersion: 2, + }).Result() + Expect(err).To(HaveOccurred()) + }) + + It("should return NaN as default for AVG reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestAvg", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestAvg") + + client.HSet(ctx, "doc1", "grp", "g1") + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchAvg, Args: []interface{}{"@n"}, As: "avg"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestAvg", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + + Expect(res.Rows[0].Fields["avg"]).To(SatisfyAny(Equal("nan"), Equal("NaN"))) + }) + + It("should return 1 as default for COUNT reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestCount", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestCount") + + client.HSet(ctx, "doc1", "grp", "g1") + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchCount, As: "cnt"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestCount", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + + Expect(res.Rows[0].Fields["cnt"]).To(BeEquivalentTo("1")) + }) + + It("should return NaN as default for SUM reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestSum", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestSum") + + client.HSet(ctx, "doc1", "grp", "g1") + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchSum, Args: []interface{}{"@n"}, As: "sum"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestSum", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + + Expect(res.Rows[0].Fields["sum"]).To(SatisfyAny(Equal("nan"), Equal("NaN"))) + }) + + It("should return the full requested number of results by re-running the query when some results expire", Label("search", "ftsearch"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggExpired", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "order", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggExpired") + + for i := 1; i <= 15; i++ { + key := fmt.Sprintf("doc%d", i) + _, err := client.HSet(ctx, key, "order", i).Result() + Expect(err).NotTo(HaveOccurred()) + } + + _, err = client.Del(ctx, "doc3", "doc7").Result() + Expect(err).NotTo(HaveOccurred()) + + options := &redis.FTSearchOptions{ + SortBy: []redis.FTSearchSortBy{{FieldName: "order", Asc: true}}, + LimitOffset: 0, + Limit: 10, + } + res, err := client.FTSearchWithArgs(ctx, "aggExpired", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + + Expect(len(res.Docs)).To(BeEquivalentTo(10)) + + for _, doc := range res.Docs { + Expect(doc.ID).ToNot(Or(Equal("doc3"), Equal("doc7"))) + } + }) + + It("should stop processing and return an error when a timeout occurs", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTimeoutHeavy", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTimeoutHeavy") + + const totalDocs = 10000 + for i := 0; i < totalDocs; i++ { + key := fmt.Sprintf("doc%d", i) + _, err := client.HSet(ctx, key, "n", i).Result() + Expect(err).NotTo(HaveOccurred()) + } + + options := &redis.FTAggregateOptions{ + SortBy: []redis.FTAggregateSortBy{{FieldName: "@n", Desc: true}}, + LimitOffset: 0, + Limit: 100, + Timeout: 1, // 1 ms timeout, expected to trigger a timeout error. + } + _, err = client.FTAggregateWithArgs(ctx, "aggTimeoutHeavy", "*", options).Result() + Expect(err).To(HaveOccurred()) + Expect(strings.ToLower(err.Error())).To(ContainSubstring("timeout")) + }) + + It("should return 0 as default for COUNT_DISTINCT reducer when no values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestCountDistinct", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "x", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestCountDistinct") + + client.HSet(ctx, "doc1", "grp", "g1") + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchCountDistinct, Args: []interface{}{"@x"}, As: "distinct_count"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + + res, err := client.FTAggregateWithArgs(ctx, "aggTestCountDistinct", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + Expect(res.Rows[0].Fields["distinct_count"]).To(BeEquivalentTo("0")) + }) + + It("should return 0 as default for COUNT_DISTINCTISH reducer when no values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestCountDistinctIsh", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "y", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestCountDistinctIsh") + + _, err = client.HSet(ctx, "doc1", "grp", "g1").Result() + Expect(err).NotTo(HaveOccurred()) + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchCountDistinctish, Args: []interface{}{"@y"}, As: "distinctish_count"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestCountDistinctIsh", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + Expect(res.Rows[0].Fields["distinctish_count"]).To(BeEquivalentTo("0")) + }) + + It("should use BM25 as the default scorer", Label("search", "ftsearch"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "scoringTest", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "description", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "scoringTest") + + _, err = client.HSet(ctx, "doc1", "description", "red apple").Result() + Expect(err).NotTo(HaveOccurred()) + _, err = client.HSet(ctx, "doc2", "description", "green apple").Result() + Expect(err).NotTo(HaveOccurred()) + + resDefault, err := client.FTSearchWithArgs(ctx, "scoringTest", "apple", &redis.FTSearchOptions{WithScores: true}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resDefault.Total).To(BeNumerically(">", 0)) + + resBM25, err := client.FTSearchWithArgs(ctx, "scoringTest", "apple", &redis.FTSearchOptions{WithScores: true, Scorer: "BM25"}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resBM25.Total).To(BeNumerically(">", 0)) + Expect(resDefault.Total).To(BeEquivalentTo(resBM25.Total)) + Expect(resDefault.Docs[0].ID).To(BeElementOf("doc1", "doc2")) + Expect(resDefault.Docs[1].ID).To(BeElementOf("doc1", "doc2")) + }) + + It("should return 0 as default for STDDEV reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestStddev", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestStddev") + + _, err = client.HSet(ctx, "doc1", "grp", "g1").Result() + Expect(err).NotTo(HaveOccurred()) + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchStdDev, Args: []interface{}{"@n"}, As: "stddev"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestStddev", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + + Expect(res.Rows[0].Fields["stddev"]).To(BeEquivalentTo("0")) + }) + + It("should return NaN as default for QUANTILE reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestQuantile", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestQuantile") + + _, err = client.HSet(ctx, "doc1", "grp", "g1").Result() + Expect(err).NotTo(HaveOccurred()) + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchQuantile, Args: []interface{}{"@n", 0.5}, As: "quantile"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestQuantile", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + Expect(res.Rows[0].Fields["quantile"]).To(SatisfyAny(Equal("nan"), Equal("NaN"))) + }) + + It("should return nil as default for FIRST_VALUE reducer when no values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestFirstValue", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "t", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestFirstValue") + + _, err = client.HSet(ctx, "doc1", "grp", "g1").Result() + Expect(err).NotTo(HaveOccurred()) + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchFirstValue, Args: []interface{}{"@t"}, As: "first_val"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestFirstValue", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + Expect(res.Rows[0].Fields["first_val"]).To(BeNil()) + }) + + It("should fail to add an alias that is an existing index name", Label("search", "ftalias"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "name", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "idx1") + + val, err = client.FTCreate(ctx, "idx2", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "name", FieldType: redis.SearchFieldTypeText}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "idx2") + + _, err = client.FTAliasAdd(ctx, "idx2", "idx1").Result() + Expect(err).To(HaveOccurred()) + Expect(strings.ToLower(err.Error())).To(ContainSubstring("alias")) + }) + It("should test ft.search with CountOnly param", Label("search", "ftsearch"), func() { val, err := client.FTCreate(ctx, "txtIndex", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}, @@ -1721,6 +2105,99 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() { Expect(len(resLimit.Docs)).To(BeEquivalentTo(2)) }) + It("should reject deprecated configuration keys", Label("search", "ftconfig"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + // List of deprecated configuration keys. + deprecatedKeys := []string{ + "_FREE_RESOURCE_ON_THREAD", + "_NUMERIC_COMPRESS", + "_NUMERIC_RANGES_PARENTS", + "_PRINT_PROFILE_CLOCK", + "_PRIORITIZE_INTERSECT_UNION_CHILDREN", + "BG_INDEX_SLEEP_GAP", + "CONN_PER_SHARD", + "CURSOR_MAX_IDLE", + "CURSOR_REPLY_THRESHOLD", + "DEFAULT_DIALECT", + "EXTLOAD", + "FORK_GC_CLEAN_THRESHOLD", + "FORK_GC_RETRY_INTERVAL", + "FORK_GC_RUN_INTERVAL", + "FORKGC_SLEEP_BEFORE_EXIT", + "FRISOINI", + "GC_POLICY", + "GCSCANSIZE", + "INDEX_CURSOR_LIMIT", + "MAXAGGREGATERESULTS", + "MAXDOCTABLESIZE", + "MAXPREFIXEXPANSIONS", + "MAXSEARCHRESULTS", + "MIN_OPERATION_WORKERS", + "MIN_PHONETIC_TERM_LEN", + "MINPREFIX", + "MINSTEMLEN", + "NO_MEM_POOLS", + "NOGC", + "ON_TIMEOUT", + "MULTI_TEXT_SLOP", + "PARTIAL_INDEXED_DOCS", + "RAW_DOCID_ENCODING", + "SEARCH_THREADS", + "TIERED_HNSW_BUFFER_LIMIT", + "TIMEOUT", + "TOPOLOGY_VALIDATION_TIMEOUT", + "UNION_ITERATOR_HEAP", + "VSS_MAX_RESIZE", + "WORKERS", + "WORKERS_PRIORITY_BIAS_THRESHOLD", + "MT_MODE", + "WORKER_THREADS", + } + + for _, key := range deprecatedKeys { + _, err := client.FTConfigSet(ctx, key, "test_value").Result() + Expect(err).To(HaveOccurred()) + } + + val, err := client.ConfigGet(ctx, "*").Result() + Expect(err).NotTo(HaveOccurred()) + // Since FT.CONFIG is deprecated since redis 8, use CONFIG instead with new search parameters. + keys := make([]string, 0, len(val)) + for key := range val { + keys = append(keys, key) + } + Expect(keys).To(ContainElement(ContainSubstring("search"))) + }) + + It("should return INF for MIN reducer and -INF for MAX reducer when no numeric values are present", Label("search", "ftaggregate"), func() { + SkipBeforeRedisVersion(7.9, "requires Redis 8.x") + val, err := client.FTCreate(ctx, "aggTestMinMax", &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "grp", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "aggTestMinMax") + + _, err = client.HSet(ctx, "doc1", "grp", "g1").Result() + Expect(err).NotTo(HaveOccurred()) + + reducers := []redis.FTAggregateReducer{ + {Reducer: redis.SearchMin, Args: []interface{}{"@n"}, As: "minValue"}, + {Reducer: redis.SearchMax, Args: []interface{}{"@n"}, As: "maxValue"}, + } + groupBy := []redis.FTAggregateGroupBy{ + {Fields: []interface{}{"@grp"}, Reduce: reducers}, + } + options := &redis.FTAggregateOptions{GroupBy: groupBy} + res, err := client.FTAggregateWithArgs(ctx, "aggTestMinMax", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Rows).ToNot(BeEmpty()) + + Expect(res.Rows[0].Fields["minValue"]).To(BeEquivalentTo("inf")) + Expect(res.Rows[0].Fields["maxValue"]).To(BeEquivalentTo("-inf")) + }) + }) func _assert_geosearch_result(result *redis.FTSearchResult, expectedDocIDs []string) { From ad8c4323fc8dbccbceb10985f92ca8e33f3afa19 Mon Sep 17 00:00:00 2001 From: Bulat Khasanov Date: Wed, 16 Apr 2025 18:32:57 +0300 Subject: [PATCH 14/27] Use correct slot for COUNTKEYSINSLOT command (#3327) --- internal_test.go | 24 ++++++++++++++++++++++++ osscluster.go | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal_test.go b/internal_test.go index a6317196a6..516ada8236 100644 --- a/internal_test.go +++ b/internal_test.go @@ -352,3 +352,27 @@ var _ = Describe("withConn", func() { Expect(client.connPool.Len()).To(Equal(1)) }) }) + +var _ = Describe("ClusterClient", func() { + var client *ClusterClient + + BeforeEach(func() { + client = &ClusterClient{} + }) + + Describe("cmdSlot", func() { + It("select slot from args for GETKEYSINSLOT command", func() { + cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", 100, 200) + + slot := client.cmdSlot(context.Background(), cmd) + Expect(slot).To(Equal(100)) + }) + + It("select slot from args for COUNTKEYSINSLOT command", func() { + cmd := NewStringSliceCmd(ctx, "cluster", "countkeysinslot", 100) + + slot := client.cmdSlot(context.Background(), cmd) + Expect(slot).To(Equal(100)) + }) + }) +}) diff --git a/osscluster.go b/osscluster.go index b018cc9e46..20180464e2 100644 --- a/osscluster.go +++ b/osscluster.go @@ -1856,7 +1856,7 @@ func (c *ClusterClient) cmdInfo(ctx context.Context, name string) *CommandInfo { func (c *ClusterClient) cmdSlot(ctx context.Context, cmd Cmder) int { args := cmd.Args() - if args[0] == "cluster" && args[1] == "getkeysinslot" { + if args[0] == "cluster" && (args[1] == "getkeysinslot" || args[1] == "countkeysinslot") { return args[2].(int) } From 5067de704f6d3b0322db7c9411c235e0f2c9033c Mon Sep 17 00:00:00 2001 From: Naveen Prashanth <78990165+gnpaone@users.noreply.github.com> Date: Thu, 17 Apr 2025 01:02:40 +0530 Subject: [PATCH 15/27] Ensure context isn't exhausted via concurrent query as opposed to sentinel query (#3334) --- sentinel.go | 67 +++++++++++++++++++++++++++++++++++------------- sentinel_test.go | 19 ++++++++++++++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/sentinel.go b/sentinel.go index a132af2fe5..0638663536 100644 --- a/sentinel.go +++ b/sentinel.go @@ -566,29 +566,60 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { } } - for i, sentinelAddr := range c.sentinelAddrs { - sentinel := NewSentinelClient(c.opt.sentinelOptions(sentinelAddr)) + var ( + masterAddr string + wg sync.WaitGroup + once sync.Once + errCh = make(chan error, len(c.sentinelAddrs)) + ) - masterAddr, err := sentinel.GetMasterAddrByName(ctx, c.opt.MasterName).Result() - if err != nil { - _ = sentinel.Close() - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return "", err - } - internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName master=%q failed: %s", - c.opt.MasterName, err) - continue - } + ctx, cancel := context.WithCancel(ctx) + defer cancel() - // Push working sentinel to the top. - c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0] - c.setSentinel(ctx, sentinel) + for i, sentinelAddr := range c.sentinelAddrs { + wg.Add(1) + go func(i int, addr string) { + defer wg.Done() + sentinelCli := NewSentinelClient(c.opt.sentinelOptions(addr)) + addrVal, err := sentinelCli.GetMasterAddrByName(ctx, c.opt.MasterName).Result() + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Report immediately and return + errCh <- err + return + } + internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName addr=%s, master=%q failed: %s", + addr, c.opt.MasterName, err) + _ = sentinelCli.Close() + return + } - addr := net.JoinHostPort(masterAddr[0], masterAddr[1]) - return addr, nil + once.Do(func() { + masterAddr = net.JoinHostPort(addrVal[0], addrVal[1]) + // Push working sentinel to the top + c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0] + c.setSentinel(ctx, sentinelCli) + internal.Logger.Printf(ctx, "sentinel: selected addr=%s masterAddr=%s", addr, masterAddr) + cancel() + }) + }(i, sentinelAddr) } - return "", errors.New("redis: all sentinels specified in configuration are unreachable") + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + if masterAddr != "" { + return masterAddr, nil + } + return "", errors.New("redis: all sentinels specified in configuration are unreachable") + case err := <-errCh: + return "", err + } } func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected bool) ([]string, error) { diff --git a/sentinel_test.go b/sentinel_test.go index 07c7628a06..cde7f956d1 100644 --- a/sentinel_test.go +++ b/sentinel_test.go @@ -3,6 +3,7 @@ package redis_test import ( "context" "net" + "time" . "github.com/bsm/ginkgo/v2" . "github.com/bsm/gomega" @@ -32,6 +33,24 @@ var _ = Describe("Sentinel PROTO 2", func() { }) }) +var _ = Describe("Sentinel resolution", func() { + It("should resolve master without context exhaustion", func() { + shortCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + client := redis.NewFailoverClient(&redis.FailoverOptions{ + MasterName: sentinelName, + SentinelAddrs: sentinelAddrs, + MaxRetries: -1, + }) + + err := client.Ping(shortCtx).Err() + Expect(err).NotTo(HaveOccurred(), "expected master to resolve without context exhaustion") + + _ = client.Close() + }) +}) + var _ = Describe("Sentinel", func() { var client *redis.Client var master *redis.Client From cacf44d3be9b85269b327a41ef6e3468ce2ab30c Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:31:07 +0300 Subject: [PATCH 16/27] fix: better error handling when fetching the master node from the sentinels (#3349) * Better error handling when fetching the master node from the sentinels * fix error message generation * close the errCh to not block * use len over errCh --- sentinel.go | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/sentinel.go b/sentinel.go index 0638663536..f5b9a52d10 100644 --- a/sentinel.go +++ b/sentinel.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "errors" + "fmt" "net" "strings" "sync" @@ -583,17 +584,12 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { sentinelCli := NewSentinelClient(c.opt.sentinelOptions(addr)) addrVal, err := sentinelCli.GetMasterAddrByName(ctx, c.opt.MasterName).Result() if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Report immediately and return - errCh <- err - return - } internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName addr=%s, master=%q failed: %s", addr, c.opt.MasterName, err) _ = sentinelCli.Close() + errCh <- err return } - once.Do(func() { masterAddr = net.JoinHostPort(addrVal[0], addrVal[1]) // Push working sentinel to the top @@ -605,21 +601,16 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { }(i, sentinelAddr) } - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - - select { - case <-done: - if masterAddr != "" { - return masterAddr, nil - } - return "", errors.New("redis: all sentinels specified in configuration are unreachable") - case err := <-errCh: - return "", err + wg.Wait() + close(errCh) + if masterAddr != "" { + return masterAddr, nil + } + errs := make([]error, 0, len(errCh)) + for err := range errCh { + errs = append(errs, err) } + return "", fmt.Errorf("redis: all sentinels specified in configuration are unreachable: %w", errors.Join(errs...)) } func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected bool) ([]string, error) { From cc27ec236ade9aa2f20a49e5dc8fc2e4f9c46a3d Mon Sep 17 00:00:00 2001 From: Glenn Date: Mon, 21 Apr 2025 22:11:00 -0700 Subject: [PATCH 17/27] docs: fix documentation comments (#3351) --- command.go | 3 ++- commands.go | 2 +- hash_commands.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/command.go b/command.go index 696501453a..364706e3e7 100644 --- a/command.go +++ b/command.go @@ -3831,7 +3831,8 @@ func (cmd *MapStringStringSliceCmd) readReply(rd *proto.Reader) error { } // ----------------------------------------------------------------------- -// MapStringInterfaceCmd represents a command that returns a map of strings to interface{}. + +// MapMapStringInterfaceCmd represents a command that returns a map of strings to interface{}. type MapMapStringInterfaceCmd struct { baseCmd val map[string]interface{} diff --git a/commands.go b/commands.go index 6321c15e2d..123005bc79 100644 --- a/commands.go +++ b/commands.go @@ -330,7 +330,7 @@ func (info LibraryInfo) Validate() error { return nil } -// Hello Set the resp protocol used. +// Hello sets the resp protocol used. func (c statefulCmdable) Hello(ctx context.Context, ver int, username, password, clientName string, ) *MapStringInterfaceCmd { diff --git a/hash_commands.go b/hash_commands.go index 50d94bae73..be58b8d2d0 100644 --- a/hash_commands.go +++ b/hash_commands.go @@ -480,7 +480,7 @@ func (c cmdable) HGetEX(ctx context.Context, key string, fields ...string) *Stri return cmd } -// ExpirationType represents an expiration option for the HGETEX command. +// HGetEXExpirationType represents an expiration option for the HGETEX command. type HGetEXExpirationType string const ( From b203bfdfa396904bdc4ac5aceed86ea2c4e09a6b Mon Sep 17 00:00:00 2001 From: andy-stark-redis <164213578+andy-stark-redis@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:24:57 +0100 Subject: [PATCH 18/27] DOC-5111 added hash search examples (#3357) --- doctests/home_json_example_test.go | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/doctests/home_json_example_test.go b/doctests/home_json_example_test.go index 4ee93d79d6..f32bf8d10f 100644 --- a/doctests/home_json_example_test.go +++ b/doctests/home_json_example_test.go @@ -227,3 +227,107 @@ func ExampleClient_search_json() { // London - 1 // Tel Aviv - 2 } + +func ExampleClient_search_hash() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + Protocol: 2, + }) + + // REMOVE_START + rdb.Del(ctx, "huser:1", "huser:2", "huser:3") + rdb.FTDropIndex(ctx, "hash-idx:users") + // REMOVE_END + + // STEP_START make_hash_index + _, err := rdb.FTCreate( + ctx, + "hash-idx:users", + // Options: + &redis.FTCreateOptions{ + OnHash: true, + Prefix: []interface{}{"huser:"}, + }, + // Index schema fields: + &redis.FieldSchema{ + FieldName: "name", + FieldType: redis.SearchFieldTypeText, + }, + &redis.FieldSchema{ + FieldName: "city", + FieldType: redis.SearchFieldTypeTag, + }, + &redis.FieldSchema{ + FieldName: "age", + FieldType: redis.SearchFieldTypeNumeric, + }, + ).Result() + + if err != nil { + panic(err) + } + // STEP_END + + user1 := map[string]interface{}{ + "name": "Paul John", + "email": "paul.john@example.com", + "age": 42, + "city": "London", + } + + user2 := map[string]interface{}{ + "name": "Eden Zamir", + "email": "eden.zamir@example.com", + "age": 29, + "city": "Tel Aviv", + } + + user3 := map[string]interface{}{ + "name": "Paul Zamir", + "email": "paul.zamir@example.com", + "age": 35, + "city": "Tel Aviv", + } + + // STEP_START add_hash_data + _, err = rdb.HSet(ctx, "huser:1", user1).Result() + + if err != nil { + panic(err) + } + + _, err = rdb.HSet(ctx, "huser:2", user2).Result() + + if err != nil { + panic(err) + } + + _, err = rdb.HSet(ctx, "huser:3", user3).Result() + + if err != nil { + panic(err) + } + // STEP_END + + // STEP_START query1_hash + findPaulHashResult, err := rdb.FTSearch( + ctx, + "hash-idx:users", + "Paul @age:[30 40]", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(findPaulHashResult) + // >>> {1 [{huser:3 map[age:35 city:Tel Aviv... + // STEP_END + + // Output: + // {1 [{huser:3 map[age:35 city:Tel Aviv email:paul.zamir@example.com name:Paul Zamir]}]} +} From adb479820c600565622a8c7d739d7b38ce86e5b1 Mon Sep 17 00:00:00 2001 From: frankj Date: Wed, 23 Apr 2025 15:47:13 +0800 Subject: [PATCH 19/27] fix: Fix panic caused when arg is nil (#3353) --- commands.go | 2 ++ commands_test.go | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/commands.go b/commands.go index 123005bc79..bca7d7eedd 100644 --- a/commands.go +++ b/commands.go @@ -81,6 +81,8 @@ func appendArg(dst []interface{}, arg interface{}) []interface{} { return dst case time.Time, time.Duration, encoding.BinaryMarshaler, net.IP: return append(dst, arg) + case nil: + return dst default: // scan struct field v := reflect.ValueOf(arg) diff --git a/commands_test.go b/commands_test.go index 55b9574964..6a76756a98 100644 --- a/commands_test.go +++ b/commands_test.go @@ -7209,6 +7209,17 @@ var _ = Describe("Commands", func() { Expect(err).NotTo(HaveOccurred()) Expect(vals).To(Equal([]interface{}{int64(12), proto.RedisError("error"), "abc"})) }) + + It("returns empty values when args are nil", func() { + vals, err := client.Eval( + ctx, + "return {ARGV[1]}", + []string{}, + nil, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(vals).To(BeEmpty()) + }) }) Describe("EvalRO", func() { @@ -7232,6 +7243,17 @@ var _ = Describe("Commands", func() { Expect(err).NotTo(HaveOccurred()) Expect(vals).To(Equal([]interface{}{int64(12), proto.RedisError("error"), "abc"})) }) + + It("returns empty values when args are nil", func() { + vals, err := client.EvalRO( + ctx, + "return {ARGV[1]}", + []string{}, + nil, + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(vals).To(BeEmpty()) + }) }) Describe("Functions", func() { From b28606cf2599bdebce2b2f534445e605f4d76e1e Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:36:38 +0300 Subject: [PATCH 20/27] Update README.md, use redis discord guild (#3331) * use redis discord guild * add line in CONTRIBUTING.md * update with badges similar to rest of the libraries. update url * updated with direct invite link * fix discord link in CONTRIBUTING.md * fix stackoverflow tag --------- Co-authored-by: Elena Kolevska --- CONTRIBUTING.md | 4 ++++ README.md | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcaee7c731..7228a4a060 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,3 +112,7 @@ The core team regularly looks at pull requests. We will provide feedback as soon as possible. After receiving our feedback, please respond within two weeks. After that time, we may close your PR if it isn't showing any activity. + +## Support + +Maintainers can provide limited support to contributors on discord: https://discord.gg/W4txy5AeKM diff --git a/README.md b/README.md index 6c42c0fc2c..4487c6e9a7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,14 @@ [![build workflow](https://github.com/redis/go-redis/actions/workflows/build.yml/badge.svg)](https://github.com/redis/go-redis/actions) [![PkgGoDev](https://pkg.go.dev/badge/github.com/redis/go-redis/v9)](https://pkg.go.dev/github.com/redis/go-redis/v9?tab=doc) [![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.uptrace.dev/) +[![Go Report Card](https://goreportcard.com/badge/github.com/redis/go-redis/v9)](https://goreportcard.com/report/github.com/redis/go-redis/v9) [![codecov](https://codecov.io/github/redis/go-redis/graph/badge.svg?token=tsrCZKuSSw)](https://codecov.io/github/redis/go-redis) -[![Chat](https://discordapp.com/api/guilds/752070105847955518/widget.png)](https://discord.gg/rWtp5Aj) + +[![Discord](https://img.shields.io/discord/697882427875393627.svg?style=social&logo=discord)](https://discord.gg/W4txy5AeKM) +[![Twitch](https://img.shields.io/twitch/status/redisinc?style=social)](https://www.twitch.tv/redisinc) +[![YouTube](https://img.shields.io/youtube/channel/views/UCD78lHSwYqMlyetR0_P4Vig?style=social)](https://www.youtube.com/redisinc) +[![Twitter](https://img.shields.io/twitter/follow/redisinc?style=social)](https://twitter.com/redisinc) +[![Stack Exchange questions](https://img.shields.io/stackexchange/stackoverflow/t/go-redis?style=social&logo=stackoverflow&label=Stackoverflow)](https://stackoverflow.com/questions/tagged/go-redis) > go-redis is the official Redis client library for the Go programming language. It offers a straightforward interface for interacting with Redis servers. @@ -44,7 +50,7 @@ in the `go.mod` to `go 1.24` in one of the next releases. ## Resources - [Discussions](https://github.com/redis/go-redis/discussions) -- [Chat](https://discord.gg/rWtp5Aj) +- [Chat](https://discord.gg/W4txy5AeKM) - [Reference](https://pkg.go.dev/github.com/redis/go-redis/v9) - [Examples](https://pkg.go.dev/github.com/redis/go-redis/v9#pkg-examples) From f9b0e70f4c0e34210d8429592fc165922d91e706 Mon Sep 17 00:00:00 2001 From: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> Date: Wed, 23 Apr 2025 22:52:38 +0300 Subject: [PATCH 21/27] update HExpire command documentation (#3355) * update HExpire command documentation * Apply suggestions from code review Format the links in the documentation. Add missing documentation. --------- Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> --- hash_commands.go | 54 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/hash_commands.go b/hash_commands.go index be58b8d2d0..98a361b3ef 100644 --- a/hash_commands.go +++ b/hash_commands.go @@ -224,7 +224,10 @@ type HExpireArgs struct { // HExpire - Sets the expiration time for specified fields in a hash in seconds. // The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. -// For more information - https://redis.io/commands/hexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HEXPIRE Documentation]. +// +// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/ func (c cmdable) HExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration), "FIELDS", len(fields)} @@ -239,7 +242,10 @@ func (c cmdable) HExpire(ctx context.Context, key string, expiration time.Durati // HExpireWithArgs - Sets the expiration time for specified fields in a hash in seconds. // It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields. // The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. -// For more information - https://redis.io/commands/hexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HEXPIRE Documentation]. +// +// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/ func (c cmdable) HExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration)} @@ -268,7 +274,10 @@ func (c cmdable) HExpireWithArgs(ctx context.Context, key string, expiration tim // HPExpire - Sets the expiration time for specified fields in a hash in milliseconds. // Similar to HExpire, it accepts a key, an expiration duration in milliseconds, a struct with expiration condition flags, and a list of fields. // The command modifies the standard time.Duration to milliseconds for the Redis command. -// For more information - https://redis.io/commands/hpexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HPEXPIRE Documentation]. +// +// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/ func (c cmdable) HPExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration), "FIELDS", len(fields)} @@ -280,6 +289,13 @@ func (c cmdable) HPExpire(ctx context.Context, key string, expiration time.Durat return cmd } +// HPExpireWithArgs - Sets the expiration time for specified fields in a hash in milliseconds. +// It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields. +// The command constructs an argument list starting with "HPEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. +// Available since Redis 7.4 CE. +// For more information refer to [HPEXPIRE Documentation]. +// +// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/ func (c cmdable) HPExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration)} @@ -308,7 +324,10 @@ func (c cmdable) HPExpireWithArgs(ctx context.Context, key string, expiration ti // HExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in seconds. // Takes a key, a UNIX timestamp, a struct of conditional flags, and a list of fields. // The command sets absolute expiration times based on the UNIX timestamp provided. -// For more information - https://redis.io/commands/hexpireat/ +// Available since Redis 7.4 CE. +// For more information refer to [HExpireAt Documentation]. +// +// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/ func (c cmdable) HExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIREAT", key, tm.Unix(), "FIELDS", len(fields)} @@ -348,7 +367,10 @@ func (c cmdable) HExpireAtWithArgs(ctx context.Context, key string, tm time.Time // HPExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in milliseconds. // Similar to HExpireAt but for timestamps in milliseconds. It accepts the same parameters and adjusts the UNIX time to milliseconds. -// For more information - https://redis.io/commands/hpexpireat/ +// Available since Redis 7.4 CE. +// For more information refer to [HExpireAt Documentation]. +// +// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/ func (c cmdable) HPExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIREAT", key, tm.UnixNano() / int64(time.Millisecond), "FIELDS", len(fields)} @@ -388,7 +410,10 @@ func (c cmdable) HPExpireAtWithArgs(ctx context.Context, key string, tm time.Tim // HPersist - Removes the expiration time from specified fields in a hash. // Accepts a key and the fields themselves. // This command ensures that each field specified will have its expiration removed if present. -// For more information - https://redis.io/commands/hpersist/ +// Available since Redis 7.4 CE. +// For more information refer to [HPersist Documentation]. +// +// [HPersist Documentation]: https://redis.io/commands/hpersist/ func (c cmdable) HPersist(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPERSIST", key, "FIELDS", len(fields)} @@ -403,6 +428,10 @@ func (c cmdable) HPersist(ctx context.Context, key string, fields ...string) *In // HExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in seconds. // Requires a key and the fields themselves to fetch their expiration timestamps. // This command returns the expiration times for each field or error/status codes for each field as specified. +// Available since Redis 7.4 CE. +// For more information refer to [HExpireTime Documentation]. +// +// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/ // For more information - https://redis.io/commands/hexpiretime/ func (c cmdable) HExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRETIME", key, "FIELDS", len(fields)} @@ -418,6 +447,10 @@ func (c cmdable) HExpireTime(ctx context.Context, key string, fields ...string) // HPExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in milliseconds. // Similar to HExpireTime, adjusted for timestamps in milliseconds. It requires the same parameters. // Provides the expiration timestamp for each field in milliseconds. +// Available since Redis 7.4 CE. +// For more information refer to [HExpireTime Documentation]. +// +// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/ // For more information - https://redis.io/commands/hexpiretime/ func (c cmdable) HPExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRETIME", key, "FIELDS", len(fields)} @@ -433,7 +466,10 @@ func (c cmdable) HPExpireTime(ctx context.Context, key string, fields ...string) // HTTL - Retrieves the remaining time to live for specified fields in a hash in seconds. // Requires a key and the fields themselves. It returns the TTL for each specified field. // This command fetches the TTL in seconds for each field or returns error/status codes as appropriate. -// For more information - https://redis.io/commands/httl/ +// Available since Redis 7.4 CE. +// For more information refer to [HTTL Documentation]. +// +// [HTTL Documentation]: https://redis.io/commands/httl/ func (c cmdable) HTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HTTL", key, "FIELDS", len(fields)} @@ -448,6 +484,10 @@ func (c cmdable) HTTL(ctx context.Context, key string, fields ...string) *IntSli // HPTTL - Retrieves the remaining time to live for specified fields in a hash in milliseconds. // Similar to HTTL, but returns the TTL in milliseconds. It requires a key and the specified fields. // This command provides the TTL in milliseconds for each field or returns error/status codes as needed. +// Available since Redis 7.4 CE. +// For more information refer to [HPTTL Documentation]. +// +// [HPTTL Documentation]: https://redis.io/commands/hpttl/ // For more information - https://redis.io/commands/hpttl/ func (c cmdable) HPTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPTTL", key, "FIELDS", len(fields)} From 46d4b20ee247198ad0386e93286773c755e3632a Mon Sep 17 00:00:00 2001 From: Hui Date: Tue, 29 Apr 2025 05:16:53 +0800 Subject: [PATCH 22/27] feat: func isEmptyValue support time.Time (#3273) * fix:func isEmptyValue support time.Time * fix: Improve HSet unit tests * feat: Improve HSet unit tests * fix: isEmptyValue Struct only support time.Time * test(hset): add empty custom struct test --------- Co-authored-by: Guo Hui Co-authored-by: Nedyalko Dyakov --- commands.go | 6 +++++ commands_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/commands.go b/commands.go index bca7d7eedd..2713232420 100644 --- a/commands.go +++ b/commands.go @@ -155,6 +155,12 @@ func isEmptyValue(v reflect.Value) bool { return v.Float() == 0 case reflect.Interface, reflect.Pointer: return v.IsNil() + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + return v.IsZero() + } + // Only supports the struct time.Time, + // subsequent iterations will follow the func Scan support decoder. } return false } diff --git a/commands_test.go b/commands_test.go index 6a76756a98..8b2aa37d47 100644 --- a/commands_test.go +++ b/commands_test.go @@ -2578,6 +2578,63 @@ var _ = Describe("Commands", func() { "val2", "val", })) + + type setOmitEmpty struct { + Set1 string `redis:"set1"` + Set2 int `redis:"set2,omitempty"` + Set3 time.Duration `redis:"set3,omitempty"` + Set4 string `redis:"set4,omitempty"` + Set5 time.Time `redis:"set5,omitempty"` + Set6 *numberStruct `redis:"set6,omitempty"` + Set7 numberStruct `redis:"set7,omitempty"` + } + + hSet = client.HSet(ctx, "hash3", &setOmitEmpty{ + Set1: "val", + }) + Expect(hSet.Err()).NotTo(HaveOccurred()) + // both set1 and set7 are set + // custom struct is not omitted + Expect(hSet.Val()).To(Equal(int64(2))) + + hGetAll := client.HGetAll(ctx, "hash3") + Expect(hGetAll.Err()).NotTo(HaveOccurred()) + Expect(hGetAll.Val()).To(Equal(map[string]string{ + "set1": "val", + "set7": `{"Number":0}`, + })) + var hash3 setOmitEmpty + Expect(hGetAll.Scan(&hash3)).NotTo(HaveOccurred()) + Expect(hash3.Set1).To(Equal("val")) + Expect(hash3.Set2).To(Equal(0)) + Expect(hash3.Set3).To(Equal(time.Duration(0))) + Expect(hash3.Set4).To(Equal("")) + Expect(hash3.Set5).To(Equal(time.Time{})) + Expect(hash3.Set6).To(BeNil()) + Expect(hash3.Set7).To(Equal(numberStruct{})) + + now := time.Now() + hSet = client.HSet(ctx, "hash4", setOmitEmpty{ + Set1: "val", + Set5: now, + Set6: &numberStruct{ + Number: 5, + }, + Set7: numberStruct{ + Number: 3, + }, + }) + Expect(hSet.Err()).NotTo(HaveOccurred()) + Expect(hSet.Val()).To(Equal(int64(4))) + + hGetAll = client.HGetAll(ctx, "hash4") + Expect(hGetAll.Err()).NotTo(HaveOccurred()) + Expect(hGetAll.Val()).To(Equal(map[string]string{ + "set1": "val", + "set5": now.Format(time.RFC3339Nano), + "set6": `{"Number":5}`, + "set7": `{"Number":3}`, + })) }) It("should HSetNX", func() { @@ -7619,12 +7676,16 @@ type numberStruct struct { Number int } -func (s *numberStruct) MarshalBinary() ([]byte, error) { - return json.Marshal(s) +func (n numberStruct) MarshalBinary() ([]byte, error) { + return json.Marshal(n) +} + +func (n *numberStruct) UnmarshalBinary(b []byte) error { + return json.Unmarshal(b, n) } -func (s *numberStruct) UnmarshalBinary(b []byte) error { - return json.Unmarshal(b, s) +func (n *numberStruct) ScanRedis(str string) error { + return json.Unmarshal([]byte(str), n) } func deref(viface interface{}) interface{} { From cb2cfb000d5321b7f49204175dfa24f2ab69d18e Mon Sep 17 00:00:00 2001 From: fukua95 Date: Tue, 29 Apr 2025 14:39:26 +0800 Subject: [PATCH 23/27] fix: `PubSub` isn't concurrency-safe (#3360) --- pubsub.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pubsub.go b/pubsub.go index 20c085f1f8..2a0e7a81e1 100644 --- a/pubsub.go +++ b/pubsub.go @@ -45,6 +45,9 @@ func (c *PubSub) init() { } func (c *PubSub) String() string { + c.mu.Lock() + defer c.mu.Unlock() + channels := mapKeys(c.channels) channels = append(channels, mapKeys(c.patterns)...) channels = append(channels, mapKeys(c.schannels)...) From 09dc3510a2c7c38a6be81f8fcf22ed8ed7410e40 Mon Sep 17 00:00:00 2001 From: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:53:06 +0300 Subject: [PATCH 24/27] migrate golangci-lint config to v2 format (#3354) * migrate golangci-lint config to v2 format * chore: skip CI on migration [skip ci] * Bump golangci version * Address several golangci-lint/staticcheck warnings * change staticchecks settings --- .github/workflows/golangci-lint.yml | 4 ++-- .golangci.yml | 31 +++++++++++++++++++++++++++++ command.go | 5 +++-- extra/rediscensus/go.mod | 2 +- extra/rediscmd/go.mod | 2 +- extra/redisotel/go.mod | 2 +- extra/redisprometheus/go.mod | 2 +- options.go | 5 +++-- osscluster.go | 5 +++-- ring.go | 5 +++-- sentinel_test.go | 2 +- universal.go | 14 ++++++------- 12 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 515750af60..5e0ac1d0ac 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v6.5.2 + uses: golangci/golangci-lint-action@v7.0.0 with: - verify: false # disable verifying the configuration since golangci is currently introducing breaking changes in the configuration + verify: true diff --git a/.golangci.yml b/.golangci.yml index 285aca6b3a..872454ff7f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,34 @@ +version: "2" run: timeout: 5m tests: false +linters: + settings: + staticcheck: + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + - -ST1003 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/command.go b/command.go index 364706e3e7..3253af6cc9 100644 --- a/command.go +++ b/command.go @@ -1412,7 +1412,8 @@ func (cmd *MapStringSliceInterfaceCmd) readReply(rd *proto.Reader) (err error) { cmd.val = make(map[string][]interface{}) - if readType == proto.RespMap { + switch readType { + case proto.RespMap: n, err := rd.ReadMapLen() if err != nil { return err @@ -1435,7 +1436,7 @@ func (cmd *MapStringSliceInterfaceCmd) readReply(rd *proto.Reader) (err error) { cmd.val[k][j] = value } } - } else if readType == proto.RespArray { + case proto.RespArray: // RESP2 response n, err := rd.ReadArrayLen() if err != nil { diff --git a/extra/rediscensus/go.mod b/extra/rediscensus/go.mod index 7033e805f6..b39f7dd4c4 100644 --- a/extra/rediscensus/go.mod +++ b/extra/rediscensus/go.mod @@ -19,6 +19,6 @@ require ( ) retract ( - v9.5.3 // This version was accidentally released. v9.7.2 // This version was accidentally released. + v9.5.3 // This version was accidentally released. ) diff --git a/extra/rediscmd/go.mod b/extra/rediscmd/go.mod index c1cff3e90d..93cc423dba 100644 --- a/extra/rediscmd/go.mod +++ b/extra/rediscmd/go.mod @@ -16,6 +16,6 @@ require ( ) retract ( - v9.5.3 // This version was accidentally released. v9.7.2 // This version was accidentally released. + v9.5.3 // This version was accidentally released. ) diff --git a/extra/redisotel/go.mod b/extra/redisotel/go.mod index e5b442e614..c5b29dffa1 100644 --- a/extra/redisotel/go.mod +++ b/extra/redisotel/go.mod @@ -24,6 +24,6 @@ require ( ) retract ( - v9.5.3 // This version was accidentally released. v9.7.2 // This version was accidentally released. + v9.5.3 // This version was accidentally released. ) diff --git a/extra/redisprometheus/go.mod b/extra/redisprometheus/go.mod index 8bff000869..c934767e05 100644 --- a/extra/redisprometheus/go.mod +++ b/extra/redisprometheus/go.mod @@ -23,6 +23,6 @@ require ( ) retract ( - v9.5.3 // This version was accidentally released. v9.7.2 // This version was accidentally released. + v9.5.3 // This version was accidentally released. ) diff --git a/options.go b/options.go index 0ebeec342c..3ffcd07ede 100644 --- a/options.go +++ b/options.go @@ -214,9 +214,10 @@ func (opt *Options) init() { opt.ConnMaxIdleTime = 30 * time.Minute } - if opt.MaxRetries == -1 { + switch opt.MaxRetries { + case -1: opt.MaxRetries = 0 - } else if opt.MaxRetries == 0 { + case 0: opt.MaxRetries = 3 } switch opt.MinRetryBackoff { diff --git a/osscluster.go b/osscluster.go index 20180464e2..3b46cbe35b 100644 --- a/osscluster.go +++ b/osscluster.go @@ -111,9 +111,10 @@ type ClusterOptions struct { } func (opt *ClusterOptions) init() { - if opt.MaxRedirects == -1 { + switch opt.MaxRedirects { + case -1: opt.MaxRedirects = 0 - } else if opt.MaxRedirects == 0 { + case 0: opt.MaxRedirects = 3 } diff --git a/ring.go b/ring.go index 0ff3f75b1e..8f2dd3c401 100644 --- a/ring.go +++ b/ring.go @@ -128,9 +128,10 @@ func (opt *RingOptions) init() { opt.NewConsistentHash = newRendezvous } - if opt.MaxRetries == -1 { + switch opt.MaxRetries { + case -1: opt.MaxRetries = 0 - } else if opt.MaxRetries == 0 { + case 0: opt.MaxRetries = 3 } switch opt.MinRetryBackoff { diff --git a/sentinel_test.go b/sentinel_test.go index cde7f956d1..2d481d5fc3 100644 --- a/sentinel_test.go +++ b/sentinel_test.go @@ -41,7 +41,7 @@ var _ = Describe("Sentinel resolution", func() { client := redis.NewFailoverClient(&redis.FailoverOptions{ MasterName: sentinelName, SentinelAddrs: sentinelAddrs, - MaxRetries: -1, + MaxRetries: -1, }) err := client.Ping(shortCtx).Err() diff --git a/universal.go b/universal.go index 3d91dd493e..46d5640da6 100644 --- a/universal.go +++ b/universal.go @@ -259,13 +259,13 @@ var ( // NewUniversalClient returns a new multi client. The type of the returned client depends // on the following conditions: // -// 1. If the MasterName option is specified with RouteByLatency, RouteRandomly or IsClusterMode, -// a FailoverClusterClient is returned. -// 2. If the MasterName option is specified without RouteByLatency, RouteRandomly or IsClusterMode, -// a sentinel-backed FailoverClient is returned. -// 3. If the number of Addrs is two or more, or IsClusterMode option is specified, -// a ClusterClient is returned. -// 4. Otherwise, a single-node Client is returned. +// 1. If the MasterName option is specified with RouteByLatency, RouteRandomly or IsClusterMode, +// a FailoverClusterClient is returned. +// 2. If the MasterName option is specified without RouteByLatency, RouteRandomly or IsClusterMode, +// a sentinel-backed FailoverClient is returned. +// 3. If the number of Addrs is two or more, or IsClusterMode option is specified, +// a ClusterClient is returned. +// 4. Otherwise, a single-node Client is returned. func NewUniversalClient(opts *UniversalOptions) UniversalClient { switch { case opts.MasterName != "" && (opts.RouteByLatency || opts.RouteRandomly || opts.IsClusterMode): From 8f582356b31a8bdc9bfad7327abb4e279eddbdb5 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:08:34 +0300 Subject: [PATCH 25/27] chore(ci): Use redis 8 rc2 image. (#3361) * chore(ci): Use redis 8 rc2 image * test(timeseries): fix duplicatePolicy check --- .github/actions/run-tests/action.yml | 2 +- .github/workflows/build.yml | 6 +++--- timeseries_commands_test.go | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 2edb16d398..08323aa511 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -25,7 +25,7 @@ runs: # Mapping of redis version to redis testing containers declare -A redis_version_mapping=( - ["8.0-RC1"]="8.0-RC1-pre" + ["8.0-RC2"]="8.0-RC2-pre" ["7.4.2"]="rs-7.4.0-v2" ["7.2.7"]="rs-7.2.0-v14" ) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f88ca67224..810ab50979 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: redis-version: - - "8.0-RC1" # 8.0 RC1 + - "8.0-RC2" # 8.0 RC2 - "7.4.2" # should use redis stack 7.4 go-version: - "1.23.x" @@ -43,7 +43,7 @@ jobs: # Mapping of redis version to redis testing containers declare -A redis_version_mapping=( - ["8.0-RC1"]="8.0-RC1-pre" + ["8.0-RC2"]="8.0-RC2-pre" ["7.4.2"]="rs-7.4.0-v2" ) if [[ -v redis_version_mapping[$REDIS_VERSION] ]]; then @@ -72,7 +72,7 @@ jobs: fail-fast: false matrix: redis-version: - - "8.0-RC1" # 8.0 RC1 + - "8.0-RC2" # 8.0 RC2 - "7.4.2" # should use redis stack 7.4 - "7.2.7" # should redis stack 7.2 go-version: diff --git a/timeseries_commands_test.go b/timeseries_commands_test.go index d0d865b48c..fdef3e6029 100644 --- a/timeseries_commands_test.go +++ b/timeseries_commands_test.go @@ -269,11 +269,21 @@ var _ = Describe("RedisTimeseries commands", Label("timeseries"), func() { if client.Options().Protocol == 2 { Expect(resultInfo["labels"].([]interface{})[0]).To(BeEquivalentTo([]interface{}{"Time", "Series"})) Expect(resultInfo["retentionTime"]).To(BeEquivalentTo(10)) - Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo(redis.Nil)) + if RedisVersion >= 8 { + Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo("block")) + } else { + // Older versions of Redis had a bug where the duplicate policy was not set correctly + Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo(redis.Nil)) + } } else { Expect(resultInfo["labels"].(map[interface{}]interface{})["Time"]).To(BeEquivalentTo("Series")) Expect(resultInfo["retentionTime"]).To(BeEquivalentTo(10)) - Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo(redis.Nil)) + if RedisVersion >= 8 { + Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo("block")) + } else { + // Older versions of Redis had a bug where the duplicate policy was not set correctly + Expect(resultInfo["duplicatePolicy"]).To(BeEquivalentTo(redis.Nil)) + } } opt = &redis.TSAlterOptions{DuplicatePolicy: "min"} resultAlter, err = client.TSAlter(ctx, "1", opt).Result() From 22992116f37f38c0b34ad2a6a5f19ff244c3f8ad Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Wed, 30 Apr 2025 09:33:40 +0300 Subject: [PATCH 26/27] feat(options): panic when options are nil (#3363) Client creation should panic when options are nil. --- osscluster.go | 3 +++ redis.go | 3 +++ redis_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ ring.go | 3 +++ sentinel.go | 11 +++++++++++ universal.go | 4 ++++ 6 files changed, 75 insertions(+) diff --git a/osscluster.go b/osscluster.go index 3b46cbe35b..c0278ed057 100644 --- a/osscluster.go +++ b/osscluster.go @@ -924,6 +924,9 @@ type ClusterClient struct { // NewClusterClient returns a Redis Cluster client as described in // http://redis.io/topics/cluster-spec. func NewClusterClient(opt *ClusterOptions) *ClusterClient { + if opt == nil { + panic("redis: NewClusterClient nil options") + } opt.init() c := &ClusterClient{ diff --git a/redis.go b/redis.go index e0159294dd..f50df5689b 100644 --- a/redis.go +++ b/redis.go @@ -661,6 +661,9 @@ type Client struct { // NewClient returns a client to the Redis Server specified by Options. func NewClient(opt *Options) *Client { + if opt == nil { + panic("redis: NewClient nil options") + } opt.init() c := Client{ diff --git a/redis_test.go b/redis_test.go index 7d9bf1cef9..80e28341c1 100644 --- a/redis_test.go +++ b/redis_test.go @@ -727,3 +727,54 @@ var _ = Describe("Dialer connection timeouts", func() { Expect(time.Since(start)).To(BeNumerically("<", 2*dialSimulatedDelay)) }) }) +var _ = Describe("Client creation", func() { + Context("simple client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewClient(nil) + }).To(Panic()) + }) + }) + Context("cluster client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewClusterClient(nil) + }).To(Panic()) + }) + }) + Context("ring client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewRing(nil) + }).To(Panic()) + }) + }) + Context("universal client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewUniversalClient(nil) + }).To(Panic()) + }) + }) + Context("failover client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewFailoverClient(nil) + }).To(Panic()) + }) + }) + Context("failover cluster client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewFailoverClusterClient(nil) + }).To(Panic()) + }) + }) + Context("sentinel client with nil options", func() { + It("panics", func() { + Expect(func() { + redis.NewSentinelClient(nil) + }).To(Panic()) + }) + }) +}) diff --git a/ring.go b/ring.go index 8f2dd3c401..555ea2a163 100644 --- a/ring.go +++ b/ring.go @@ -523,6 +523,9 @@ type Ring struct { } func NewRing(opt *RingOptions) *Ring { + if opt == nil { + panic("redis: NewRing nil options") + } opt.init() hbCtx, hbCancel := context.WithCancel(context.Background()) diff --git a/sentinel.go b/sentinel.go index f5b9a52d10..cfc848cf0c 100644 --- a/sentinel.go +++ b/sentinel.go @@ -224,6 +224,10 @@ func (opt *FailoverOptions) clusterOptions() *ClusterOptions { // for automatic failover. It's safe for concurrent use by multiple // goroutines. func NewFailoverClient(failoverOpt *FailoverOptions) *Client { + if failoverOpt == nil { + panic("redis: NewFailoverClient nil options") + } + if failoverOpt.RouteByLatency { panic("to route commands by latency, use NewFailoverClusterClient") } @@ -313,6 +317,9 @@ type SentinelClient struct { } func NewSentinelClient(opt *Options) *SentinelClient { + if opt == nil { + panic("redis: NewSentinelClient nil options") + } opt.init() c := &SentinelClient{ baseClient: &baseClient{ @@ -828,6 +835,10 @@ func contains(slice []string, str string) bool { // NewFailoverClusterClient returns a client that supports routing read-only commands // to a replica node. func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient { + if failoverOpt == nil { + panic("redis: NewFailoverClusterClient nil options") + } + sentinelAddrs := make([]string, len(failoverOpt.SentinelAddrs)) copy(sentinelAddrs, failoverOpt.SentinelAddrs) diff --git a/universal.go b/universal.go index 46d5640da6..a1ce17bac3 100644 --- a/universal.go +++ b/universal.go @@ -267,6 +267,10 @@ var ( // a ClusterClient is returned. // 4. Otherwise, a single-node Client is returned. func NewUniversalClient(opts *UniversalOptions) UniversalClient { + if opts == nil { + panic("redis: NewUniversalClient nil options") + } + switch { case opts.MasterName != "" && (opts.RouteByLatency || opts.RouteRandomly || opts.IsClusterMode): return NewFailoverClusterClient(opts.Failover()) From 46ede21fdbe2ab00aff180ab14f2a490996c81ba Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Tue, 29 Apr 2025 18:33:31 +0300 Subject: [PATCH 27/27] chore(release): Update version to v9.8.0 - update version in relevant places - add RELEASE-NOTES.md to keep track of release notes --- .github/workflows/build.yml | 4 +- .github/workflows/codeql-analysis.yml | 5 +- .github/workflows/golangci-lint.yml | 1 + .github/workflows/test-redis-enterprise.yml | 2 +- RELEASE-NOTES.md | 80 +++++++++++++++++++++ example/del-keys-without-ttl/go.mod | 2 +- example/hll/go.mod | 2 +- example/hset-struct/go.mod | 2 +- example/lua-scripting/go.mod | 2 +- example/otel/go.mod | 6 +- example/redis-bloom/go.mod | 2 +- example/scan-struct/go.mod | 2 +- extra/rediscensus/go.mod | 4 +- extra/rediscmd/go.mod | 2 +- extra/redisotel/go.mod | 4 +- extra/redisprometheus/go.mod | 2 +- version.go | 2 +- 17 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 RELEASE-NOTES.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 810ab50979..a58ebb9c8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,9 @@ name: Go on: push: - branches: [master, v9, v9.7] + branches: [master, v9, v9.7, v9.8] pull_request: - branches: [master, v9, v9.7] + branches: [master, v9, v9.7, v9.8] permissions: contents: read diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c4b558f37a..1a803d373b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,9 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [master, v9, v9.7, v9.8] pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] + branches: [master, v9, v9.7, v9.8] jobs: analyze: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 5e0ac1d0ac..db6e5c4a53 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -8,6 +8,7 @@ on: - master - main - v9 + - v9.8 pull_request: permissions: diff --git a/.github/workflows/test-redis-enterprise.yml b/.github/workflows/test-redis-enterprise.yml index 459b2edf00..47de6478cb 100644 --- a/.github/workflows/test-redis-enterprise.yml +++ b/.github/workflows/test-redis-enterprise.yml @@ -2,7 +2,7 @@ name: RE Tests on: push: - branches: [master] + branches: [master, v9, v9.7, v9.8] pull_request: permissions: diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md new file mode 100644 index 0000000000..fa106cb924 --- /dev/null +++ b/RELEASE-NOTES.md @@ -0,0 +1,80 @@ +# Release Notes + +# 9.8.0 (2025-04-30) + +## 🚀 Highlights +- **Redis 8 Support**: Full compatibility with Redis 8.0, including testing and CI integration +- **Enhanced Hash Operations**: Added support for new hash commands (`HGETDEL`, `HGETEX`, `HSETEX`) and `HSTRLEN` command +- **Search Improvements**: Enabled Search DIALECT 2 by default and added `CountOnly` argument for `FT.Search` + +## ✨ New Features +- Added support for new hash commands: `HGETDEL`, `HGETEX`, `HSETEX` ([#3305](https://github.com/redis/go-redis/pull/3305)) +- Added `HSTRLEN` command for hash operations ([#2843](https://github.com/redis/go-redis/pull/2843)) +- Added `Do` method for raw query by single connection from `pool.Conn()` ([#3182](https://github.com/redis/go-redis/pull/3182)) +- Prevent false-positive marshaling by treating zero time.Time as empty in isEmptyValue ([#3273](https://github.com/redis/go-redis/pull/3273)) +- Added FailoverClusterClient support for Universal client ([#2794](https://github.com/redis/go-redis/pull/2794)) +- Added support for cluster mode with `IsClusterMode` config parameter ([#3255](https://github.com/redis/go-redis/pull/3255)) +- Added client name support in `HELLO` RESP handshake ([#3294](https://github.com/redis/go-redis/pull/3294)) +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Added read-only option for failover configurations ([#3281](https://github.com/redis/go-redis/pull/3281)) +- Added `CountOnly` argument for `FT.Search` to use `LIMIT 0 0` ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Added `DB` option support in `NewFailoverClusterClient` ([#3342](https://github.com/redis/go-redis/pull/3342)) +- Added `nil` check for the options when creating a client ([#3363](https://github.com/redis/go-redis/pull/3363)) + +## 🐛 Bug Fixes +- Fixed `PubSub` concurrency safety issues ([#3360](https://github.com/redis/go-redis/pull/3360)) +- Fixed panic caused when argument is `nil` ([#3353](https://github.com/redis/go-redis/pull/3353)) +- Improved error handling when fetching master node from sentinels ([#3349](https://github.com/redis/go-redis/pull/3349)) +- Fixed connection pool timeout issues and increased retries ([#3298](https://github.com/redis/go-redis/pull/3298)) +- Fixed context cancellation error leading to connection spikes on Primary instances ([#3190](https://github.com/redis/go-redis/pull/3190)) +- Fixed RedisCluster client to consider `MASTERDOWN` a retriable error ([#3164](https://github.com/redis/go-redis/pull/3164)) +- Fixed tracing to show complete commands instead of truncated versions ([#3290](https://github.com/redis/go-redis/pull/3290)) +- Fixed OpenTelemetry instrumentation to prevent multiple span reporting ([#3168](https://github.com/redis/go-redis/pull/3168)) +- Fixed `FT.Search` Limit argument and added `CountOnly` argument for limit 0 0 ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Fixed missing command in interface ([#3344](https://github.com/redis/go-redis/pull/3344)) +- Fixed slot calculation for `COUNTKEYSINSLOT` command ([#3327](https://github.com/redis/go-redis/pull/3327)) +- Updated PubSub implementation with correct context ([#3329](https://github.com/redis/go-redis/pull/3329)) + +## 📚 Documentation +- Added hash search examples ([#3357](https://github.com/redis/go-redis/pull/3357)) +- Fixed documentation comments ([#3351](https://github.com/redis/go-redis/pull/3351)) +- Added `CountOnly` search example ([#3345](https://github.com/redis/go-redis/pull/3345)) +- Added examples for list commands: `LLEN`, `LPOP`, `LPUSH`, `LRANGE`, `RPOP`, `RPUSH` ([#3234](https://github.com/redis/go-redis/pull/3234)) +- Added `SADD` and `SMEMBERS` command examples ([#3242](https://github.com/redis/go-redis/pull/3242)) +- Updated `README.md` to use Redis Discord guild ([#3331](https://github.com/redis/go-redis/pull/3331)) +- Updated `HExpire` command documentation ([#3355](https://github.com/redis/go-redis/pull/3355)) +- Featured OpenTelemetry instrumentation more prominently ([#3316](https://github.com/redis/go-redis/pull/3316)) +- Updated `README.md` with additional information ([#310ce55](https://github.com/redis/go-redis/commit/310ce55)) + +## ⚡ Performance and Reliability +- Bound connection pool background dials to configured dial timeout ([#3089](https://github.com/redis/go-redis/pull/3089)) +- Ensured context isn't exhausted via concurrent query ([#3334](https://github.com/redis/go-redis/pull/3334)) + +## 🔧 Dependencies and Infrastructure +- Updated testing image to Redis 8.0-RC2 ([#3361](https://github.com/redis/go-redis/pull/3361)) +- Enabled CI for Redis CE 8.0 ([#3274](https://github.com/redis/go-redis/pull/3274)) +- Updated various dependencies: + - Bumped golangci/golangci-lint-action from 6.5.0 to 7.0.0 ([#3354](https://github.com/redis/go-redis/pull/3354)) + - Bumped rojopolis/spellcheck-github-actions ([#3336](https://github.com/redis/go-redis/pull/3336)) + - Bumped golang.org/x/net in example/otel ([#3308](https://github.com/redis/go-redis/pull/3308)) +- Migrated golangci-lint configuration to v2 format ([#3354](https://github.com/redis/go-redis/pull/3354)) + +## ⚠️ Breaking Changes +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Dropped RedisGears (Triggers and Functions) support ([#3321](https://github.com/redis/go-redis/pull/3321)) +- Dropped FT.PROFILE command that was never enabled ([#3323](https://github.com/redis/go-redis/pull/3323)) + +## 🔒 Security +- Fixed network error handling on SETINFO (CVE-2025-29923) ([#3295](https://github.com/redis/go-redis/pull/3295)) + +## 🧪 Testing +- Added integration tests for Redis 8 behavior changes in Redis Search ([#3337](https://github.com/redis/go-redis/pull/3337)) +- Added vector types INT8 and UINT8 tests ([#3299](https://github.com/redis/go-redis/pull/3299)) +- Added test codes for search_commands.go ([#3285](https://github.com/redis/go-redis/pull/3285)) +- Fixed example test sorting ([#3292](https://github.com/redis/go-redis/pull/3292)) + +## 👥 Contributors + +We would like to thank all the contributors who made this release possible: + +[@alexander-menshchikov](https://github.com/alexander-menshchikov), [@EXPEbdodla](https://github.com/EXPEbdodla), [@afti](https://github.com/afti), [@dmaier-redislabs](https://github.com/dmaier-redislabs), [@four_leaf_clover](https://github.com/four_leaf_clover), [@alohaglenn](https://github.com/alohaglenn), [@gh73962](https://github.com/gh73962), [@justinmir](https://github.com/justinmir), [@LINKIWI](https://github.com/LINKIWI), [@liushuangbill](https://github.com/liushuangbill), [@golang88](https://github.com/golang88), [@gnpaone](https://github.com/gnpaone), [@ndyakov](https://github.com/ndyakov), [@nikolaydubina](https://github.com/nikolaydubina), [@oleglacto](https://github.com/oleglacto), [@andy-stark-redis](https://github.com/andy-stark-redis), [@rodneyosodo](https://github.com/rodneyosodo), [@dependabot](https://github.com/dependabot), [@rfyiamcool](https://github.com/rfyiamcool), [@frankxjkuang](https://github.com/frankxjkuang), [@fukua95](https://github.com/fukua95), [@soleymani-milad](https://github.com/soleymani-milad), [@ofekshenawa](https://github.com/ofekshenawa), [@khasanovbi](https://github.com/khasanovbi) diff --git a/example/del-keys-without-ttl/go.mod b/example/del-keys-without-ttl/go.mod index 727fbbd7fd..6d731f370d 100644 --- a/example/del-keys-without-ttl/go.mod +++ b/example/del-keys-without-ttl/go.mod @@ -5,7 +5,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. require ( - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0 go.uber.org/zap v1.24.0 ) diff --git a/example/hll/go.mod b/example/hll/go.mod index 775e3e7b19..28edd2caf2 100644 --- a/example/hll/go.mod +++ b/example/hll/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.8.0-beta.1 +require github.com/redis/go-redis/v9 v9.8.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/hset-struct/go.mod b/example/hset-struct/go.mod index 33d3ef6d80..c10579f13e 100644 --- a/example/hset-struct/go.mod +++ b/example/hset-struct/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/davecgh/go-spew v1.1.1 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0 ) require ( diff --git a/example/lua-scripting/go.mod b/example/lua-scripting/go.mod index 363c93c294..50964fa8ea 100644 --- a/example/lua-scripting/go.mod +++ b/example/lua-scripting/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.8.0-beta.1 +require github.com/redis/go-redis/v9 v9.8.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/otel/go.mod b/example/otel/go.mod index 5a060d99a7..da45631037 100644 --- a/example/otel/go.mod +++ b/example/otel/go.mod @@ -11,8 +11,8 @@ replace github.com/redis/go-redis/extra/redisotel/v9 => ../../extra/redisotel replace github.com/redis/go-redis/extra/rediscmd/v9 => ../../extra/rediscmd require ( - github.com/redis/go-redis/extra/redisotel/v9 v9.8.0-beta.1 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/extra/redisotel/v9 v9.8.0 + github.com/redis/go-redis/v9 v9.8.0 github.com/uptrace/uptrace-go v1.21.0 go.opentelemetry.io/otel v1.22.0 ) @@ -25,7 +25,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.46.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect diff --git a/example/redis-bloom/go.mod b/example/redis-bloom/go.mod index 3d7a4caaa8..86f25db7b6 100644 --- a/example/redis-bloom/go.mod +++ b/example/redis-bloom/go.mod @@ -4,7 +4,7 @@ go 1.18 replace github.com/redis/go-redis/v9 => ../.. -require github.com/redis/go-redis/v9 v9.8.0-beta.1 +require github.com/redis/go-redis/v9 v9.8.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/example/scan-struct/go.mod b/example/scan-struct/go.mod index 33d3ef6d80..c10579f13e 100644 --- a/example/scan-struct/go.mod +++ b/example/scan-struct/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/davecgh/go-spew v1.1.1 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0 ) require ( diff --git a/extra/rediscensus/go.mod b/extra/rediscensus/go.mod index b39f7dd4c4..65499e2c7c 100644 --- a/extra/rediscensus/go.mod +++ b/extra/rediscensus/go.mod @@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../.. replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd require ( - github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0 + github.com/redis/go-redis/v9 v9.8.0 go.opencensus.io v0.24.0 ) diff --git a/extra/rediscmd/go.mod b/extra/rediscmd/go.mod index 93cc423dba..20c78b9d15 100644 --- a/extra/rediscmd/go.mod +++ b/extra/rediscmd/go.mod @@ -7,7 +7,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/bsm/ginkgo/v2 v2.12.0 github.com/bsm/gomega v1.27.10 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0 ) require ( diff --git a/extra/redisotel/go.mod b/extra/redisotel/go.mod index c5b29dffa1..c9c63427b5 100644 --- a/extra/redisotel/go.mod +++ b/extra/redisotel/go.mod @@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../.. replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd require ( - github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0-beta.1 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/extra/rediscmd/v9 v9.8.0 + github.com/redis/go-redis/v9 v9.8.0 go.opentelemetry.io/otel v1.22.0 go.opentelemetry.io/otel/metric v1.22.0 go.opentelemetry.io/otel/sdk v1.22.0 diff --git a/extra/redisprometheus/go.mod b/extra/redisprometheus/go.mod index c934767e05..25193e4655 100644 --- a/extra/redisprometheus/go.mod +++ b/extra/redisprometheus/go.mod @@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../.. require ( github.com/prometheus/client_golang v1.14.0 - github.com/redis/go-redis/v9 v9.8.0-beta.1 + github.com/redis/go-redis/v9 v9.8.0 ) require ( diff --git a/version.go b/version.go index b547951687..c56e04ff14 100644 --- a/version.go +++ b/version.go @@ -2,5 +2,5 @@ package redis // Version is the current release version. func Version() string { - return "9.8.0-beta.1" + return "9.8.0" }