From 5efda85ca7d3e72cbfbaa988bfdc5cef337715aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 00:42:33 +0000 Subject: [PATCH] chore(deps): bump github.com/redis/go-redis/v9 from 9.19.0 to 9.20.0 Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.19.0 to 9.20.0. - [Release notes](https://github.com/redis/go-redis/releases) - [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md) - [Commits](https://github.com/redis/go-redis/compare/v9.19.0...v9.20.0) --- updated-dependencies: - dependency-name: github.com/redis/go-redis/v9 dependency-version: 9.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 5 +- vendor/github.com/redis/go-redis/v9/Makefile | 2 +- vendor/github.com/redis/go-redis/v9/README.md | 33 +- .../redis/go-redis/v9/RELEASE-NOTES.md | 84 ++ .../redis/go-redis/v9/array_commands.go | 387 ++++++++ .../github.com/redis/go-redis/v9/command.go | 703 ++++++++++++++- .../github.com/redis/go-redis/v9/commands.go | 1 + .../redis/go-redis/v9/docker-compose.yml | 2 +- vendor/github.com/redis/go-redis/v9/error.go | 14 +- .../go-redis/v9/internal/hashtag/hashtag.go | 3 +- .../redis/go-redis/v9/internal/internal.go | 3 +- .../redis/go-redis/v9/internal/pool/conn.go | 6 +- .../redis/go-redis/v9/internal/pool/pool.go | 22 +- .../redis/go-redis/v9/internal/rand/rand.go | 50 -- .../redis/go-redis/v9/internal/util.go | 24 + vendor/github.com/redis/go-redis/v9/json.go | 47 +- .../github.com/redis/go-redis/v9/options.go | 5 +- .../redis/go-redis/v9/osscluster.go | 80 +- .../redis/go-redis/v9/osscluster_router.go | 28 +- vendor/github.com/redis/go-redis/v9/pubsub.go | 9 + vendor/github.com/redis/go-redis/v9/redis.go | 21 +- vendor/github.com/redis/go-redis/v9/ring.go | 16 +- .../redis/go-redis/v9/search_commands.go | 823 ++++++++++++++++-- .../github.com/redis/go-redis/v9/sentinel.go | 24 +- .../redis/go-redis/v9/sortedset_commands.go | 2 +- .../redis/go-redis/v9/stream_commands.go | 74 ++ .../redis/go-redis/v9/string_commands.go | 128 +++ .../redis/go-redis/v9/timeseries_commands.go | 209 ++++- .../github.com/redis/go-redis/v9/universal.go | 3 + .../redis/go-redis/v9/vectorset_commands.go | 77 +- .../github.com/redis/go-redis/v9/version.go | 2 +- vendor/modules.txt | 3 +- 33 files changed, 2633 insertions(+), 259 deletions(-) create mode 100644 vendor/github.com/redis/go-redis/v9/array_commands.go delete mode 100644 vendor/github.com/redis/go-redis/v9/internal/rand/rand.go diff --git a/go.mod b/go.mod index c9dc478b8..c9a3d53f8 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/coreos/go-systemd/v22 v22.7.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/redis/go-redis/v9 v9.19.0 + github.com/redis/go-redis/v9 v9.20.0 github.com/testcontainers/testcontainers-go v0.42.0 go.etcd.io/etcd/api/v3 v3.6.11 go.yaml.in/yaml/v3 v3.0.4 diff --git a/go.sum b/go.sum index 5010a799d..0d068a9ef 100644 --- a/go.sum +++ b/go.sum @@ -325,8 +325,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= -github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -473,6 +473,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= diff --git a/vendor/github.com/redis/go-redis/v9/Makefile b/vendor/github.com/redis/go-redis/v9/Makefile index 098ff3cfa..90f03b57e 100644 --- a/vendor/github.com/redis/go-redis/v9/Makefile +++ b/vendor/github.com/redis/go-redis/v9/Makefile @@ -2,7 +2,7 @@ GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort) REDIS_VERSION ?= 8.8 RE_CLUSTER ?= false RCE_DOCKER ?= true -CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.8-m02 +CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.8.0 docker.start: export RE_CLUSTER=$(RE_CLUSTER) && \ diff --git a/vendor/github.com/redis/go-redis/v9/README.md b/vendor/github.com/redis/go-redis/v9/README.md index 3ac008f03..ae90d2b7d 100644 --- a/vendor/github.com/redis/go-redis/v9/README.md +++ b/vendor/github.com/redis/go-redis/v9/README.md @@ -18,15 +18,24 @@ In `go-redis` we are aiming to support the last three releases of Redis. Currently, this means we do support: - [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0 -- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2 +- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2 - [Redis 8.4](https://raw.githubusercontent.com/redis/redis/8.4/00-RELEASENOTES) - using Redis CE 8.4 +- [Redis 8.8](https://raw.githubusercontent.com/redis/redis/8.8/00-RELEASENOTES) - using Redis CE 8.8 -Although the `go.mod` states it requires at minimum `go 1.24`, our CI is configured to run the tests against all three +Although the `go.mod` states it requires at minimum `go 1.24`, our CI is configured to run the tests against all supported versions of Redis and multiple versions of Go ([1.24](https://go.dev/doc/devel/release#go1.24.0), oldstable, and stable). We observe that some modules related test may not pass with Redis Stack 7.2 and some commands are changed with Redis CE 8.0. Although it is not officially supported, `go-redis/v9` should be able to work with any Redis 7.0+. Please do refer to the documentation and the tests if you experience any issues. +### Array data type (Redis 8.8+) + +Starting with Redis 8.8, go-redis exposes the new array data type via the `AR*` command family +(`ARSET`, `ARGET`, `ARGETRANGE`, `ARMSET`, `ARMGET`, `ARINSERT`, `ARDEL`, `ARDELRANGE`, +`ARLEN`, `ARCOUNT`, `ARNEXT`, `ARSEEK`, `ARSCAN`, `ARGREP`, `ARRING`, `ARLASTITEMS`, +`ARINFO`/`ARINFOFULL`, and the `AROP*` reducers). See `array_commands.go` for the full +surface. The API is experimental and may change in a future release. + ## How do I Redis? [Learn for free at Redis University](https://university.redis.com/) @@ -48,6 +57,7 @@ Please do refer to the documentation and the tests if you experience any issues. - [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) +- [Release notes](./RELEASE-NOTES.md) ([GitHub Releases](https://github.com/redis/go-redis/releases)) ## old documentation @@ -355,18 +365,17 @@ rdb := redis.NewClient(&redis.Options{ }) ``` -#### Unstable RESP3 Structures for RediSearch Commands -When integrating Redis with application functionalities using RESP3, it's important to note that some response structures aren't final yet. This is especially true for more complex structures like search and query results. We recommend using RESP2 when using the search and query capabilities, but we plan to stabilize the RESP3-based API-s in the coming versions. You can find more guidance in the upcoming release notes. +#### RESP3 for RediSearch Commands (`UnstableResp3` is deprecated) +As of v9.20, `FT.SEARCH`, `FT.AGGREGATE`, `FT.INFO`, `FT.SPELLCHECK`, and `FT.SYNDUMP` +parse RESP3 (map) responses into the same typed result objects as RESP2. **No flag +is required โ€” `Val()` / `Result()` work uniformly on both protocols.** -To enable unstable RESP3, set the option in your client configuration: +The legacy `UnstableResp3` option is now a **no-op** and is retained on every +options struct only for backwards compatibility. It will be removed in a future +release; new code should not set it. -```go -redis.NewClient(&redis.Options{ - UnstableResp3: true, - }) -``` -**Note:** When UnstableResp3 mode is enabled, it's necessary to use RawResult() and RawVal() to retrieve a raw data. - Since, raw response is the only option for unstable search commands Val() and Result() calls wouldn't have any affect on them: +`RawResult()` / `RawVal()` continue to work for callers that prefer the raw RESP +payload directly: ```go res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawResult() diff --git a/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md b/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md index 927d045f1..d9be95b09 100644 --- a/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md +++ b/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md @@ -1,5 +1,89 @@ # Release Notes +# 9.20.0 (2026-05-28) + +## ๐Ÿš€ Highlights + +### Redis 8.8 Support + +This release adds support for **Redis 8.8**. The README's supported-versions list now includes Redis 8.8 alongside 8.0/8.2/8.4, and CI exercises the `8.8-rc1` client-libs-test image across the full suite (Makefile, build workflow, doctests, run-tests action, and docker-compose). + +Coverage for the new commands that ship in the 8.x line, rounded out in this release: + +- **`AR*` array data type** ([#3813](https://github.com/redis/go-redis/pull/3813)) โ€” new array data structure, exposed via the `ArrayCmdable` interface (see the experimental-features highlight below). +- **`INCREX`** ([#3816](https://github.com/redis/go-redis/pull/3816)) โ€” atomic increment with expiration in a single round-trip. +- **`XNACK`** ([#3790](https://github.com/redis/go-redis/pull/3790)) โ€” explicit negative-acknowledge of pending stream entries. +- **`XAUTOCLAIM` PEL deletes** ([#3798](https://github.com/redis/go-redis/pull/3798)) โ€” `XAUTOCLAIM`/`XAUTOCLAIMJUSTID` now return the list of deleted message IDs from the pending entries list. +- **`TS.RANGE` multiple aggregators** ([#3791](https://github.com/redis/go-redis/pull/3791)) โ€” `TS.RANGE`/`TS.REVRANGE`/`TS.MRANGE`/`TS.MREVRANGE` accept multiple aggregators in a single call. +- **`Z(UNION|INTER|DIFF)` `COUNT` aggregator** ([#3802](https://github.com/redis/go-redis/pull/3802)) โ€” `COUNT` reducer for sorted-set set operations. +- **`JSON.SET FPHA`** ([#3797](https://github.com/redis/go-redis/pull/3797)) โ€” new `FPHA` argument that specifies the floating-point type for homogeneous FP arrays. + +CI image bump ([#3814](https://github.com/redis/go-redis/pull/3814)) by [@ofekshenawa](https://github.com/ofekshenawa). Command coverage contributions by [@cxljs](https://github.com/cxljs), [@elena-kolevska](https://github.com/elena-kolevska), [@Khukharr](https://github.com/Khukharr), [@ndyakov](https://github.com/ndyakov), and [@ofekshenawa](https://github.com/ofekshenawa). + +### Stable RESP3 for RediSearch (`UnstableResp3` deprecated) + +`FT.SEARCH`, `FT.AGGREGATE`, `FT.INFO`, `FT.SPELLCHECK`, and `FT.SYNDUMP` now parse RESP3 (map) responses into the same typed result objects as RESP2 โ€” `Val()` and `Result()` work uniformly on both protocols, no flag required. Previously, RESP3 search responses required `UnstableResp3: true` and were returned as opaque maps accessible only via `RawResult()` / `RawVal()`. + +As a result, the `UnstableResp3` option is now a **no-op** across every options struct (`Options`, `ClusterOptions`, `UniversalOptions`, `FailoverOptions`, `RingOptions`) and has been marked `// Deprecated:`. The field is retained for backwards compatibility โ€” existing code that sets `UnstableResp3: true` will continue to compile and behave identically โ€” but it will be removed in a future release and new code should not set it. `RawResult()` / `RawVal()` continue to work for callers that prefer the raw RESP payload. + +([#3741](https://github.com/redis/go-redis/pull/3741)) by [@ndyakov](https://github.com/ndyakov) + +### Experimental Array Data Structure Commands + +Adds an experimental `ArrayCmdable` interface with the `AR*` command family (`ARSet`, `ARGet`, `ARGetRange`, `ARMSet`, `ARMGet`, `ARDel`, `ARDelRange`, `ARScan`, `ARSeek`, `ARNext`, `ARLastItems`, `ARGrep`, `ARGrepWithValues`, `ARInfo`/`ARInfoFull`, and typed reducers `AROpSum`/`AROpMin`/`AROpMax`/`AROpAnd`/`AROpOr`/`AROpXor`/`AROpMatch`/`AROpUsed`) for working with Redis 8.8's new array data type. **API is experimental and may change in a future release.** + +([#3813](https://github.com/redis/go-redis/pull/3813)) by [@cxljs](https://github.com/cxljs) + +## โœจ New Features + +- **RESP3 search parser**: First-class RESP3 parsing for `FT.SEARCH`/`FT.AGGREGATE`/`FT.INFO`/`FT.SPELLCHECK`/`FT.SYNDUMP` responses with backwards compatibility for RESP2 ([#3741](https://github.com/redis/go-redis/pull/3741)) by [@ndyakov](https://github.com/ndyakov) +- **INCREX**: New `INCREX` command support โ€” atomic increment with expiration ([#3816](https://github.com/redis/go-redis/pull/3816)) by [@ndyakov](https://github.com/ndyakov) +- **XNACK**: Client support for the `XNACK` stream command for explicitly negative-acknowledging pending entries ([#3790](https://github.com/redis/go-redis/pull/3790)) by [@elena-kolevska](https://github.com/elena-kolevska) +- **TS range multiple aggregators**: `TS.RANGE`/`TS.REVRANGE`/`TS.MRANGE`/`TS.MREVRANGE` now accept multiple aggregators in a single call ([#3791](https://github.com/redis/go-redis/pull/3791)) by [@elena-kolevska](https://github.com/elena-kolevska) +- **`XAutoClaim` deleted IDs**: `XAUTOCLAIM`/`XAUTOCLAIMJUSTID` now return the list of deleted message IDs from the PEL ([#3798](https://github.com/redis/go-redis/pull/3798)) by [@Khukharr](https://github.com/Khukharr) +- **`JSON.SET FPHA`**: `JSON.SET` accepts a new `FPHA` argument that specifies the floating-point type for homogeneous floating-point arrays ([#3797](https://github.com/redis/go-redis/pull/3797)) by [@ndyakov](https://github.com/ndyakov) +- **Sorted-set union/intersection COUNT**: `ZUNION`/`ZINTER`/`ZDIFF` aggregator now supports `COUNT` ([#3802](https://github.com/redis/go-redis/pull/3802)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` vector validation**: Validates hybrid-search vector input types and adds proper typed vector parameters ([#3756](https://github.com/redis/go-redis/pull/3756)) by [@DengY11](https://github.com/DengY11) +- **Cluster pool wait stats**: `ClusterClient.PoolStats()` now accumulates `WaitCount` and `WaitDurationNs` across all node pools (previously always zero) ([#3809](https://github.com/redis/go-redis/pull/3809)) by [@LINKIWI](https://github.com/LINKIWI) + +## ๐Ÿ› Bug Fixes + +- **TLS-only Cluster PubSub**: `CLUSTER SLOTS` port-0 entries now fall back to the origin endpoint's port, fixing `dial tcp :0: connection refused` on TLS-only clusters started with `--port 0 --tls-port ` (fixes [#3726](https://github.com/redis/go-redis/issues/3726)) ([#3828](https://github.com/redis/go-redis/pull/3828)) by [@ndyakov](https://github.com/ndyakov) +- **Sharded PubSub reconnect routing**: `PubSub.conn()` now passes both regular (`c.channels`) and sharded (`c.schannels`) channels into the per-PubSub `newConn` closure. Previously, `ClusterClient.SSubscribe`-only PubSubs reconnected to a random node (because the routing closure saw an empty channel list), the `SSUBSCRIBE` was sent to the wrong shard, and the resulting `MOVED` reply was silently dropped ([#3829](https://github.com/redis/go-redis/pull/3829)) by [@ndyakov](https://github.com/ndyakov) +- **ClusterClient `Watch` retry**: User errors returned from a `Watch` callback are no longer subjected to cluster-retry classification; transient cluster errors still retry, but a callback returning e.g. `net.ErrClosed` short-circuits immediately ([#3821](https://github.com/redis/go-redis/pull/3821)) by [@obiyang](https://github.com/obiyang) +- **Sentinel concurrent-probe leak**: `MasterAddr`'s concurrent sentinel probe now closes the non-winning sentinel clients instead of leaking them ([#3827](https://github.com/redis/go-redis/pull/3827)) by [@cxljs](https://github.com/cxljs) +- **Sentinel rediscovery loop on master-only setups**: `replicaAddrs` no longer tears down the cached sentinel client when the replica list is empty, eliminating a continuous rediscovery loop on master-only Sentinel deployments that flooded logs and added per-operation latency ([#3795](https://github.com/redis/go-redis/pull/3795)) by [@shahyash2609](https://github.com/shahyash2609) +- **Pool `CloseConn` hooks**: `Pool.CloseConn` now triggers registered hooks, fixing a memory leak when connections are closed explicitly rather than via the normal removal path ([#3818](https://github.com/redis/go-redis/pull/3818)) by [@ndyakov](https://github.com/ndyakov) +- **Dial TCP error redirection**: Wrapped `dial tcp` errors are now correctly classified as redirectable so cluster routing can recover from a single unreachable node ([#3810](https://github.com/redis/go-redis/pull/3810)) by [@vladisa88](https://github.com/vladisa88) +- **Pool `Close` health checks**: `ConnPool.Close` now only runs health checks against idle connections, avoiding spurious activity on connections still in use ([#3805](https://github.com/redis/go-redis/pull/3805)) by [@ndyakov](https://github.com/ndyakov) +- **VLinks return type**: Fixed the return type of `VLINKS`/`VLINKSWITHSCORES` vector-set replies ([#3820](https://github.com/redis/go-redis/pull/3820)) by [@romanpovol](https://github.com/romanpovol) + +## ๐Ÿงช Testing & Infrastructure + +- **Flaky tests**: Stabilized several flaky tests in the sentinel and pool suites ([#3815](https://github.com/redis/go-redis/pull/3815)) by [@ndyakov](https://github.com/ndyakov) +- **Sentinel failover metric race**: Fixed a data race in the sentinel failover metric test ([#3824](https://github.com/redis/go-redis/pull/3824)) by [@cxljs](https://github.com/cxljs) +- **`waitForSentinelClusterStable` post-conditions**: The sentinel test harness now waits for replicas to be fully connected (not just present in the count) and is robust to randomized spec ordering after failover specs, eliminating an intermittent `Expected master to equal slave` flake ([#3830](https://github.com/redis/go-redis/pull/3830)) by [@ndyakov](https://github.com/ndyakov) +- **`govulncheck` workflow**: New scheduled GitHub Actions workflow runs `govulncheck` on every push, PR, and weekly, surfacing newly disclosed Go vulnerabilities even when no code changes ([#3779](https://github.com/redis/go-redis/pull/3779)) by [@solardome](https://github.com/solardome) +- **CI Redis 8.8-rc1**: CI now exercises the 8.8-rc1 Redis image ([#3814](https://github.com/redis/go-redis/pull/3814)) by [@ofekshenawa](https://github.com/ofekshenawa) + +## ๐Ÿงฐ Maintenance + +- **`Cmd.Slot()` lookup refactor**: Caches the per-command `CommandInfo` and short-circuits keyless commands before the switch dispatch, removing redundant `Peek` calls ([#3804](https://github.com/redis/go-redis/pull/3804)) by [@retr0-kernel](https://github.com/retr0-kernel) +- **stdlib `math/rand`**: Replaced `internal/rand` with `math/rand` from the standard library now that the minimum Go version is 1.24 ([#3823](https://github.com/redis/go-redis/pull/3823)) by [@cxljs](https://github.com/cxljs) +- **ConnPool queue channel**: Removed the unused queue channel from `ConnPool`, trimming the pool's footprint ([#3826](https://github.com/redis/go-redis/pull/3826)) by [@cxljs](https://github.com/cxljs) +- **Extra packages LICENSE**: Added a LICENSE file to each `extra/*` package ([#3817](https://github.com/redis/go-redis/pull/3817)) by [@ndyakov](https://github.com/ndyakov) +- **README & CI image**: Documentation refresh and bumped the default CI image tag ([#3822](https://github.com/redis/go-redis/pull/3822)) by [@ndyakov](https://github.com/ndyakov) + +## ๐Ÿ‘ฅ Contributors + +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@DengY11](https://github.com/DengY11), [@elena-kolevska](https://github.com/elena-kolevska), [@Khukharr](https://github.com/Khukharr), [@LINKIWI](https://github.com/LINKIWI), [@ndyakov](https://github.com/ndyakov), [@obiyang](https://github.com/obiyang), [@ofekshenawa](https://github.com/ofekshenawa), [@retr0-kernel](https://github.com/retr0-kernel), [@romanpovol](https://github.com/romanpovol), [@shahyash2609](https://github.com/shahyash2609), [@solardome](https://github.com/solardome), [@vladisa88](https://github.com/vladisa88) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.19.0...v9.20.0 + # 9.19.0 (2026-04-27) ## ๐Ÿš€ Highlights diff --git a/vendor/github.com/redis/go-redis/v9/array_commands.go b/vendor/github.com/redis/go-redis/v9/array_commands.go new file mode 100644 index 000000000..71037dfd6 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/array_commands.go @@ -0,0 +1,387 @@ +package redis + +import ( + "context" +) + +// note: the APIs is experimental and may be subject to change. +// +// ArrayCmdable defines the interface for Redis Array data structure commands +// available in Redis 8.8.0+. +// +// Redis array supports index range [0, math.MaxUint64-1), so index parameters use uint64. +type ArrayCmdable interface { + ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd + ARGet(ctx context.Context, key string, index uint64) *StringCmd + ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd + ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd + ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd + ARInsert(ctx context.Context, key string, values ...string) *UintCmd + ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd + ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd + ARLen(ctx context.Context, key string) *UintCmd + ARCount(ctx context.Context, key string) *UintCmd + ARNext(ctx context.Context, key string) *UintCmd + ARSeek(ctx context.Context, key string, index uint64) *IntCmd + ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd + ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd + ARScan(ctx context.Context, key string, start, end uint64, args *ARScanArgs) *AREntrySliceCmd + AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd + AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd + AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd + AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd + AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd + AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd + AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd + AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd + ARGrep(ctx context.Context, key string, start, end string, args *ARGrepArgs) *UintSliceCmd + ARGrepWithValues(ctx context.Context, key string, start, end string, args *ARGrepArgs) *AREntrySliceCmd + ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd + ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd +} + +// AREntry represents an index-value pair for ARMSET. +type AREntry struct { + Index uint64 + Value string +} + +// ARRange represents a start-end range for ARDELRANGE. +type ARRange struct { + Start uint64 + End uint64 +} + +// ARScanArgs contains optional arguments for ARSCAN. +type ARScanArgs struct { + Limit uint64 +} + +// ARGrepPredicateType defines the type of predicate for ARGREP. +type ARGrepPredicateType string + +const ( + ARGrepExact ARGrepPredicateType = "EXACT" + ARGrepMatch ARGrepPredicateType = "MATCH" + ARGrepGlob ARGrepPredicateType = "GLOB" + ARGrepRegex ARGrepPredicateType = "RE" +) + +// ARGrepPredicate represents a search predicate for ARGREP. +type ARGrepPredicate struct { + Type ARGrepPredicateType + Value string +} + +// ARGrepArgs contains optional arguments for ARGREP. +// Redis ARGREP defaults to OR when multiple predicates are given. +// Set CombineAnd to true to combine predicates with AND instead. +type ARGrepArgs struct { + Predicates []ARGrepPredicate + CombineAnd bool + Limit uint64 + NoCase bool +} + +// ARSet sets one or more contiguous values starting at an index in an array. +// Returns the number of new slots that were set (previously empty). +func (c cmdable) ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd { + args := make([]any, 3, 3+len(values)) + args[0] = "arset" + args[1] = key + args[2] = index + for _, v := range values { + args = append(args, v) + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARGet gets the value at an index in an array. +// Returns redis.Nil if the key or index does not exist. +func (c cmdable) ARGet(ctx context.Context, key string, index uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arget", key, index) + _ = c(ctx, cmd) + return cmd +} + +// ARGetRange gets values in a range of indexes. +// Returns values in the range, with nil for unset indexes. +func (c cmdable) ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd { + cmd := NewSliceCmd(ctx, "argetrange", key, start, end) + _ = c(ctx, cmd) + return cmd +} + +// ARMGet gets values at multiple indexes in an array. +// Returns values at the specified indexes, with nil for unset indexes. +func (c cmdable) ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd { + args := make([]any, 2+len(indexes)) + args[0] = "armget" + args[1] = key + for i, idx := range indexes { + args[2+i] = idx + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARMSet sets multiple index-value pairs in an array. +// Returns the number of new slots that were set (previously empty). +func (c cmdable) ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd { + args := make([]any, 2, 2+2*len(members)) + args[0] = "armset" + args[1] = key + for _, m := range members { + args = append(args, m.Index, m.Value) + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARInsert inserts one or more values at consecutive indexes. +// Returns the last index where a value was inserted. +func (c cmdable) ARInsert(ctx context.Context, key string, values ...string) *UintCmd { + args := make([]any, 2, 2+len(values)) + args[0] = "arinsert" + args[1] = key + for _, v := range values { + args = append(args, v) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARDel deletes elements at the specified indexes in an array. +// Returns the number of elements deleted. +func (c cmdable) ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd { + args := make([]any, 2+len(indexes)) + args[0] = "ardel" + args[1] = key + for i, idx := range indexes { + args[2+i] = idx + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARDelRange deletes elements in one or more ranges. +// Returns the number of elements deleted. +func (c cmdable) ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd { + args := make([]any, 2, 2+2*len(ranges)) + args[0] = "ardelrange" + args[1] = key + for _, r := range ranges { + args = append(args, r.Start, r.End) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARLen returns the length of an array (max index + 1). +// Returns 0 if the key does not exist. +func (c cmdable) ARLen(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arlen", key) + _ = c(ctx, cmd) + return cmd +} + +// ARCount returns the number of non-empty elements in an array. +// Returns 0 if the key does not exist. +func (c cmdable) ARCount(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arcount", key) + _ = c(ctx, cmd) + return cmd +} + +// ARNext returns the next index ARINSERT would use. +// Returns 0 for missing keys or when no insert happened yet. +// Returns nil when the insertion cursor is exhausted / would overflow. +func (c cmdable) ARNext(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arnext", key) + _ = c(ctx, cmd) + return cmd +} + +// ARSeek sets the ARINSERT / ARRING cursor to a specific index. +// Returns 1 if the cursor was set, 0 if the key does not exist. +func (c cmdable) ARSeek(ctx context.Context, key string, index uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arseek", key, index) + _ = c(ctx, cmd) + return cmd +} + +// ARInfo returns metadata about an array. +func (c cmdable) ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd { + cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key) + _ = c(ctx, cmd) + return cmd +} + +// ARInfoFull returns detailed metadata about an array including slice statistics. +func (c cmdable) ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd { + cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key, "full") + _ = c(ctx, cmd) + return cmd +} + +// ARScan iterates existing elements in a range, returning index-value pairs. +func (c cmdable) ARScan(ctx context.Context, key string, start, end uint64, scanArgs *ARScanArgs) *AREntrySliceCmd { + args := make([]any, 4, 6) + args[0], args[1], args[2], args[3] = "arscan", key, start, end + if scanArgs != nil && scanArgs.Limit > 0 { + args = append(args, "limit", scanArgs.Limit) + } + cmd := NewAREntrySliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// AROpSum returns the sum of numeric elements in a range. +func (c cmdable) AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "SUM") + _ = c(ctx, cmd) + return cmd +} + +// AROpMin returns the minimum numeric element in a range. +func (c cmdable) AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "MIN") + _ = c(ctx, cmd) + return cmd +} + +// AROpMax returns the maximum numeric element in a range. +func (c cmdable) AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "MAX") + _ = c(ctx, cmd) + return cmd +} + +// AROpAnd returns the bitwise AND of integer elements in a range. +func (c cmdable) AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "AND") + _ = c(ctx, cmd) + return cmd +} + +// AROpOr returns the bitwise OR of integer elements in a range. +func (c cmdable) AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "OR") + _ = c(ctx, cmd) + return cmd +} + +// AROpXor returns the bitwise XOR of integer elements in a range. +func (c cmdable) AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "XOR") + _ = c(ctx, cmd) + return cmd +} + +// AROpMatch returns the count of elements matching a target string in a range. +func (c cmdable) AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "MATCH", value) + _ = c(ctx, cmd) + return cmd +} + +// AROpUsed returns the count of non-empty slots in a range. +func (c cmdable) AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "USED") + _ = c(ctx, cmd) + return cmd +} + +// ARGrep searches array elements in a range using textual predicates. +// Returns matching indexes only. Use ARGrepWithValues to also get the values. +func (c cmdable) ARGrep(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *UintSliceCmd { + args := make([]any, 4, 4+grepArgs.Len()) + args[0], args[1], args[2], args[3] = "argrep", key, start, end + args = grepArgs.Append(args) + cmd := NewUintSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARGrepWithValues searches array elements in a range using textual predicates. +// Returns matching indexes and their values as index-value pairs. +func (c cmdable) ARGrepWithValues(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *AREntrySliceCmd { + args := make([]any, 4, 5+grepArgs.Len()) + args[0], args[1], args[2], args[3] = "argrep", key, start, end + args = grepArgs.Append(args) + args = append(args, "withvalues") + cmd := NewAREntrySliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (args *ARGrepArgs) Len() int { + if args == nil { + return 0 + } + n := 2 * len(args.Predicates) + if args.CombineAnd { + n++ + } + if args.Limit > 0 { + n += 2 + } + if args.NoCase { + n++ + } + return n +} + +func (args *ARGrepArgs) Append(a []any) []any { + if args == nil { + return a + } + for _, p := range args.Predicates { + a = append(a, string(p.Type), p.Value) + } + if args.CombineAnd { + a = append(a, "and") + } + if args.Limit > 0 { + a = append(a, "limit", args.Limit) + } + if args.NoCase { + a = append(a, "nocase") + } + return a +} + +// ARRing inserts values into a ring buffer of specified size, wrapping and truncating as needed. +// Returns the last index where a value was inserted. +func (c cmdable) ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd { + args := make([]any, 3, 3+len(values)) + args[0] = "arring" + args[1] = key + args[2] = size + for _, v := range values { + args = append(args, v) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARLastItems returns the most recently inserted elements. +// When rev is true, returns items in reverse order. +func (c cmdable) ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd { + args := make([]any, 3, 4) + args[0], args[1], args[2] = "arlastitems", key, count + if rev { + args = append(args, "rev") + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/vendor/github.com/redis/go-redis/v9/command.go b/vendor/github.com/redis/go-redis/v9/command.go index 8931f81c8..ed966bf32 100644 --- a/vendor/github.com/redis/go-redis/v9/command.go +++ b/vendor/github.com/redis/go-redis/v9/command.go @@ -109,6 +109,7 @@ const ( CmdTypeXPending CmdTypeXPendingExt CmdTypeXAutoClaim + CmdTypeXAutoClaimWithDeleted CmdTypeXAutoClaimJustID CmdTypeXInfoConsumers CmdTypeXInfoGroups @@ -155,6 +156,11 @@ const ( CmdTypeTSTimestampValue CmdTypeTSTimestampValueSlice CmdTypeHotKeys + CmdTypeIncrEXInt + CmdTypeIncrEXFloat + CmdTypeUint + CmdTypeUintSlice + CmdTypeAREntrySlice ) type ( @@ -163,6 +169,12 @@ type ( start string } + CmdTypeXAutoClaimWithDeletedValue struct { + messages []XMessage + start string + deletedIDs []string + } + CmdTypeXAutoClaimJustIDValue struct { ids []string start string @@ -267,10 +279,10 @@ func writeCmd(wr *proto.Writer, cmd Cmder) error { return wr.WriteArgs(cmd.Args()) } -// cmdFirstKeyPos returns the position of the first key in the command's arguments. -// If the command does not have a key, it returns 0. -// TODO: Use the data in CommandInfo to determine the first key position. -func cmdFirstKeyPos(cmd Cmder) int { +// cmdFirstKeyPosWithInfo returns the first key position in a command's args (0 if none). +// Uses CommandInfo.FirstKeyPos when available (via cache peek, no network call), falling +// back to a hardcoded table. eval/evalsha variants are resolved from the runtime numkeys arg. +func cmdFirstKeyPosWithInfo(cmd Cmder, info *CommandInfo) int { if pos := cmd.firstKeyPos(); pos != 0 { return int(pos) } @@ -289,14 +301,20 @@ func cmdFirstKeyPos(cmd Cmder) int { } return 0 - case "publish": - return 1 case "memory": // https://github.com/redis/redis/issues/7493 if cmd.stringArg(1) == "usage" { return 2 } + // CommandInfo (if available) gives the correct answer + // otherwise the hardcoded fallback applies. + } + + // Use CommandInfo cache when warm (in-memory only, no extra round-trips). + if info != nil { + return int(info.FirstKeyPos) } + return 1 } @@ -1037,6 +1055,52 @@ func (cmd *IntCmd) Clone() Cmder { } } +type UintCmd struct { + baseCmd + + val uint64 +} + +var _ Cmder = (*UintCmd)(nil) + +func NewUintCmd(ctx context.Context, args ...any) *UintCmd { + return &UintCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeUint, + }, + } +} + +func (cmd *UintCmd) SetVal(val uint64) { + cmd.val = val +} + +func (cmd *UintCmd) Val() uint64 { + return cmd.val +} + +func (cmd *UintCmd) Result() (uint64, error) { + return cmd.val, cmd.err +} + +func (cmd *UintCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *UintCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadUint() + return err +} + +func (cmd *UintCmd) Clone() Cmder { + return &UintCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + //------------------------------------------------------------------------------ // DigestCmd is a command that returns a uint64 xxh3 hash digest. @@ -1168,6 +1232,66 @@ func (cmd *IntSliceCmd) Clone() Cmder { } } +type UintSliceCmd struct { + baseCmd + + val []uint64 +} + +var _ Cmder = (*UintSliceCmd)(nil) + +func NewUintSliceCmd(ctx context.Context, args ...any) *UintSliceCmd { + return &UintSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeUintSlice, + }, + } +} + +func (cmd *UintSliceCmd) SetVal(val []uint64) { + cmd.val = val +} + +func (cmd *UintSliceCmd) Val() []uint64 { + return cmd.val +} + +func (cmd *UintSliceCmd) Result() ([]uint64, error) { + return cmd.val, cmd.err +} + +func (cmd *UintSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *UintSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]uint64, n) + for i := range cmd.val { + if cmd.val[i], err = rd.ReadUint(); err != nil { + return err + } + } + return nil +} + +func (cmd *UintSliceCmd) Clone() Cmder { + var val []uint64 + if cmd.val != nil { + val = make([]uint64, len(cmd.val)) + copy(val, cmd.val) + } + return &UintSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + //------------------------------------------------------------------------------ type DurationCmd struct { @@ -1645,6 +1769,87 @@ func (cmd *StringSliceCmd) Clone() Cmder { //------------------------------------------------------------------------------ +// StringSliceSliceCmd returns a slice of string slices ([][]string). +// This is used for commands like VLINKS that return an array of arrays. +type StringSliceSliceCmd struct { + baseCmd + + val [][]string +} + +var _ Cmder = (*StringSliceSliceCmd)(nil) + +func NewStringSliceSliceCmd(ctx context.Context, args ...any) *StringSliceSliceCmd { + return &StringSliceSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *StringSliceSliceCmd) SetVal(val [][]string) { + cmd.val = val +} + +func (cmd *StringSliceSliceCmd) Val() [][]string { + return cmd.val +} + +func (cmd *StringSliceSliceCmd) Result() ([][]string, error) { + return cmd.val, cmd.err +} + +func (cmd *StringSliceSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *StringSliceSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([][]string, n) + for i := range n { + // Read inner array + innerN, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[i] = make([]string, innerN) + for j := range innerN { + switch s, err := rd.ReadString(); { + case err == Nil: + cmd.val[i][j] = "" + case err != nil: + return err + default: + cmd.val[i][j] = s + } + } + } + return nil +} + +func (cmd *StringSliceSliceCmd) Clone() Cmder { + var val [][]string + if cmd.val != nil { + val = make([][]string, len(cmd.val)) + for i, slice := range cmd.val { + if slice != nil { + val[i] = make([]string, len(slice)) + copy(val[i], slice) + } + } + } + return &StringSliceSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + type KeyValue struct { Key string Value string @@ -2668,9 +2873,7 @@ func (cmd *XAutoClaimCmd) readReply(rd *proto.Reader) error { } if n >= 3 { - if err := rd.DiscardNext(); err != nil { - return err - } + return rd.DiscardNext() } return nil @@ -2701,6 +2904,119 @@ func (cmd *XAutoClaimCmd) Clone() Cmder { //------------------------------------------------------------------------------ +type XAutoClaimWithDeletedCmd struct { + baseCmd + + start string + val []XMessage + deletedIDs []string +} + +var _ Cmder = (*XAutoClaimWithDeletedCmd)(nil) + +func NewXAutoClaimWithDeletedCmd(ctx context.Context, args ...interface{}) *XAutoClaimWithDeletedCmd { + return &XAutoClaimWithDeletedCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXAutoClaimWithDeleted, + }, + } +} + +func (cmd *XAutoClaimWithDeletedCmd) SetVal(val []XMessage, start string, deletedIDs []string) { + cmd.val = val + cmd.start = start + cmd.deletedIDs = deletedIDs +} + +func (cmd *XAutoClaimWithDeletedCmd) Val() (messages []XMessage, start string, deletedIDs []string) { + return cmd.val, cmd.start, cmd.deletedIDs +} + +func (cmd *XAutoClaimWithDeletedCmd) Result() (messages []XMessage, start string, deletedIDs []string, err error) { + return cmd.val, cmd.start, cmd.deletedIDs, cmd.err +} + +func (cmd *XAutoClaimWithDeletedCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *XAutoClaimWithDeletedCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + switch n { + case 2, // Redis 6 + 3: // Redis 7: + // ok + default: + return fmt.Errorf("redis: got %d elements in XAutoClaim reply, wanted 2/3", n) + } + + cmd.start, err = rd.ReadString() + if err != nil { + return err + } + + cmd.val, err = readXMessageSlice(rd) + if err != nil { + return err + } + + if n < 3 { + return nil + } + + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.deletedIDs = make([]string, nn) + for i := 0; i < nn; i++ { + cmd.deletedIDs[i], err = rd.ReadString() + if err != nil { + return err + } + } + + return nil +} + +func (cmd *XAutoClaimWithDeletedCmd) Clone() Cmder { + var val []XMessage + if cmd.val != nil { + val = make([]XMessage, len(cmd.val)) + for i, msg := range cmd.val { + val[i] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val[i].Values = make(map[string]interface{}, len(msg.Values)) + for k, v := range msg.Values { + val[i].Values[k] = v + } + } + } + } + var deletedIDs []string + if cmd.deletedIDs != nil { + deletedIDs = make([]string, len(cmd.deletedIDs)) + copy(deletedIDs, cmd.deletedIDs) + } + return &XAutoClaimWithDeletedCmd{ + baseCmd: cmd.cloneBaseCmd(), + start: cmd.start, + val: val, + deletedIDs: deletedIDs, + } +} + +//------------------------------------------------------------------------------ + type XAutoClaimJustIDCmd struct { baseCmd @@ -4778,7 +5094,7 @@ type cmdsInfoCache struct { fn func(ctx context.Context) (map[string]*CommandInfo, error) once internal.Once - refreshLock sync.Mutex + refreshLock sync.RWMutex cmds map[string]*CommandInfo } @@ -4818,9 +5134,25 @@ func (c *cmdsInfoCache) Refresh() { c.once = internal.Once{} } +// Peek returns the cached CommandInfo map without triggering a Redis round-trip. +// Returns nil when the cache is cold; callers should fall back to other heuristics. +// Note: during the very first Get() (initial population) this call will block on +// the writer lock. After that, concurrent Peek() calls do not block each other. +// The returned map and its entries MUST NOT be mutated by the caller. +func (c *cmdsInfoCache) Peek() map[string]*CommandInfo { + if c == nil { + return nil + } + c.refreshLock.RLock() + defer c.refreshLock.RUnlock() + return c.cmds +} + // ------------------------------------------------------------------------------ -const requestPolicy = "request_policy" -const responsePolicy = "response_policy" +const ( + requestPolicy = "request_policy" + responsePolicy = "response_policy" +) func parseCommandPolicies(commandInfoTips map[string]string, firstKeyPos int8) *routing.CommandPolicy { req := routing.ReqDefault @@ -7711,6 +8043,120 @@ func (cmd *VectorScoreSliceCmd) Clone() Cmder { } } +// VectorScoreSliceSliceCmd is used for VLINKS WITHSCORES which returns an array of arrays. +// In RESP3, each inner array contains maps of element -> score. +type VectorScoreSliceSliceCmd struct { + baseCmd + + val [][]VectorScore +} + +var _ Cmder = (*VectorScoreSliceSliceCmd)(nil) + +func NewVectorScoreSliceSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceSliceCmd { + return &VectorScoreSliceSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorScoreSliceSliceCmd) SetVal(val [][]VectorScore) { + cmd.val = val +} + +func (cmd *VectorScoreSliceSliceCmd) Val() [][]VectorScore { + return cmd.val +} + +func (cmd *VectorScoreSliceSliceCmd) Result() ([][]VectorScore, error) { + return cmd.val, cmd.err +} + +func (cmd *VectorScoreSliceSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreSliceSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([][]VectorScore, n) + for i := range n { + // Each level can be either a map (RESP3) or an array (RESP2) + levelTyp, err := rd.PeekReplyType() + if err != nil { + return err + } + + if levelTyp == proto.RespMap { + // RESP3 format: each level is a map {element: score, element: score, ...} + mapLen, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val[i] = make([]VectorScore, mapLen) + for j := range mapLen { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i][j] = VectorScore{Name: name, Score: score} + } + } else { + // RESP2 format: each level is an array of [element, score, element, score, ...] pairs + innerLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + if innerLen%2 != 0 { + return fmt.Errorf("redis: got %d elements in the VLINKS array, wanted a multiple of 2", innerLen) + } + + cmd.val[i] = make([]VectorScore, innerLen/2) + for j := 0; j < innerLen; j += 2 { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i][j/2] = VectorScore{Name: name, Score: score} + } + } + } + + return nil +} + +func (cmd *VectorScoreSliceSliceCmd) Clone() Cmder { + var val [][]VectorScore + if cmd.val != nil { + val = make([][]VectorScore, len(cmd.val)) + for i, slice := range cmd.val { + if slice != nil { + val[i] = make([]VectorScore, len(slice)) + copy(val[i], slice) + } + } + } + return &VectorScoreSliceSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + func readVectorAttribStringOrNil(rd *proto.Reader) (*string, error) { v, err := rd.ReadReply() if err != nil { @@ -7948,6 +8394,13 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { }); ok { return intCmd.Val(), intCmd.Err() } + case CmdTypeUint: + if uintCmd, ok := cmd.(interface { + Val() uint64 + Err() error + }); ok { + return uintCmd.Val(), uintCmd.Err() + } case CmdTypeBool: if boolCmd, ok := cmd.(interface { Val() bool @@ -8026,6 +8479,14 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { messages, start := xAutoClaimCmd.Val() return CmdTypeXAutoClaimValue{messages: messages, start: start}, xAutoClaimCmd.Err() } + case CmdTypeXAutoClaimWithDeleted: + if xAutoClaimWithDeletedCmd, ok := cmd.(interface { + Val() ([]XMessage, string, []string) + Err() error + }); ok { + messages, start, deletedIDs := xAutoClaimWithDeletedCmd.Val() + return CmdTypeXAutoClaimWithDeletedValue{messages: messages, start: start, deletedIDs: deletedIDs}, xAutoClaimWithDeletedCmd.Err() + } case CmdTypeXAutoClaimJustID: if xAutoClaimJustIDCmd, ok := cmd.(interface { Val() ([]string, string) @@ -8133,6 +8594,20 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { }); ok { return hotKeysCmd.Val(), hotKeysCmd.Err() } + case CmdTypeIncrEXInt: + if incrEXCmd, ok := cmd.(interface { + Val() IncrEXIntResult + Err() error + }); ok { + return incrEXCmd.Val(), incrEXCmd.Err() + } + case CmdTypeIncrEXFloat: + if incrEXCmd, ok := cmd.(interface { + Val() IncrEXFloatResult + Err() error + }); ok { + return incrEXCmd.Val(), incrEXCmd.Err() + } case CmdTypeKeyValues: if keyValuesCmd, ok := cmd.(interface { Val() (string, []string) @@ -8352,6 +8827,13 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { }); ok { return intSliceCmd.Val(), intSliceCmd.Err() } + case CmdTypeUintSlice: + if uintSliceCmd, ok := cmd.(interface { + Val() []uint64 + Err() error + }); ok { + return uintSliceCmd.Val(), uintSliceCmd.Err() + } case CmdTypeBoolSlice: if boolSliceCmd, ok := cmd.(interface { Val() []bool @@ -8380,6 +8862,13 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { }); ok { return keyValueSliceCmd.Val(), keyValueSliceCmd.Err() } + case CmdTypeAREntrySlice: + if arEntrySliceCmd, ok := cmd.(interface { + Val() []AREntry + Err() error + }); ok { + return arEntrySliceCmd.Val(), arEntrySliceCmd.Err() + } case CmdTypeMapStringString: if mapCmd, ok := cmd.(interface { Val() map[string]string @@ -8431,3 +8920,193 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) { // If we can't get the command type, return nil return nil, nil } + +//------------------------------------------------------------------------------ + +// IncrEXIntResult is the reply of an INCREX command issued via IncrEXInt. +// Value is the new value of the key; AppliedIncrement is the increment that +// the server actually applied (0 when an out-of-bounds operation was +// rejected, clamped when SATURATE was set). +type IncrEXIntResult struct { + Value int64 + AppliedIncrement int64 +} + +type IncrEXIntCmd struct { + baseCmd + + val IncrEXIntResult +} + +var _ Cmder = (*IncrEXIntCmd)(nil) + +func NewIncrEXIntCmd(ctx context.Context, args ...interface{}) *IncrEXIntCmd { + return &IncrEXIntCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeIncrEXInt, + }, + } +} + +func (cmd *IncrEXIntCmd) SetVal(val IncrEXIntResult) { cmd.val = val } +func (cmd *IncrEXIntCmd) Val() IncrEXIntResult { return cmd.val } +func (cmd *IncrEXIntCmd) Result() (IncrEXIntResult, error) { + return cmd.val, cmd.err +} +func (cmd *IncrEXIntCmd) String() string { return cmdString(cmd, cmd.val) } + +func (cmd *IncrEXIntCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + value, err := rd.ReadInt() + if err != nil { + return err + } + applied, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val = IncrEXIntResult{Value: value, AppliedIncrement: applied} + return nil +} + +func (cmd *IncrEXIntCmd) Clone() Cmder { + return &IncrEXIntCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +// IncrEXFloatResult is the reply of an INCREX command issued via IncrEXFloat. +type IncrEXFloatResult struct { + Value float64 + AppliedIncrement float64 +} + +type IncrEXFloatCmd struct { + baseCmd + + val IncrEXFloatResult +} + +var _ Cmder = (*IncrEXFloatCmd)(nil) + +func NewIncrEXFloatCmd(ctx context.Context, args ...interface{}) *IncrEXFloatCmd { + return &IncrEXFloatCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeIncrEXFloat, + }, + } +} + +func (cmd *IncrEXFloatCmd) SetVal(val IncrEXFloatResult) { cmd.val = val } +func (cmd *IncrEXFloatCmd) Val() IncrEXFloatResult { return cmd.val } +func (cmd *IncrEXFloatCmd) Result() (IncrEXFloatResult, error) { + return cmd.val, cmd.err +} +func (cmd *IncrEXFloatCmd) String() string { return cmdString(cmd, cmd.val) } + +func (cmd *IncrEXFloatCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + value, err := rd.ReadFloat() + if err != nil { + return err + } + applied, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val = IncrEXFloatResult{Value: value, AppliedIncrement: applied} + return nil +} + +func (cmd *IncrEXFloatCmd) Clone() Cmder { + return &IncrEXFloatCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +// AREntrySliceCmd is a command that returns index-value pairs from ARSCAN or ARGREP. +type AREntrySliceCmd struct { + baseCmd + val []AREntry +} + +var _ Cmder = (*AREntrySliceCmd)(nil) + +func NewAREntrySliceCmd(ctx context.Context, args ...any) *AREntrySliceCmd { + return &AREntrySliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeAREntrySlice, + }, + } +} + +func (cmd *AREntrySliceCmd) SetVal(val []AREntry) { + cmd.val = val +} + +func (cmd *AREntrySliceCmd) Val() []AREntry { + return cmd.val +} + +func (cmd *AREntrySliceCmd) Result() ([]AREntry, error) { + return cmd.val, cmd.err +} + +func (cmd *AREntrySliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *AREntrySliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n == 0 { + cmd.val = make([]AREntry, 0) + return nil + } + + cmd.val = make([]AREntry, n) + for i := range n { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + cmd.val[i].Index, err = rd.ReadUint() + if err != nil { + return err + } + + cmd.val[i].Value, err = rd.ReadString() + if err != nil { + return err + } + } + return nil +} + +func (cmd *AREntrySliceCmd) Clone() Cmder { + var val []AREntry + if cmd.val != nil { + val = make([]AREntry, len(cmd.val)) + copy(val, cmd.val) + } + return &AREntrySliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} diff --git a/vendor/github.com/redis/go-redis/v9/commands.go b/vendor/github.com/redis/go-redis/v9/commands.go index b3b6badc6..d347ffeb5 100644 --- a/vendor/github.com/redis/go-redis/v9/commands.go +++ b/vendor/github.com/redis/go-redis/v9/commands.go @@ -228,6 +228,7 @@ type Cmdable interface { ModuleLoadex(ctx context.Context, conf *ModuleLoadexConfig) *StringCmd ACLCmdable + ArrayCmdable BitMapCmdable ClusterCmdable GenericCmdable diff --git a/vendor/github.com/redis/go-redis/v9/docker-compose.yml b/vendor/github.com/redis/go-redis/v9/docker-compose.yml index 16005aa04..fed908bea 100644 --- a/vendor/github.com/redis/go-redis/v9/docker-compose.yml +++ b/vendor/github.com/redis/go-redis/v9/docker-compose.yml @@ -1,6 +1,6 @@ --- -x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-m02} +x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8.0} services: redis: diff --git a/vendor/github.com/redis/go-redis/v9/error.go b/vendor/github.com/redis/go-redis/v9/error.go index 6e6c27487..06ecca740 100644 --- a/vendor/github.com/redis/go-redis/v9/error.go +++ b/vendor/github.com/redis/go-redis/v9/error.go @@ -91,6 +91,14 @@ func shouldRetry(err error, retryTimeout bool) bool { return true } + // Dial errors mean TCP connection was never established โ€” safe to retry even + // when wrapped inside context.DeadlineExceeded (from DialTimeout context). + // Must be checked before the context error check below. + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + return true + } + // Check for context errors (works with wrapped errors) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false @@ -105,12 +113,6 @@ func shouldRetry(err error, retryTimeout bool) bool { // Check for timeout errors (works with wrapped errors) if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout { if hasTimeoutFlag { - // A dial error means the TCP connection was never established and the - // command was never sent to the server, so retry is always safe - var opErr *net.OpError - if errors.As(err, &opErr) && opErr.Op == "dial" { - return true - } return retryTimeout } return true diff --git a/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go b/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go index 2f81c14f1..8aa87db3d 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go +++ b/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go @@ -1,9 +1,8 @@ package hashtag import ( + "math/rand" "strings" - - "github.com/redis/go-redis/v9/internal/rand" ) const slotNumber = 16384 diff --git a/vendor/github.com/redis/go-redis/v9/internal/internal.go b/vendor/github.com/redis/go-redis/v9/internal/internal.go index e783d139a..403db56ca 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/internal.go +++ b/vendor/github.com/redis/go-redis/v9/internal/internal.go @@ -1,9 +1,8 @@ package internal import ( + "math/rand" "time" - - "github.com/redis/go-redis/v9/internal/rand" ) func RetryBackoff(retry int, minBackoff, maxBackoff time.Duration) time.Duration { diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go b/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go index 6abb58739..fab54654a 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go @@ -82,8 +82,10 @@ type Conn struct { bw *bufio.Writer wr *proto.Writer - // Lightweight mutex to protect reader operations during handoff - // Only used for the brief period during SetNetConn and HasBufferedData/PeekReplyTypeSafe + // Lightweight mutex to protect reader operations during handoff and health checks + // Used during: + // - SetNetConn (write lock for resetting reader state) + // - HasBufferedData/PeekReplyTypeSafe (read lock for safe concurrent peek operations) readerMu sync.RWMutex // State machine for connection state management diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go b/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go index 19381c313..d551fbb17 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go @@ -3,6 +3,7 @@ package pool import ( "context" "errors" + "math/rand" "net" "sync" "sync/atomic" @@ -10,7 +11,6 @@ import ( "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/proto" - "github.com/redis/go-redis/v9/internal/rand" ) // Connection close reason constants for metrics. @@ -388,7 +388,6 @@ type ConnPool struct { dialErrorsNum uint32 // atomic lastDialError atomic.Value - queue chan struct{} dialsInProgress chan struct{} dialsQueue *wantConnQueue // Fast semaphore for connection limiting with eventual fairness @@ -420,7 +419,6 @@ func NewConnPool(opt *Options) *ConnPool { p := &ConnPool{ cfg: opt, semaphore: internal.NewFastSemaphore(opt.PoolSize), - queue: make(chan struct{}, opt.PoolSize), conns: make(map[uint64]*Conn), dialsInProgress: make(chan struct{}, opt.MaxConcurrentDials), dialsQueue: newWantConnQueue(), @@ -1447,6 +1445,10 @@ func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason erro // - reason: why the connection is being closed (use CloseReason* constants) // - fromState: the metric state the connection was in (use MetricState* constants) func (p *ConnPool) CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error { + if hookManager := p.hookManager.Load(); hookManager != nil { + hookManager.ProcessOnRemove(ctx, cn, errors.New(reason)) + } + removed := p.removeConnWithLock(cn) // Only emit UpDownCounter decrements if we actually removed the connection. @@ -1616,9 +1618,19 @@ func (p *ConnPool) Close() error { // Check health before closing, since closeConn invalidates the // underlying fd and would make connCheck (inside isHealthyConn) // always fail with EBADF. - healthy := p.isHealthyConn(cn, nowNs) + // Only check health for idle connections to avoid data races when + // peeking at the socket/reader while another goroutine is reading from it. + // Non-idle connections are either in use or in transitional states and + // shouldn't be health-checked during shutdown. + _, isIdle := idleSet[cn.GetID()] + var healthy bool + if isIdle { + healthy = p.isHealthyConn(cn, nowNs) + } else { + healthy = true + } if cb != nil { - if _, isIdle := idleSet[cn.GetID()]; isIdle { + if isIdle { cb(ctx, -1, cn, "idle", false) } else { cb(ctx, -1, cn, "used", false) diff --git a/vendor/github.com/redis/go-redis/v9/internal/rand/rand.go b/vendor/github.com/redis/go-redis/v9/internal/rand/rand.go deleted file mode 100644 index 2edccba94..000000000 --- a/vendor/github.com/redis/go-redis/v9/internal/rand/rand.go +++ /dev/null @@ -1,50 +0,0 @@ -package rand - -import ( - "math/rand" - "sync" -) - -// Int returns a non-negative pseudo-random int. -func Int() int { return pseudo.Int() } - -// Intn returns, as an int, a non-negative pseudo-random number in [0,n). -// It panics if n <= 0. -func Intn(n int) int { return pseudo.Intn(n) } - -// Int63n returns, as an int64, a non-negative pseudo-random number in [0,n). -// It panics if n <= 0. -func Int63n(n int64) int64 { return pseudo.Int63n(n) } - -// Perm returns, as a slice of n ints, a pseudo-random permutation of the integers [0,n). -func Perm(n int) []int { return pseudo.Perm(n) } - -// Seed uses the provided seed value to initialize the default Source to a -// deterministic state. If Seed is not called, the generator behaves as if -// seeded by Seed(1). -func Seed(n int64) { pseudo.Seed(n) } - -var pseudo = rand.New(&source{src: rand.NewSource(1)}) - -type source struct { - src rand.Source - mu sync.Mutex -} - -func (s *source) Int63() int64 { - s.mu.Lock() - n := s.src.Int63() - s.mu.Unlock() - return n -} - -func (s *source) Seed(seed int64) { - s.mu.Lock() - s.src.Seed(seed) - s.mu.Unlock() -} - -// Shuffle pseudo-randomizes the order of elements. -// n is the number of elements. -// swap swaps the elements with indexes i and j. -func Shuffle(n int, swap func(i, j int)) { pseudo.Shuffle(n, swap) } diff --git a/vendor/github.com/redis/go-redis/v9/internal/util.go b/vendor/github.com/redis/go-redis/v9/internal/util.go index f77775ff4..00516075b 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/util.go +++ b/vendor/github.com/redis/go-redis/v9/internal/util.go @@ -2,6 +2,7 @@ package internal import ( "context" + "math" "net" "strconv" "strings" @@ -10,6 +11,29 @@ import ( "github.com/redis/go-redis/v9/internal/util" ) +// String representations of special float values. +// Values are lowercase for consistency with Redis RESP2 protocol responses. +const ( + NaN = "nan" // Not a Number + Inf = "inf" // Positive infinity + NInf = "-inf" // Negative infinity +) + +// FormatFloat formats a float64 to string, normalizing special values +// (NaN, Inf) to lowercase for consistency with Redis RESP2 protocol. +func FormatFloat(f float64) string { + switch { + case math.IsNaN(f): + return NaN + case math.IsInf(f, 1): + return Inf + case math.IsInf(f, -1): + return NInf + default: + return strconv.FormatFloat(f, 'f', -1, 64) + } +} + func Sleep(ctx context.Context, dur time.Duration) error { t := time.NewTimer(dur) defer t.Stop() diff --git a/vendor/github.com/redis/go-redis/v9/json.go b/vendor/github.com/redis/go-redis/v9/json.go index 781cc4686..2bcad0b79 100644 --- a/vendor/github.com/redis/go-redis/v9/json.go +++ b/vendor/github.com/redis/go-redis/v9/json.go @@ -35,6 +35,7 @@ type JSONCmdable interface { JSONObjLen(ctx context.Context, key, path string) *IntPointerSliceCmd JSONSet(ctx context.Context, key, path string, value interface{}) *StatusCmd JSONSetMode(ctx context.Context, key, path string, value interface{}, mode string) *StatusCmd + JSONSetWithArgs(ctx context.Context, key, path string, value interface{}, options *JSONSetArgsOptions) *StatusCmd JSONStrAppend(ctx context.Context, key, path, value string) *IntPointerSliceCmd JSONStrLen(ctx context.Context, key, path string) *IntPointerSliceCmd JSONToggle(ctx context.Context, key, path string) *IntPointerSliceCmd @@ -57,6 +58,25 @@ type JSONArrTrimArgs struct { Stop *int } +// FPHAType is the floating-point type used for storing FP homogeneous arrays +// in JSON.SET (Redis 8.8+). +type FPHAType string + +const ( + FPHATypeBF16 FPHAType = "BF16" + FPHATypeFP16 FPHAType = "FP16" + FPHATypeFP32 FPHAType = "FP32" + FPHATypeFP64 FPHAType = "FP64" +) + +// JSONSetArgsOptions are the optional arguments for JSONSetWithArgs. +// Mode is "NX" or "XX" (case-insensitive). FPHA, when set, forces Redis to +// store all FP homogeneous arrays using the specified floating-point type. +type JSONSetArgsOptions struct { + Mode string + FPHA FPHAType +} + type JSONCmd struct { baseCmd val string @@ -584,6 +604,15 @@ func (c cmdable) JSONSet(ctx context.Context, key, path string, value interface{ // the argument is a string or []byte when we assume that it can be passed directly as JSON. // For more information, see https://redis.io/commands/json.set func (c cmdable) JSONSetMode(ctx context.Context, key, path string, value interface{}, mode string) *StatusCmd { + return c.JSONSetWithArgs(ctx, key, path, value, &JSONSetArgsOptions{Mode: mode}) +} + +// JSONSetWithArgs sets the JSON value at the given path in the given key with optional arguments +// for setting mode (NX/XX) and the FPHA (Floating-Point Homogeneous Array) type used for storing +// FP arrays. The value must be something that can be marshaled to JSON (using encoding/JSON) unless +// the argument is a string or []byte when we assume that it can be passed directly as JSON. +// For more information, see https://redis.io/commands/json.set +func (c cmdable) JSONSetWithArgs(ctx context.Context, key, path string, value interface{}, options *JSONSetArgsOptions) *StatusCmd { var bytes []byte var err error switch v := value.(type) { @@ -595,13 +624,17 @@ func (c cmdable) JSONSetMode(ctx context.Context, key, path string, value interf bytes, err = json.Marshal(v) } args := []interface{}{"JSON.SET", key, path, util.BytesToString(bytes)} - if mode != "" { - switch strings.ToUpper(mode) { - case "XX", "NX": - args = append(args, strings.ToUpper(mode)) - - default: - panic("redis: JSON.SET mode must be NX or XX") + if options != nil { + if options.Mode != "" { + switch strings.ToUpper(options.Mode) { + case "XX", "NX": + args = append(args, strings.ToUpper(options.Mode)) + default: + panic("redis: JSON.SET mode must be NX or XX") + } + } + if options.FPHA != "" { + args = append(args, "FPHA", string(options.FPHA)) } } cmd := NewStatusCmd(ctx, args...) diff --git a/vendor/github.com/redis/go-redis/v9/options.go b/vendor/github.com/redis/go-redis/v9/options.go index af902feaf..ba45a0cb8 100644 --- a/vendor/github.com/redis/go-redis/v9/options.go +++ b/vendor/github.com/redis/go-redis/v9/options.go @@ -290,8 +290,9 @@ type Options struct { // IdentitySuffix - add suffix to client name. IdentitySuffix string - // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. - // When unstable mode is enabled, the client will use RESP3 protocol and only be able to use RawResult + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // Push notifications are always enabled for RESP3 connections (Protocol: 3) diff --git a/vendor/github.com/redis/go-redis/v9/osscluster.go b/vendor/github.com/redis/go-redis/v9/osscluster.go index ca72f68bf..efd52960c 100644 --- a/vendor/github.com/redis/go-redis/v9/osscluster.go +++ b/vendor/github.com/redis/go-redis/v9/osscluster.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "math" + "math/rand" "net" "net/url" "runtime" @@ -23,7 +24,6 @@ import ( "github.com/redis/go-redis/v9/internal/otel" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" - "github.com/redis/go-redis/v9/internal/rand" "github.com/redis/go-redis/v9/internal/routing" "github.com/redis/go-redis/v9/maintnotifications" "github.com/redis/go-redis/v9/push" @@ -163,7 +163,9 @@ type ClusterOptions struct { IdentitySuffix string // Add suffix to client name. Default is empty. - // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications. @@ -530,7 +532,7 @@ func (n *clusterNode) updateLatency() { if successes == 0 { // If none of the pings worked, set latency to some arbitrarily high value so this node gets // least priority. - latency = float64((maximumNodeLatency) / time.Microsecond) + latency = float64(maximumNodeLatency / time.Microsecond) } else { latency = float64(dur) / float64(successes) } @@ -816,7 +818,7 @@ func newClusterState( createdAt: time.Now(), } - originHost, _, _ := net.SplitHostPort(origin) + originHost, originPort, _ := net.SplitHostPort(origin) isLoopbackOrigin := isLoopback(originHost) for _, slot := range slots { @@ -828,6 +830,11 @@ func newClusterState( if !isLoopbackOrigin { addr = replaceLoopbackHost(addr, originHost) } + // TLS-only clusters (`--port 0 --tls-port 6379`) report port 0 + // in CLUSTER SLOTS. Fall back to the origin port โ€” by definition + // reachable, since it is the port that returned this slot map. + // See https://github.com/redis/go-redis/issues/3726. + addr = replaceZeroPort(addr, originPort) node, err := c.nodes.GetOrCreateWithNodeAddress(addr, nodeAddress) if err != nil { @@ -881,6 +888,21 @@ func replaceLoopbackHost(nodeAddr, originHost string) string { return net.JoinHostPort(originHost, nodePort) } +// replaceZeroPort substitutes originPort for a node port of "0", which is +// what CLUSTER SLOTS reports for TLS-only clusters started with +// `--port 0 --tls-port `. Non-zero ports and addresses without a +// recoverable origin port are returned unchanged. +func replaceZeroPort(nodeAddr, originPort string) string { + if originPort == "" || originPort == "0" { + return nodeAddr + } + nodeHost, nodePort, err := net.SplitHostPort(nodeAddr) + if err != nil || nodePort != "0" { + return nodeAddr + } + return net.JoinHostPort(nodeHost, originPort) +} + // isLoopback returns true if the host is a loopback address. // For IP addresses, it uses net.IP.IsLoopback(). // For hostnames, it recognizes well-known loopback hostnames like "localhost" @@ -944,7 +966,7 @@ func (c *clusterState) slotClosestNode(slot int) (*clusterNode, error) { return c.nodes.Random() } - var allNodesFailing = true + allNodesFailing := true var ( closestNonFailingNode *clusterNode closestNode *clusterNode @@ -1450,6 +1472,8 @@ func (c *ClusterClient) PoolStats() *PoolStats { acc.Hits += s.Hits acc.Misses += s.Misses acc.Timeouts += s.Timeouts + acc.WaitCount += s.WaitCount + acc.WaitDurationNs += s.WaitDurationNs acc.TotalConns += s.TotalConns acc.IdleConns += s.IdleConns @@ -1461,6 +1485,8 @@ func (c *ClusterClient) PoolStats() *PoolStats { acc.Hits += s.Hits acc.Misses += s.Misses acc.Timeouts += s.Timeouts + acc.WaitCount += s.WaitCount + acc.WaitDurationNs += s.WaitDurationNs acc.TotalConns += s.TotalConns acc.IdleConns += s.IdleConns @@ -1913,13 +1939,23 @@ func (c *ClusterClient) processTxPipeline(ctx context.Context, cmds []Cmder) err func (c *ClusterClient) slottedKeyedCommands(ctx context.Context, cmds []Cmder) map[int][]Cmder { cmdsSlots := map[int][]Cmder{} + // Peek once outside the loop, one RLock for the whole batch instead of + // two per command (one for the keyless check, one inside cmdSlot). + cachedInfo := c.cmdsInfoCache.Peek() + prefferedRandomSlot := -1 for _, cmd := range cmds { - if cmdFirstKeyPos(cmd) == 0 { + var info *CommandInfo + if cachedInfo != nil { + info = cachedInfo[cmd.Name()] + } + + pos := cmdFirstKeyPosWithInfo(cmd, info) + if pos == 0 { continue } - slot := c.cmdSlot(cmd, prefferedRandomSlot) + slot := c.cmdSlotWithPos(cmd, pos, prefferedRandomSlot) if prefferedRandomSlot == -1 { prefferedRandomSlot = slot } @@ -2102,10 +2138,18 @@ func (c *ClusterClient) Watch(ctx context.Context, fn func(*Tx) error, keys ...s } } - err = node.Client.Watch(ctx, fn, keys...) + // Track callback errors separately to avoid retrying user failures through cluster retry classification. + var fnErr error + err = node.Client.Watch(ctx, func(tx *Tx) error { + fnErr = fn(tx) + return fnErr + }, keys...) if err == nil { break } + if fnErr != nil { + return fnErr + } moved, ask, addr := isMovedError(err) if moved || ask { @@ -2305,13 +2349,29 @@ func (c *ClusterClient) cmdInfo(ctx context.Context, name string) *CommandInfo { return info } +// cmdInfoPeek returns the cached CommandInfo for the named command without +// triggering a round-trip to Redis. It returns nil when the cache is cold. +func (c *ClusterClient) cmdInfoPeek(name string) *CommandInfo { + if cmds := c.cmdsInfoCache.Peek(); cmds != nil { + return cmds[name] + } + return nil +} + func (c *ClusterClient) cmdSlot(cmd Cmder, prefferedSlot int) int { + info := c.cmdInfoPeek(cmd.Name()) + return c.cmdSlotWithPos(cmd, cmdFirstKeyPosWithInfo(cmd, info), prefferedSlot) +} + +// cmdSlotWithPos computes the cluster slot for cmd given a pre-resolved first key +// position. Separating pos resolution from slot computation lets callers that +// already know pos avoid a redundant Peek() call. +func (c *ClusterClient) cmdSlotWithPos(cmd Cmder, pos int, prefferedSlot int) int { args := cmd.Args() if args[0] == "cluster" && (args[1] == "getkeysinslot" || args[1] == "countkeysinslot") { return args[2].(int) } - - return cmdSlot(cmd, cmdFirstKeyPos(cmd), prefferedSlot) + return cmdSlot(cmd, pos, prefferedSlot) } func cmdSlot(cmd Cmder, pos int, prefferedRandomSlot int) int { diff --git a/vendor/github.com/redis/go-redis/v9/osscluster_router.go b/vendor/github.com/redis/go-redis/v9/osscluster_router.go index 3b001fef7..0da29530a 100644 --- a/vendor/github.com/redis/go-redis/v9/osscluster_router.go +++ b/vendor/github.com/redis/go-redis/v9/osscluster_router.go @@ -110,7 +110,7 @@ func (c *ClusterClient) executeOnAllShards(ctx context.Context, cmd Cmder, polic // executeMultiShard handles commands that operate on multiple keys across shards func (c *ClusterClient) executeMultiShard(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy) error { args := cmd.Args() - firstKeyPos := int(cmdFirstKeyPos(cmd)) + firstKeyPos := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name())) stepCount := int(cmd.stepCount()) if stepCount == 0 { stepCount = 1 // Default to 1 if not set @@ -141,11 +141,11 @@ func (c *ClusterClient) executeMultiShard(ctx context.Context, cmd Cmder, policy keyOrder = append(keyOrder, key) } - return c.executeMultiSlot(ctx, cmd, slotMap, keyOrder, policy) + return c.executeMultiSlot(ctx, cmd, slotMap, keyOrder, policy, firstKeyPos) } // executeMultiSlot executes commands across multiple slots concurrently -func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap map[int][]string, keyOrder []string, policy *routing.CommandPolicy) error { +func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap map[int][]string, keyOrder []string, policy *routing.CommandPolicy, firstKeyPos int) error { results := make(chan slotResult, len(slotMap)) var wg sync.WaitGroup @@ -162,7 +162,7 @@ func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap } // Create a command for this specific slot's keys - subCmd := c.createSlotSpecificCommand(ctx, cmd, keys) + subCmd := c.createSlotSpecificCommand(ctx, cmd, keys, firstKeyPos) err = node.Client.Process(ctx, subCmd) results <- slotResult{subCmd, keys, err} }(slot, keys) @@ -176,10 +176,12 @@ func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap return c.aggregateMultiSlotResults(ctx, cmd, results, keyOrder, policy) } -// createSlotSpecificCommand creates a new command for a specific slot's keys -func (c *ClusterClient) createSlotSpecificCommand(ctx context.Context, originalCmd Cmder, keys []string) Cmder { +// createSlotSpecificCommand creates a new command for a specific slot's keys. +// firstKeyPos is passed in from the caller (computed once in executeMultiShard) +// so this function never independently re-peeks the cache โ€” avoids the +// cold --> warm inconsistency the reviewer flagged. +func (c *ClusterClient) createSlotSpecificCommand(ctx context.Context, originalCmd Cmder, keys []string, firstKeyPos int) Cmder { originalArgs := originalCmd.Args() - firstKeyPos := int(cmdFirstKeyPos(originalCmd)) // Build new args with only the specified keys newArgs := make([]interface{}, 0, firstKeyPos+len(keys)) @@ -241,6 +243,8 @@ func createCommandByType(ctx context.Context, cmdType CmdType, args ...interface return NewXPendingExtCmd(ctx, args...) case CmdTypeXAutoClaim: return NewXAutoClaimCmd(ctx, args...) + case CmdTypeXAutoClaimWithDeleted: + return NewXAutoClaimWithDeletedCmd(ctx, args...) case CmdTypeXAutoClaimJustID: return NewXAutoClaimJustIDCmd(ctx, args...) case CmdTypeXInfoStreamFull: @@ -465,7 +469,7 @@ func (c *ClusterClient) createAggregator(policy *routing.CommandPolicy, cmd Cmde } if !isKeyed { - firstKeyPos := cmdFirstKeyPos(cmd) + firstKeyPos := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name())) isKeyed = firstKeyPos > 0 } @@ -498,7 +502,7 @@ func (c *ClusterClient) pickArbitraryNode(ctx context.Context) *clusterNode { // hasKeys checks if a command operates on keys func (c *ClusterClient) hasKeys(cmd Cmder) bool { - firstKeyPos := cmdFirstKeyPos(cmd) + firstKeyPos := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name())) return firstKeyPos > 0 } @@ -668,6 +672,12 @@ func (c *ClusterClient) setCommandValue(cmd Cmder, value interface{}) error { c.SetVal(v.messages, v.start) } } + case CmdTypeXAutoClaimWithDeleted: + if c, ok := cmd.(*XAutoClaimWithDeletedCmd); ok { + if v, ok := value.(CmdTypeXAutoClaimWithDeletedValue); ok { + c.SetVal(v.messages, v.start, v.deletedIDs) + } + } case CmdTypeXAutoClaimJustID: if c, ok := cmd.(*XAutoClaimJustIDCmd); ok { if v, ok := value.(CmdTypeXAutoClaimJustIDValue); ok { diff --git a/vendor/github.com/redis/go-redis/v9/pubsub.go b/vendor/github.com/redis/go-redis/v9/pubsub.go index 15bde54e8..dad981f0a 100644 --- a/vendor/github.com/redis/go-redis/v9/pubsub.go +++ b/vendor/github.com/redis/go-redis/v9/pubsub.go @@ -87,7 +87,16 @@ func (c *PubSub) conn(ctx context.Context, newChannels []string) (*pool.Conn, er c.opt.Addr = internal.RedisNull } + // Include c.schannels so reconnect-time routing of an SSubscribe-only + // PubSub picks the slot owner (channels[0] in ClusterClient.pubSub()'s + // newConn closure) instead of a random node. + // See https://github.com/redis/go-redis/issues/3806. + // c.patterns is intentionally NOT included: patterns are not slot- + // addressable, and adding them would force PSubscribe-only PubSubs to + // pin to a single node based on pattern-string hash, regressing the + // existing random-node behaviour. channels := slices.Collect(maps.Keys(c.channels)) + channels = append(channels, slices.Collect(maps.Keys(c.schannels))...) channels = append(channels, newChannels...) cn, err := c.newConn(ctx, c.opt.Addr, channels) diff --git a/vendor/github.com/redis/go-redis/v9/redis.go b/vendor/github.com/redis/go-redis/v9/redis.go index 923bad62a..dd3451890 100644 --- a/vendor/github.com/redis/go-redis/v9/redis.go +++ b/vendor/github.com/redis/go-redis/v9/redis.go @@ -26,6 +26,14 @@ type Scanner = hscan.Scanner // Nil reply returned by Redis when key does not exist. const Nil = proto.Nil +// String representations of special float values. +// Values are lowercase for consistency with Redis RESP2 protocol responses. +const ( + NaN = internal.NaN // Not a Number + Inf = internal.Inf // Positive infinity + NInf = internal.NInf // Negative infinity +) + // SetLogger set custom log // Use with VoidLogger to disable logging. // If logger is nil, the call is ignored and the existing logger is kept. @@ -936,16 +944,9 @@ func classifyCommandError(err error) (errorType, statusCode string, isInternal b } func (c *baseClient) assertUnstableCommand(cmd Cmder) (bool, error) { - switch cmd.(type) { - case *AggregateCmd, *FTInfoCmd, *FTSpellCheckCmd, *FTSearchCmd, *FTSynDumpCmd: - if c.opt.UnstableResp3 { - return true, nil - } else { - return false, fmt.Errorf("RESP3 responses for this command are disabled because they may still change. Please set the flag UnstableResp3. See the README and the release notes for guidance") - } - default: - return false, nil - } + // All search commands (FTSearchCmd, AggregateCmd, FTInfoCmd, FTSpellCheckCmd, FTSynDumpCmd) + // now have stable RESP3 parsing. No commands require the UnstableResp3 flag anymore. + return false, nil } func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, *pool.Conn, error) { diff --git a/vendor/github.com/redis/go-redis/v9/ring.go b/vendor/github.com/redis/go-redis/v9/ring.go index ab4d00acb..b60d3eab5 100644 --- a/vendor/github.com/redis/go-redis/v9/ring.go +++ b/vendor/github.com/redis/go-redis/v9/ring.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "errors" "fmt" + "math/rand" "net" "strconv" "sync" @@ -12,12 +13,10 @@ import ( "time" "github.com/redis/go-redis/v9/auth" - "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/hashtag" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" - "github.com/redis/go-redis/v9/internal/rand" ) var errRingShardsDown = errors.New("redis: all ring shards are down") @@ -159,7 +158,11 @@ type RingOptions struct { // default: false DisableIdentity bool IdentitySuffix string - UnstableResp3 bool + + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. + UnstableResp3 bool } func (opt *RingOptions) init() { @@ -772,7 +775,10 @@ func (c *Ring) cmdsInfo(ctx context.Context) (map[string]*CommandInfo, error) { } func (c *Ring) cmdShard(cmd Cmder) (*ringShard, error) { - pos := cmdFirstKeyPos(cmd) + // TODO: populate cmdsInfoCache lazily (via cmdsInfoCache.Get) so that + // the warm-cache branch in cmdFirstKeyPosWithInfo is reachable for Ring, + // mirroring how ClusterClient.cmdInfo works. For now pass nil + pos := cmdFirstKeyPosWithInfo(cmd, nil) if pos == 0 { return c.sharding.Random() } @@ -840,7 +846,7 @@ func (c *Ring) generalProcessPipeline( cmdsMap := make(map[string][]Cmder) for _, cmd := range cmds { - hash := cmd.stringArg(cmdFirstKeyPos(cmd)) + hash := cmd.stringArg(cmdFirstKeyPosWithInfo(cmd, nil)) if hash != "" { hash = c.sharding.Hash(hash) } diff --git a/vendor/github.com/redis/go-redis/v9/search_commands.go b/vendor/github.com/redis/go-redis/v9/search_commands.go index bd7707da8..c3d14abaf 100644 --- a/vendor/github.com/redis/go-redis/v9/search_commands.go +++ b/vendor/github.com/redis/go-redis/v9/search_commands.go @@ -6,6 +6,7 @@ import ( "maps" "slices" "strconv" + "strings" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/proto" @@ -481,9 +482,12 @@ type FTSynDumpCmd struct { val []FTSynDumpResult } +// FTAggregateResult represents the result of an aggregate operation +// NOTE: For RESP3 Total is not reliable (before Redis 8.8) type FTAggregateResult struct { - Total int - Rows []AggregateRow + Total int + Rows []AggregateRow + Warnings []string } type AggregateRow struct { @@ -613,8 +617,9 @@ type SpellCheckSuggestion struct { } type FTSearchResult struct { - Total int - Docs []Document + Total int + Docs []Document + Warnings []string } type Document struct { @@ -949,15 +954,129 @@ func (cmd *AggregateCmd) String() string { } func (cmd *AggregateCmd) readReply(rd *proto.Reader) (err error) { - data, err := rd.ReadSlice() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val, err = ProcessAggregateResult(data) + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + // Parse the raw response into structured result + if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok { + cmd.val, err = parseFTAggregateMapRESP3(mapVal) + } else { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + return err + } + + // RESP2 format or error response - use ReadReply to handle errors properly + data, err := rd.ReadReply() if err != nil { return err } - return nil + cmd.rawVal = data // Store raw value for debugging + if dataSlice, ok := data.([]interface{}); ok { + cmd.val, err = ProcessAggregateResult(dataSlice) + return err + } + return fmt.Errorf("unexpected response type: %T", data) +} + +// parseFTAggregateMapRESP3 parses the RESP3 format response from FT.AGGREGATE. +// It takes a map[interface{}]interface{} which is the raw response from ReadReply(). +// RESP3 format: +// +// %5 +// $10 attributes => *0 +// $13 total_results => :N +// $6 format => $6 STRING +// $7 results => *N (array of maps with extra_attributes, values) +// $7 warning => *N (array of strings) +func parseFTAggregateMapRESP3(data map[interface{}]interface{}) (*FTAggregateResult, error) { + result := &FTAggregateResult{ + Rows: make([]AggregateRow, 0), + } + + for k, v := range data { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "total_results": + result.Total = internal.ToInteger(v) + case "results": + if resultsData, ok := v.([]interface{}); ok { + rows, err := parseFTAggregateResultsMapRESP3(resultsData) + if err != nil { + return nil, err + } + result.Rows = rows + } + case "warning": + if warningsData, ok := v.([]interface{}); ok { + result.Warnings = make([]string, 0, len(warningsData)) + for _, w := range warningsData { + if ws, ok := w.(string); ok { + result.Warnings = append(result.Warnings, ws) + } + } + } + // Ignore "attributes", "format", and other fields as per the spec + } + } + + return result, nil +} + +// parseFTAggregateResultsMapRESP3 parses the results array from RESP3 FT.AGGREGATE response. +func parseFTAggregateResultsMapRESP3(resultsData []interface{}) ([]AggregateRow, error) { + rows := make([]AggregateRow, 0, len(resultsData)) + for _, item := range resultsData { + if itemMap, ok := item.(map[interface{}]interface{}); ok { + row, err := parseFTAggregateRowMapRESP3(itemMap) + if err != nil { + return nil, err + } + rows = append(rows, row) + } + } + return rows, nil +} + +// parseFTAggregateRowMapRESP3 parses a single row from RESP3 FT.AGGREGATE response. +func parseFTAggregateRowMapRESP3(itemMap map[interface{}]interface{}) (AggregateRow, error) { + row := AggregateRow{ + Fields: make(map[string]interface{}), + } + + for k, v := range itemMap { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "extra_attributes": + if extraAttrs, ok := v.(map[interface{}]interface{}); ok { + for ek, ev := range extraAttrs { + if ekStr, ok := ek.(string); ok { + row.Fields[ekStr] = ev + } + } + } + // Ignore "values" and other fields as per the spec + } + } + + return row, nil } func (cmd *AggregateCmd) Clone() Cmder { @@ -978,6 +1097,10 @@ func (cmd *AggregateCmd) Clone() Cmder { } } } + if cmd.val.Warnings != nil { + val.Warnings = make([]string, len(cmd.val.Warnings)) + copy(val.Warnings, cmd.val.Warnings) + } } return &AggregateCmd{ baseCmd: cmd.cloneBaseCmd(), @@ -1564,134 +1687,322 @@ func (c cmdable) FTExplainCli(ctx context.Context, key, path string) error { return fmt.Errorf("FTExplainCli is not implemented") } +// parseFTAttributeFromMap parses an FTAttribute from a RESP3 map format +func parseFTAttributeFromMap(attrMap map[interface{}]interface{}) FTAttribute { + att := FTAttribute{} + for k, v := range attrMap { + key := internal.ToLower(internal.ToString(k)) + switch key { + case "attribute": + att.Attribute = internal.ToString(v) + case "identifier": + att.Identifier = internal.ToString(v) + case "type": + att.Type = internal.ToString(v) + case "weight": + att.Weight = internal.ToFloat(v) + case "phonetic": + att.PhoneticMatcher = internal.ToString(v) + case "algorithm": + att.Algorithm = internal.ToString(v) + case "data_type": + att.DataType = internal.ToString(v) + case "dim": + att.Dim = internal.ToInteger(v) + case "distance_metric": + att.DistanceMetric = internal.ToString(v) + case "m": + att.M = internal.ToInteger(v) + case "ef_construction": + att.EFConstruction = internal.ToInteger(v) + case "flags": + // flags is an array of strings like ["SORTABLE", "NOSTEM"] + if flags, ok := v.([]interface{}); ok { + for _, flag := range flags { + flagStr := internal.ToLower(internal.ToString(flag)) + switch flagStr { + case "nostem": + att.NoStem = true + case "sortable": + att.Sortable = true + case "noindex": + att.NoIndex = true + case "unf": + att.UNF = true + case "case_sensitive": + att.CaseSensitive = true + case "withsuffixtrie": + att.WithSuffixtrie = true + } + } + } + } + } + return att +} + +// getMapStringKey extracts a string value from a map with interface{} keys +func getMapStringKey(m map[interface{}]interface{}, key string) interface{} { + if v, ok := m[key]; ok { + return v + } + return nil +} + +// parseIndexErrorsRESP3 parses Index Errors from RESP3 map format +func parseIndexErrorsRESP3(m map[interface{}]interface{}) IndexErrors { + return IndexErrors{ + IndexingFailures: internal.ToInteger(getMapStringKey(m, "indexing failures")), + LastIndexingError: internal.ToString(getMapStringKey(m, "last indexing error")), + LastIndexingErrorKey: internal.ToString(getMapStringKey(m, "last indexing error key")), + } +} + +// parseCursorStatsRESP3 parses cursor_stats from RESP3 map format +func parseCursorStatsRESP3(m map[interface{}]interface{}) CursorStats { + return CursorStats{ + GlobalIdle: internal.ToInteger(getMapStringKey(m, "global_idle")), + GlobalTotal: internal.ToInteger(getMapStringKey(m, "global_total")), + IndexCapacity: internal.ToInteger(getMapStringKey(m, "index_capacity")), + IndexTotal: internal.ToInteger(getMapStringKey(m, "index_total")), + } +} + +// parseGCStatsRESP3 parses gc_stats from RESP3 map format +func parseGCStatsRESP3(m map[interface{}]interface{}) GCStats { + // Handle average_cycle_time_ms which can be a float64 (including NaN) or string + avgCycleTime := "" + if v := getMapStringKey(m, "average_cycle_time_ms"); v != nil { + switch val := v.(type) { + case string: + // Normalize to lowercase for consistency with RESP2 + avgCycleTime = strings.ToLower(val) + case float64: + avgCycleTime = internal.FormatFloat(val) + } + } + + return GCStats{ + BytesCollected: ftInfoNumInt(getMapStringKey(m, "bytes_collected")), + TotalMsRun: ftInfoNumInt(getMapStringKey(m, "total_ms_run")), + TotalCycles: ftInfoNumInt(getMapStringKey(m, "total_cycles")), + AverageCycleTimeMs: avgCycleTime, + LastRunTimeMs: ftInfoNumInt(getMapStringKey(m, "last_run_time_ms")), + GCNumericTreesMissed: ftInfoNumInt(getMapStringKey(m, "gc_numeric_trees_missed")), + GCBlocksDenied: ftInfoNumInt(getMapStringKey(m, "gc_blocks_denied")), + } +} + +// parseIndexDefinitionRESP3 parses index_definition from RESP3 map format +func parseIndexDefinitionRESP3(m map[interface{}]interface{}) IndexDefinition { + def := IndexDefinition{ + KeyType: internal.ToString(getMapStringKey(m, "key_type")), + DefaultScore: internal.ToFloat(getMapStringKey(m, "default_score")), + } + if prefixes, ok := getMapStringKey(m, "prefixes").([]interface{}); ok { + def.Prefixes = internal.ToStringSlice(prefixes) + } + return def +} + +// parseDialectStatsRESP3 parses dialect_stats from RESP3 map format +func parseDialectStatsRESP3(m map[interface{}]interface{}) map[string]int { + result := make(map[string]int) + for k, v := range m { + if kStr, ok := k.(string); ok { + result[kStr] = internal.ToInteger(v) + } + } + return result +} + +// ftInfoNumString stringifies a value that RediSearch emits via REPLY_KVNUM +// (RedisModule_ReplyWithDouble): a bulk string in RESP2 but a native double +// in RESP3. Used for FTInfoResult fields whose public type is string. +// Special float values (NaN, +Inf, -Inf) are normalized to lowercase to match +// the RESP2 wire format. +func ftInfoNumString(val interface{}) string { + switch v := val.(type) { + case string: + return v + case float64: + return internal.FormatFloat(v) + case float32: + return internal.FormatFloat(float64(v)) + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + default: + return "" + } +} + +// ftInfoNumInt converts a value that RediSearch emits via REPLY_KVNUM to int. +// In RESP2 the value is a bulk string; in RESP3 it is a native double, even +// for logically-integer fields (counters, byte sizes). This helper exists so +// the internal.ToInteger helper can remain strict about float-to-int coercion +// while still letting the RediSearch parsers read those values correctly. +func ftInfoNumInt(val interface{}) int { + switch v := val.(type) { + case float64: + return int(v) + case float32: + return int(v) + default: + return internal.ToInteger(v) + } +} + func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { var ftInfo FTInfoResult - // Manually parse each field from the map + + // Parse Index Errors - handle both RESP2 (array) and RESP3 (map) formats if indexErrors, ok := data["Index Errors"].([]interface{}); ok { + // RESP2 format: array with key-value pairs ftInfo.IndexErrors = IndexErrors{ IndexingFailures: internal.ToInteger(indexErrors[1]), LastIndexingError: internal.ToString(indexErrors[3]), LastIndexingErrorKey: internal.ToString(indexErrors[5]), } + } else if indexErrors, ok := data["Index Errors"].(map[interface{}]interface{}); ok { + // RESP3 format: map + ftInfo.IndexErrors = parseIndexErrorsRESP3(indexErrors) } if attributes, ok := data["attributes"].([]interface{}); ok { for _, attr := range attributes { - if attrMap, ok := attr.([]interface{}); ok { - att := FTAttribute{} - attrLen := len(attrMap) + att := FTAttribute{} + // Handle RESP2 format: attribute is []interface{} + if attrSlice, ok := attr.([]interface{}); ok { + attrLen := len(attrSlice) for i := 0; i < attrLen; i++ { - if internal.ToLower(internal.ToString(attrMap[i])) == "attribute" && i+1 < attrLen { - att.Attribute = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "attribute" && i+1 < attrLen { + att.Attribute = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "identifier" && i+1 < attrLen { - att.Identifier = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "identifier" && i+1 < attrLen { + att.Identifier = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "type" && i+1 < attrLen { - att.Type = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "type" && i+1 < attrLen { + att.Type = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "weight" && i+1 < attrLen { - att.Weight = internal.ToFloat(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "weight" && i+1 < attrLen { + att.Weight = internal.ToFloat(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "nostem" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "nostem" { att.NoStem = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "sortable" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "sortable" { att.Sortable = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "noindex" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "noindex" { att.NoIndex = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "unf" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "unf" { att.UNF = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "phonetic" && i+1 < attrLen { - att.PhoneticMatcher = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "phonetic" && i+1 < attrLen { + att.PhoneticMatcher = internal.ToString(attrSlice[i+1]) continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "case_sensitive" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "case_sensitive" { att.CaseSensitive = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "withsuffixtrie" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "withsuffixtrie" { att.WithSuffixtrie = true continue } // vector specific attributes - if internal.ToLower(internal.ToString(attrMap[i])) == "algorithm" && i+1 < attrLen { - att.Algorithm = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "algorithm" && i+1 < attrLen { + att.Algorithm = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "data_type" && i+1 < attrLen { - att.DataType = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "data_type" && i+1 < attrLen { + att.DataType = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "dim" && i+1 < attrLen { - att.Dim = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "dim" && i+1 < attrLen { + att.Dim = internal.ToInteger(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "distance_metric" && i+1 < attrLen { - att.DistanceMetric = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "distance_metric" && i+1 < attrLen { + att.DistanceMetric = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "m" && i+1 < attrLen { - att.M = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "m" && i+1 < attrLen { + att.M = internal.ToInteger(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "ef_construction" && i+1 < attrLen { - att.EFConstruction = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "ef_construction" && i+1 < attrLen { + att.EFConstruction = internal.ToInteger(attrSlice[i+1]) i++ continue } - } ftInfo.Attributes = append(ftInfo.Attributes, att) + } else if attrMap, ok := attr.(map[interface{}]interface{}); ok { + // Handle RESP3 format: attribute is map[interface{}]interface{} + att = parseFTAttributeFromMap(attrMap) + ftInfo.Attributes = append(ftInfo.Attributes, att) } } } - ftInfo.BytesPerRecordAvg = internal.ToString(data["bytes_per_record_avg"]) + ftInfo.BytesPerRecordAvg = ftInfoNumString(data["bytes_per_record_avg"]) ftInfo.Cleaning = internal.ToInteger(data["cleaning"]) + // Parse cursor_stats - handle both RESP2 (array) and RESP3 (map) formats if cursorStats, ok := data["cursor_stats"].([]interface{}); ok { + // RESP2 format ftInfo.CursorStats = CursorStats{ GlobalIdle: internal.ToInteger(cursorStats[1]), GlobalTotal: internal.ToInteger(cursorStats[3]), IndexCapacity: internal.ToInteger(cursorStats[5]), IndexTotal: internal.ToInteger(cursorStats[7]), } + } else if cursorStats, ok := data["cursor_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.CursorStats = parseCursorStatsRESP3(cursorStats) } + // Parse dialect_stats - handle both RESP2 (array) and RESP3 (map) formats if dialectStats, ok := data["dialect_stats"].([]interface{}); ok { + // RESP2 format ftInfo.DialectStats = make(map[string]int) for i := 0; i < len(dialectStats); i += 2 { ftInfo.DialectStats[internal.ToString(dialectStats[i])] = internal.ToInteger(dialectStats[i+1]) } + } else if dialectStats, ok := data["dialect_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.DialectStats = parseDialectStatsRESP3(dialectStats) } ftInfo.DocTableSizeMB = internal.ToFloat(data["doc_table_size_mb"]) + // Parse field statistics - handle both RESP2 and RESP3 formats if fieldStats, ok := data["field statistics"].([]interface{}); ok { for _, stat := range fieldStats { if statMap, ok := stat.([]interface{}); ok { + // RESP2 format ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, FieldStatistic{ Identifier: internal.ToString(statMap[1]), Attribute: internal.ToString(statMap[3]), @@ -1701,11 +2012,23 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { LastIndexingErrorKey: internal.ToString(statMap[5].([]interface{})[5]), }, }) + } else if statMap, ok := stat.(map[interface{}]interface{}); ok { + // RESP3 format + fs := FieldStatistic{ + Identifier: internal.ToString(getMapStringKey(statMap, "identifier")), + Attribute: internal.ToString(getMapStringKey(statMap, "attribute")), + } + if indexErrors, ok := getMapStringKey(statMap, "Index Errors").(map[interface{}]interface{}); ok { + fs.IndexErrors = parseIndexErrorsRESP3(indexErrors) + } + ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, fs) } } } + // Parse gc_stats - handle both RESP2 (array) and RESP3 (map) formats if gcStats, ok := data["gc_stats"].([]interface{}); ok { + // RESP2 format ftInfo.GCStats = GCStats{} for i := 0; i < len(gcStats); i += 2 { if internal.ToLower(internal.ToString(gcStats[i])) == "bytes_collected" { @@ -1737,21 +2060,31 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { continue } } + } else if gcStats, ok := data["gc_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.GCStats = parseGCStatsRESP3(gcStats) } ftInfo.GeoshapesSzMB = internal.ToFloat(data["geoshapes_sz_mb"]) ftInfo.HashIndexingFailures = internal.ToInteger(data["hash_indexing_failures"]) + // Parse index_definition - handle both RESP2 (array) and RESP3 (map) formats if indexDef, ok := data["index_definition"].([]interface{}); ok { + // RESP2 format ftInfo.IndexDefinition = IndexDefinition{ KeyType: internal.ToString(indexDef[1]), Prefixes: internal.ToStringSlice(indexDef[3]), DefaultScore: internal.ToFloat(indexDef[5]), } + } else if indexDef, ok := data["index_definition"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.IndexDefinition = parseIndexDefinitionRESP3(indexDef) } ftInfo.IndexName = internal.ToString(data["index_name"]) - ftInfo.IndexOptions = internal.ToStringSlice(data["index_options"].([]interface{})) + if indexOptions, ok := data["index_options"].([]interface{}); ok { + ftInfo.IndexOptions = internal.ToStringSlice(indexOptions) + } ftInfo.Indexing = internal.ToInteger(data["indexing"]) ftInfo.InvertedSzMB = internal.ToFloat(data["inverted_sz_mb"]) ftInfo.KeyTableSizeMB = internal.ToFloat(data["key_table_size_mb"]) @@ -1760,16 +2093,16 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { ftInfo.NumRecords = internal.ToInteger(data["num_records"]) ftInfo.NumTerms = internal.ToInteger(data["num_terms"]) ftInfo.NumberOfUses = internal.ToInteger(data["number_of_uses"]) - ftInfo.OffsetBitsPerRecordAvg = internal.ToString(data["offset_bits_per_record_avg"]) + ftInfo.OffsetBitsPerRecordAvg = ftInfoNumString(data["offset_bits_per_record_avg"]) ftInfo.OffsetVectorsSzMB = internal.ToFloat(data["offset_vectors_sz_mb"]) - ftInfo.OffsetsPerTermAvg = internal.ToString(data["offsets_per_term_avg"]) + ftInfo.OffsetsPerTermAvg = ftInfoNumString(data["offsets_per_term_avg"]) ftInfo.PercentIndexed = internal.ToFloat(data["percent_indexed"]) - ftInfo.RecordsPerDocAvg = internal.ToString(data["records_per_doc_avg"]) + ftInfo.RecordsPerDocAvg = ftInfoNumString(data["records_per_doc_avg"]) ftInfo.SortableValuesSizeMB = internal.ToFloat(data["sortable_values_size_mb"]) ftInfo.TagOverheadSzMB = internal.ToFloat(data["tag_overhead_sz_mb"]) ftInfo.TextOverheadSzMB = internal.ToFloat(data["text_overhead_sz_mb"]) ftInfo.TotalIndexMemorySzMB = internal.ToFloat(data["total_index_memory_sz_mb"]) - ftInfo.TotalIndexingTime = internal.ToInteger(data["total_indexing_time"]) + ftInfo.TotalIndexingTime = ftInfoNumInt(data["total_indexing_time"]) ftInfo.TotalInvertedIndexBlocks = internal.ToInteger(data["total_inverted_index_blocks"]) ftInfo.VectorIndexSzMB = internal.ToFloat(data["vector_index_sz_mb"]) @@ -1815,6 +2148,37 @@ func (cmd *FTInfoCmd) RawResult() (interface{}, error) { return cmd.rawVal, cmd.err } func (cmd *FTInfoCmd) readReply(rd *proto.Reader) (err error) { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Convert map[interface{}]interface{} to map[string]interface{} + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + data := make(map[string]interface{}, len(rawMap)) + for k, v := range rawMap { + if kStr, ok := k.(string); ok { + data[kStr] = v + } + } + + cmd.val, err = parseFTInfo(data) + return err + } + + // RESP2 format - read as map n, err := rd.ReadMapLen() if err != nil { return err @@ -1841,11 +2205,7 @@ func (cmd *FTInfoCmd) readReply(rd *proto.Reader) (err error) { data[k] = v } cmd.val, err = parseFTInfo(data) - if err != nil { - return err - } - - return nil + return err } func (cmd *FTInfoCmd) Clone() Cmder { @@ -1991,15 +2351,117 @@ func (cmd *FTSpellCheckCmd) RawResult() (interface{}, error) { } func (cmd *FTSpellCheckCmd) readReply(rd *proto.Reader) (err error) { - data, err := rd.ReadSlice() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val, err = parseFTSpellCheck(data) + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Parse the raw response into structured result + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + cmd.val, err = parseFTSpellCheckRESP3(rawMap) + return err + } + + // RESP2 format + data, err := rd.ReadSlice() if err != nil { return err } - return nil + cmd.val, err = parseFTSpellCheck(data) + return err +} + +// parseFTSpellCheckRESP3 parses the RESP3 format response from FT.SPELLCHECK. +// RESP3 format: +// +// map{ +// "results": map{ +// "misspelled_term": [ +// map{"suggestion": score}, +// ... +// ], +// ... +// } +// } +func parseFTSpellCheckRESP3(data map[interface{}]interface{}) ([]SpellCheckResult, error) { + results := make([]SpellCheckResult, 0) + + resultsData, ok := data["results"] + if !ok { + return results, nil + } + + resultsMap, ok := resultsData.(map[interface{}]interface{}) + if !ok { + return nil, fmt.Errorf("invalid results format: expected map, got %T", resultsData) + } + + for termKey, suggestionsData := range resultsMap { + term, ok := termKey.(string) + if !ok { + continue + } + + suggestionsArray, ok := suggestionsData.([]interface{}) + if !ok { + continue + } + + suggestions := make([]SpellCheckSuggestion, 0, len(suggestionsArray)) + for _, suggestionData := range suggestionsArray { + suggestionMap, ok := suggestionData.(map[interface{}]interface{}) + if !ok { + continue + } + + for suggKey, scoreVal := range suggestionMap { + suggestion, ok := suggKey.(string) + if !ok { + continue + } + + var score float64 + switch v := scoreVal.(type) { + case float64: + score = v + case int64: + score = float64(v) + case string: + var err error + score, err = strconv.ParseFloat(v, 64) + if err != nil { + continue + } + default: + continue + } + + suggestions = append(suggestions, SpellCheckSuggestion{ + Score: score, + Suggestion: suggestion, + }) + } + } + + results = append(results, SpellCheckResult{ + Term: term, + Suggestions: suggestions, + }) + } + + return results, nil } func parseFTSpellCheck(data []interface{}) ([]SpellCheckResult, error) { @@ -2205,15 +2667,145 @@ func (cmd *FTSearchCmd) RawResult() (interface{}, error) { } func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) { - data, err := rd.ReadSlice() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val, err = parseFTSearch(data, cmd.options.NoContent, cmd.options.WithScores, cmd.options.WithPayloads, cmd.options.WithSortKeys) + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + // Parse the raw response into structured result + if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok { + cmd.val, err = parseFTSearchMapRESP3(mapVal) + } else { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + return err + } + + // RESP2 format or error response - use ReadReply to handle errors properly + data, err := rd.ReadReply() if err != nil { return err } - return nil + if dataSlice, ok := data.([]interface{}); ok { + cmd.val, err = parseFTSearch(dataSlice, cmd.options.NoContent, cmd.options.WithScores, cmd.options.WithPayloads, cmd.options.WithSortKeys) + return err + } + return fmt.Errorf("unexpected response type: %T", data) +} + +// parseFTSearchMapRESP3 parses the RESP3 format response from FT.SEARCH. +// It takes a map[interface{}]interface{} which is the raw response from ReadReply(). +// RESP3 format: +// +// %5 +// $10 attributes => *0 +// $13 total_results => :N +// $6 format => $6 STRING +// $7 results => *N (array of maps with id, score, extra_attributes, values) +// $7 warning => *N (array of strings) +func parseFTSearchMapRESP3(data map[interface{}]interface{}) (FTSearchResult, error) { + var result FTSearchResult + result.Docs = make([]Document, 0) + + for k, v := range data { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "total_results": + result.Total = internal.ToInteger(v) + case "results": + if resultsData, ok := v.([]interface{}); ok { + docs, err := parseFTSearchResultsMapRESP3(resultsData) + if err != nil { + return FTSearchResult{}, err + } + result.Docs = docs + } + case "warning": + if warningsData, ok := v.([]interface{}); ok { + result.Warnings = make([]string, 0, len(warningsData)) + for _, w := range warningsData { + if ws, ok := w.(string); ok { + result.Warnings = append(result.Warnings, ws) + } + } + } + // Ignore "attributes", "format", and other fields as per the spec + } + } + + return result, nil +} + +// parseFTSearchResultsMapRESP3 parses the results array from RESP3 FT.SEARCH response. +func parseFTSearchResultsMapRESP3(resultsData []interface{}) ([]Document, error) { + docs := make([]Document, 0, len(resultsData)) + for _, item := range resultsData { + if itemMap, ok := item.(map[interface{}]interface{}); ok { + doc, err := parseFTSearchDocumentMapRESP3(itemMap) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + } + return docs, nil +} + +// parseFTSearchDocumentMapRESP3 parses a single document from RESP3 FT.SEARCH response. +func parseFTSearchDocumentMapRESP3(itemMap map[interface{}]interface{}) (Document, error) { + doc := Document{ + Fields: make(map[string]string), + } + + for k, v := range itemMap { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "id": + if id, ok := v.(string); ok { + doc.ID = id + } + case "score": + if score, ok := v.(float64); ok { + doc.Score = &score + } + case "payload": + if payload, ok := v.(string); ok { + doc.Payload = &payload + } + case "sortkey": + if sortKey, ok := v.(string); ok { + doc.SortKey = &sortKey + } + case "extra_attributes": + if extraAttrs, ok := v.(map[interface{}]interface{}); ok { + for ek, ev := range extraAttrs { + if ekStr, ok := ek.(string); ok { + if evStr, ok := ev.(string); ok { + doc.Fields[ekStr] = evStr + } + } + } + } + // Ignore "values" and other fields as per the spec + } + } + + return doc, nil } func (cmd *FTSearchCmd) Clone() Cmder { @@ -2237,6 +2829,10 @@ func (cmd *FTSearchCmd) Clone() Cmder { } } } + if cmd.val.Warnings != nil { + val.Warnings = make([]string, len(cmd.val.Warnings)) + copy(val.Warnings, cmd.val.Warnings) + } var options *FTSearchOptions if cmd.options != nil { options = &FTSearchOptions{ @@ -2886,6 +3482,30 @@ func (cmd *FTSynDumpCmd) RawResult() (interface{}, error) { } func (cmd *FTSynDumpCmd) readReply(rd *proto.Reader) error { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Parse the raw response into structured result + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + cmd.val, err = parseFTSynDumpRESP3(rawMap) + return err + } + + // RESP2 format termSynonymPairs, err := rd.ReadSlice() if err != nil { return err @@ -2922,6 +3542,44 @@ func (cmd *FTSynDumpCmd) readReply(rd *proto.Reader) error { return nil } +// parseFTSynDumpRESP3 parses the RESP3 format response from FT.SYNDUMP. +// RESP3 format: +// +// map{ +// "term1": ["synonym_group_id1", ...], +// "term2": ["synonym_group_id2", ...], +// ... +// } +func parseFTSynDumpRESP3(data map[interface{}]interface{}) ([]FTSynDumpResult, error) { + results := make([]FTSynDumpResult, 0, len(data)) + + for termKey, synonymsData := range data { + term, ok := termKey.(string) + if !ok { + continue + } + + synonymsArray, ok := synonymsData.([]interface{}) + if !ok { + continue + } + + synonymList := make([]string, 0, len(synonymsArray)) + for _, syn := range synonymsArray { + if synonym, ok := syn.(string); ok { + synonymList = append(synonymList, synonym) + } + } + + results = append(results, FTSynDumpResult{ + Term: term, + Synonyms: synonymList, + }) + } + + return results, nil +} + func (cmd *FTSynDumpCmd) Clone() Cmder { var val []FTSynDumpResult if cmd.val != nil { @@ -3006,6 +3664,42 @@ func (c cmdable) FTHybrid(ctx context.Context, index string, searchExpr string, return c.FTHybridWithArgs(ctx, index, options) } +func hybridVectorBlob(v Vector) (interface{}, error) { + if v == nil { + return nil, fmt.Errorf("FT.HYBRID: vector data is required") + } + + switch vector := v.(type) { + case *VectorFP32: + return hybridVectorBytes(vector.Val) + case *VectorFloat16: + return hybridVectorBytes(vector.Val) + case *VectorBFloat16: + return hybridVectorBytes(vector.Val) + case *VectorFloat64: + return hybridVectorBytes(vector.Val) + case *VectorInt8: + return hybridVectorBytes(vector.Val) + case *VectorUint8: + return hybridVectorBytes(vector.Val) + case *VectorValues, *VectorRef: + return nil, fmt.Errorf("FT.HYBRID: unsupported vector type %T", v) + default: + values := v.Value() + if len(values) < 2 { + return nil, fmt.Errorf("FT.HYBRID: vector Value must contain a blob at index 1") + } + return values[1], nil + } +} + +func hybridVectorBytes(blob []byte) ([]byte, error) { + if len(blob) == 0 { + return nil, fmt.Errorf("FT.HYBRID: vector blob is required") + } + return blob, nil +} + // FTHybridWithArgs - Executes a hybrid search with advanced options // FTHybridWithArgs is still experimental, the command behaviour and signature may change func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd { @@ -3032,16 +3726,11 @@ func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FT for _, vectorExpr := range options.VectorExpressions { args = append(args, "VSIM", "@"+vectorExpr.VectorField) - // For FT.HYBRID, we need to send just the raw vector bytes, not the Value() format - // Value() returns [format, data] but FT.HYBRID expects just the blob - vectorValue := vectorExpr.VectorData.Value() - var vectorBlob interface{} - if len(vectorValue) >= 2 { - // vectorValue is [format, data, ...] - we only want the data part - vectorBlob = vectorValue[1] - } else { - // Fallback for unexpected format - vectorBlob = vectorValue + vectorBlob, err := hybridVectorBlob(vectorExpr.VectorData) + if err != nil { + cmd := newFTHybridCmd(ctx, options, args...) + cmd.SetErr(err) + return cmd } // If VectorParamName is provided, use PARAMS mechanism (required for Redis 8.6+) diff --git a/vendor/github.com/redis/go-redis/v9/sentinel.go b/vendor/github.com/redis/go-redis/v9/sentinel.go index 785372f26..055b3101f 100644 --- a/vendor/github.com/redis/go-redis/v9/sentinel.go +++ b/vendor/github.com/redis/go-redis/v9/sentinel.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "errors" "fmt" + "math/rand" "net" "net/url" "slices" @@ -16,7 +17,6 @@ import ( "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/pool" - "github.com/redis/go-redis/v9/internal/rand" "github.com/redis/go-redis/v9/maintnotifications" "github.com/redis/go-redis/v9/push" ) @@ -160,6 +160,9 @@ type FailoverOptions struct { // Only applies to failover cluster clients. Default is 15 seconds. FailingTimeoutSeconds int + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications. @@ -951,6 +954,7 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { errCh <- err return } + once.Do(func() { masterAddr = net.JoinHostPort(addrVal[0], addrVal[1]) // Push working sentinel to the top @@ -959,6 +963,10 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { internal.Logger.Printf(ctx, "sentinel: selected addr=%s masterAddr=%s", addr, masterAddr) cancel() }) + + if sentinelCli != c.sentinel { + _ = sentinelCli.Close() + } }(i, sentinelAddr) } @@ -1008,8 +1016,16 @@ func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected boo c.opt.MasterName, err) } else if len(addrs) > 0 { return addrs, nil + } else if !useDisconnected { + // No error and no replicas โ€” valid steady state for master-only setups. + // Preserve the sentinel connection for master discovery and failover + // pub/sub monitoring. Only return early when useDisconnected is false; + // when true, fall through to the discovery loop which passes + // useDisconnected to parseReplicaAddrs (getReplicaAddrs hardcodes false). + return []string{}, nil } else { - // No error and no replicas. + // useDisconnected=true: close sentinel so the discovery loop can call + // setSentinel if it finds disconnected replicas. _ = c.closeSentinel() } } @@ -1042,9 +1058,9 @@ func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected boo } if sentinelReachable { - return []string{}, nil + return nil, nil } - return []string{}, errors.New("redis: all sentinels specified in configuration are unreachable") + return nil, errors.New("redis: all sentinels specified in configuration are unreachable") } func (c *sentinelFailover) getMasterAddr(ctx context.Context, sentinel *SentinelClient) (string, error) { diff --git a/vendor/github.com/redis/go-redis/v9/sortedset_commands.go b/vendor/github.com/redis/go-redis/v9/sortedset_commands.go index e21ff1aaf..b171d7ac1 100644 --- a/vendor/github.com/redis/go-redis/v9/sortedset_commands.go +++ b/vendor/github.com/redis/go-redis/v9/sortedset_commands.go @@ -781,7 +781,7 @@ type ZWithKey struct { type ZStore struct { Keys []string Weights []float64 - // Can be SUM, MIN or MAX. + // Can be SUM, MIN, MAX or COUNT. Aggregate string } diff --git a/vendor/github.com/redis/go-redis/v9/stream_commands.go b/vendor/github.com/redis/go-redis/v9/stream_commands.go index 89ae6a1b2..71191aec4 100644 --- a/vendor/github.com/redis/go-redis/v9/stream_commands.go +++ b/vendor/github.com/redis/go-redis/v9/stream_commands.go @@ -29,11 +29,13 @@ type StreamCmdable interface { XGroupDelConsumer(ctx context.Context, stream, group, consumer string) *IntCmd XReadGroup(ctx context.Context, a *XReadGroupArgs) *XStreamSliceCmd XAck(ctx context.Context, stream, group string, ids ...string) *IntCmd + XNack(ctx context.Context, a *XNackArgs) *IntCmd XPending(ctx context.Context, stream, group string) *XPendingCmd XPendingExt(ctx context.Context, a *XPendingExtArgs) *XPendingExtCmd XClaim(ctx context.Context, a *XClaimArgs) *XMessageSliceCmd XClaimJustID(ctx context.Context, a *XClaimArgs) *StringSliceCmd XAutoClaim(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimCmd + XAutoClaimWithDeleted(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimWithDeletedCmd XAutoClaimJustID(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimJustIDCmd XTrimMaxLen(ctx context.Context, key string, maxLen int64) *IntCmd XTrimMaxLenApprox(ctx context.Context, key string, maxLen, limit int64) *IntCmd @@ -359,6 +361,71 @@ func (c cmdable) XAck(ctx context.Context, stream, group string, ids ...string) return cmd } +// XNACK modes. See [XNackArgs.Mode]. +const ( + XNackModeSilent = "SILENT" + XNackModeFail = "FAIL" + XNackModeFatal = "FATAL" +) + +// XNackArgs represents the arguments for the XNACK command (Redis >= 8.8). +// +// XNACK negatively acknowledges one or more messages in a consumer group's +// Pending Entries List (PEL), releasing them back to the group so they can be +// redelivered to another consumer via XREADGROUP. +type XNackArgs struct { + Stream string + Group string + + // Mode controls how the delivery counter is adjusted for each NACKed entry. + // Must be one of [XNackModeSilent], [XNackModeFail], or [XNackModeFatal]: + // - SILENT: the consumer is shutting down or experiencing internal errors + // unrelated to the message. The delivery counter is decremented by 1, + // undoing the increment that happened when the message was delivered. + // - FAIL: the consumer could not process the message (e.g. insufficient + // memory), but another consumer might succeed. The delivery counter is + // left unchanged. + // - FATAL: the message is invalid or suspected malicious. The delivery + // counter is set to MAXINT, which will immediately move the message to + // the Dead Letter Queue (DLQ) if one is configured for the group. + Mode string + + // IDs is the list of message IDs to NACK. All IDs must already be in the + // group's PEL (i.e. previously delivered via XREADGROUP), unless Force is set. + IDs []string + + // RetryCount sets the delivery counter to an explicit value, overriding the + // counter adjustment that would otherwise be applied by Mode. + // Leave nil to let Mode control the counter (the common case). + RetryCount *uint64 + + // Force allows NACKing message IDs that are not yet in the group's PEL, + // creating new unowned NACKed PEL entries for them directly. + // This is analogous to the FORCE flag in XCLAIM. + // Primarily used internally by Redis during AOF rewrite to reconstruct + // NACKed entries, but can also be used to manually inject entries. + Force bool +} + +// XNack executes the XNACK command. See [XNackArgs] for the full argument documentation. +// Requires Redis >= 8.8. +func (c cmdable) XNack(ctx context.Context, a *XNackArgs) *IntCmd { + args := make([]interface{}, 0, 9+len(a.IDs)) + args = append(args, "xnack", a.Stream, a.Group, a.Mode, "ids", len(a.IDs)) + for _, id := range a.IDs { + args = append(args, id) + } + if a.RetryCount != nil { + args = append(args, "retrycount", *a.RetryCount) + } + if a.Force { + args = append(args, "force") + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) XPending(ctx context.Context, stream, group string) *XPendingCmd { cmd := NewXPendingCmd(ctx, "xpending", stream, group) _ = c(ctx, cmd) @@ -406,6 +473,13 @@ func (c cmdable) XAutoClaim(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimC return cmd } +func (c cmdable) XAutoClaimWithDeleted(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimWithDeletedCmd { + args := xAutoClaimArgs(ctx, a) + cmd := NewXAutoClaimWithDeletedCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) XAutoClaimJustID(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimJustIDCmd { args := xAutoClaimArgs(ctx, a) args = append(args, "justid") diff --git a/vendor/github.com/redis/go-redis/v9/string_commands.go b/vendor/github.com/redis/go-redis/v9/string_commands.go index 609a9541d..88a80844e 100644 --- a/vendor/github.com/redis/go-redis/v9/string_commands.go +++ b/vendor/github.com/redis/go-redis/v9/string_commands.go @@ -3,6 +3,7 @@ package redis import ( "context" "fmt" + "strings" "time" ) @@ -20,6 +21,8 @@ type StringCmdable interface { Incr(ctx context.Context, key string) *IntCmd IncrBy(ctx context.Context, key string, value int64) *IntCmd IncrByFloat(ctx context.Context, key string, value float64) *FloatCmd + IncrEXInt(ctx context.Context, key string, args IncrEXIntArgs) *IncrEXIntCmd + IncrEXFloat(ctx context.Context, key string, args IncrEXFloatArgs) *IncrEXFloatCmd LCS(ctx context.Context, q *LCSQuery) *LCSCmd MGet(ctx context.Context, keys ...string) *SliceCmd MSet(ctx context.Context, values ...interface{}) *StatusCmd @@ -197,6 +200,129 @@ func (c cmdable) IncrByFloat(ctx context.Context, key string, value float64) *Fl return cmd } +// IncrEXIntArgs are the arguments to IncrEXInt (the BYINT variant of INCREX). +// +// If By is zero and HasBy is false, the server increments by 1. +// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is a +// valid bound. Expiration is shared with the SET command via ExpirationOption. +type IncrEXIntArgs struct { + By int64 + HasBy bool + + LBound, UBound int64 + HasLBound, HasUBound bool + + // Saturate clamps the result to LBOUND/UBOUND (or LLONG_MAX/MIN when no + // explicit bound is given) when the increment would exceed it. Without + // this flag, out-of-bounds operations are rejected: the key and TTL are + // left unchanged and the reply is [current_value, 0]. + Saturate bool + + // Expiration sets the TTL semantics: EX, PX, EXAT, PXAT, or PERSIST. + Expiration *ExpirationOption + + // ENX applies the expiration only when the key does not already have an + // expiration. Requires Expiration to set one of EX/PX/EXAT/PXAT. + ENX bool +} + +// IncrEXFloatArgs are the arguments to IncrEXFloat (the BYFLOAT variant of +// INCREX). BYFLOAT is always sent โ€” even when By is zero โ€” to keep the +// operation in float mode on the server side; omitting BYFLOAT would cause +// the server to treat the call as an integer increment by 1. +// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is +// a valid bound. +type IncrEXFloatArgs struct { + By float64 + + LBound, UBound float64 + HasLBound, HasUBound bool + + // Saturate clamps the result to LBOUND/UBOUND (or ยฑLDBL_MAX when no + // explicit bound is given) when the increment would exceed it. Without + // this flag, out-of-bounds operations are rejected: the key and TTL are + // left unchanged and the reply is [current_value, 0]. + Saturate bool + + Expiration *ExpirationOption + + ENX bool +} + +// IncrEXInt Redis `INCREX key [BYINT amount] [LBOUND value] [UBOUND value] +// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]` +// command. +// +// Atomically increments the integer value stored at key, optionally +// constraining the result to a range and applying expiration semantics. +// Returns the new value and the increment that was actually applied. When +// the increment would exceed LBOUND/UBOUND and SATURATE is not set, the key +// and TTL are left unchanged and the reply is [current_value, 0]. +// +// Available since Redis 8.8. +// For more information, see https://redis.io/commands/increx +func (c cmdable) IncrEXInt(ctx context.Context, key string, a IncrEXIntArgs) *IncrEXIntCmd { + args := make([]interface{}, 0, 14) + args = append(args, "increx", key) + if a.HasBy { + args = append(args, "byint", a.By) + } + if a.HasLBound { + args = append(args, "lbound", a.LBound) + } + if a.HasUBound { + args = append(args, "ubound", a.UBound) + } + if a.Saturate { + args = append(args, "saturate") + } + args = appendIncrEXTail(args, a.Expiration, a.ENX) + + cmd := NewIncrEXIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// IncrEXFloat Redis `INCREX key [BYFLOAT amount] [LBOUND value] [UBOUND value] +// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]` +// command. +// +// Available since Redis 8.8. +// For more information, see https://redis.io/commands/increx +func (c cmdable) IncrEXFloat(ctx context.Context, key string, a IncrEXFloatArgs) *IncrEXFloatCmd { + args := make([]interface{}, 0, 14) + args = append(args, "increx", key, "byfloat", a.By) + if a.HasLBound { + args = append(args, "lbound", a.LBound) + } + if a.HasUBound { + args = append(args, "ubound", a.UBound) + } + if a.Saturate { + args = append(args, "saturate") + } + args = appendIncrEXTail(args, a.Expiration, a.ENX) + + cmd := NewIncrEXFloatCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func appendIncrEXTail(args []interface{}, exp *ExpirationOption, enx bool) []interface{} { + if exp != nil { + switch exp.Mode { + case EX, PX, EXAT, PXAT: + args = append(args, strings.ToLower(string(exp.Mode)), exp.Value) + case PERSIST: + args = append(args, "persist") + } + } + if enx { + args = append(args, "enx") + } + return args +} + type SetCondition string const ( @@ -219,6 +345,8 @@ const ( PXAT ExpirationMode = "PXAT" // KEEPTTL keeps the existing TTL KEEPTTL ExpirationMode = "KEEPTTL" + // PERSIST removes the existing TTL. Used by INCREX. + PERSIST ExpirationMode = "PERSIST" ) type ExpirationOption struct { diff --git a/vendor/github.com/redis/go-redis/v9/timeseries_commands.go b/vendor/github.com/redis/go-redis/v9/timeseries_commands.go index 15d80168e..db00db80f 100644 --- a/vendor/github.com/redis/go-redis/v9/timeseries_commands.go +++ b/vendor/github.com/redis/go-redis/v9/timeseries_commands.go @@ -2,6 +2,9 @@ package redis import ( "context" + "errors" + "fmt" + "strings" "github.com/redis/go-redis/v9/internal/proto" "github.com/redis/go-redis/v9/internal/util" @@ -139,39 +142,88 @@ func (a Aggregator) String() string { } } +var ( + errTSMultiAggregationGroupBy = errors.New("redis: GROUPBY is not allowed when multiple aggregators are specified") + errTSAggregationConflict = errors.New("redis: setting both Aggregator and Aggregators is not allowed; use Aggregators instead because Aggregator is deprecated") +) + +func formatAggregationArgs(aggregator Aggregator, aggregators []Aggregator) (string, int, error) { + if aggregator != Invalid && len(aggregators) > 0 { + return "", 0, errTSAggregationConflict + } + if len(aggregators) == 0 { + if aggregator == Invalid { + return "", 0, nil + } + aggregationArg, err := formatAggregatorArg(aggregator) + if err != nil { + return "", 0, err + } + return aggregationArg, 1, nil + } + + parts := make([]string, len(aggregators)) + for i, agg := range aggregators { + if agg == Invalid { + return "", 0, fmt.Errorf("redis: invalid timeseries aggregator at index %d: Invalid (%d)", i, agg) + } + aggregationArg, err := formatAggregatorArg(agg) + if err != nil { + return "", 0, fmt.Errorf("redis: invalid timeseries aggregator at index %d: %d", i, agg) + } + parts[i] = aggregationArg + } + + return strings.Join(parts, ","), len(parts), nil +} + +func formatAggregatorArg(aggregator Aggregator) (string, error) { + aggregationArg := aggregator.String() + if aggregationArg == "" { + return "", fmt.Errorf("redis: invalid timeseries aggregator: %d", aggregator) + } + return aggregationArg, nil +} + type TSRangeOptions struct { - Latest bool - FilterByTS []int - FilterByValue []int - Count int - Align interface{} + Latest bool + FilterByTS []int + FilterByValue []int + Count int + Align interface{} + // Deprecated: use Aggregators instead. Aggregator Aggregator + Aggregators []Aggregator BucketDuration int BucketTimestamp interface{} Empty bool } type TSRevRangeOptions struct { - Latest bool - FilterByTS []int - FilterByValue []int - Count int - Align interface{} + Latest bool + FilterByTS []int + FilterByValue []int + Count int + Align interface{} + // Deprecated: use Aggregators instead. Aggregator Aggregator + Aggregators []Aggregator BucketDuration int BucketTimestamp interface{} Empty bool } type TSMRangeOptions struct { - Latest bool - FilterByTS []int - FilterByValue []int - WithLabels bool - SelectedLabels []interface{} - Count int - Align interface{} + Latest bool + FilterByTS []int + FilterByValue []int + WithLabels bool + SelectedLabels []interface{} + Count int + Align interface{} + // Deprecated: use Aggregators instead. Aggregator Aggregator + Aggregators []Aggregator BucketDuration int BucketTimestamp interface{} Empty bool @@ -180,14 +232,16 @@ type TSMRangeOptions struct { } type TSMRevRangeOptions struct { - Latest bool - FilterByTS []int - FilterByValue []int - WithLabels bool - SelectedLabels []interface{} - Count int - Align interface{} + Latest bool + FilterByTS []int + FilterByValue []int + WithLabels bool + SelectedLabels []interface{} + Count int + Align interface{} + // Deprecated: use Aggregators instead. Aggregator Aggregator + Aggregators []Aggregator BucketDuration int BucketTimestamp interface{} Empty bool @@ -483,7 +537,16 @@ func (c cmdable) TSGet(ctx context.Context, key string) *TSTimestampValueCmd { type TSTimestampValue struct { Timestamp int64 Value float64 + Values []float64 +} + +func (tv TSTimestampValue) String() string { + if len(tv.Values) > 0 { + return fmt.Sprintf("{%d %v}", tv.Timestamp, tv.Values) + } + return fmt.Sprintf("{%d %v}", tv.Timestamp, tv.Value) } + type TSTimestampValueCmd struct { baseCmd val TSTimestampValue @@ -541,9 +604,14 @@ func (cmd *TSTimestampValueCmd) readReply(rd *proto.Reader) (err error) { } func (cmd *TSTimestampValueCmd) Clone() Cmder { + val := cmd.val + if cmd.val.Values != nil { + val.Values = make([]float64, len(cmd.val.Values)) + copy(val.Values, cmd.val.Values) + } return &TSTimestampValueCmd{ baseCmd: cmd.cloneBaseCmd(), - val: cmd.val, // TSTimestampValue is a simple struct, can be copied directly + val: val, } } @@ -636,8 +704,14 @@ func (c cmdable) TSRevRangeWithArgs(ctx context.Context, key string, fromTimesta if options.Align != nil { args = append(args, "ALIGN", options.Align) } - if options.Aggregator != 0 { - args = append(args, "AGGREGATION", options.Aggregator.String()) + aggregationArg, _, err := formatAggregationArgs(options.Aggregator, options.Aggregators) + if err != nil { + cmd := newTSTimestampValueSliceCmd(ctx, args...) + cmd.SetErr(err) + return cmd + } + if aggregationArg != "" { + args = append(args, "AGGREGATION", aggregationArg) } if options.BucketDuration != 0 { args = append(args, options.BucketDuration) @@ -692,8 +766,14 @@ func (c cmdable) TSRangeWithArgs(ctx context.Context, key string, fromTimestamp if options.Align != nil { args = append(args, "ALIGN", options.Align) } - if options.Aggregator != 0 { - args = append(args, "AGGREGATION", options.Aggregator.String()) + aggregationArg, _, err := formatAggregationArgs(options.Aggregator, options.Aggregators) + if err != nil { + cmd := newTSTimestampValueSliceCmd(ctx, args...) + cmd.SetErr(err) + return cmd + } + if aggregationArg != "" { + args = append(args, "AGGREGATION", aggregationArg) } if options.BucketDuration != 0 { args = append(args, options.BucketDuration) @@ -748,19 +828,38 @@ func (cmd *TSTimestampValueSliceCmd) readReply(rd *proto.Reader) (err error) { } cmd.val = make([]TSTimestampValue, n) for i := 0; i < n; i++ { - _, _ = rd.ReadArrayLen() - timestamp, err := rd.ReadInt() + itemLen, err := rd.ReadArrayLen() if err != nil { return err } - value, err := rd.ReadString() + + timestamp, err := rd.ReadInt() if err != nil { return err } cmd.val[i].Timestamp = timestamp - cmd.val[i].Value, err = util.ParseStringToFloat(value) - if err != nil { - return err + if itemLen == 2 { + value, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[i].Value, err = util.ParseStringToFloat(value) + if err != nil { + return err + } + continue + } + + cmd.val[i].Values = make([]float64, itemLen-1) + for j := 0; j < itemLen-1; j++ { + value, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[i].Values[j], err = util.ParseStringToFloat(value) + if err != nil { + return err + } } } @@ -772,6 +871,12 @@ func (cmd *TSTimestampValueSliceCmd) Clone() Cmder { if cmd.val != nil { val = make([]TSTimestampValue, len(cmd.val)) copy(val, cmd.val) + for i := range cmd.val { + if cmd.val[i].Values != nil { + val[i].Values = make([]float64, len(cmd.val[i].Values)) + copy(val[i].Values, cmd.val[i].Values) + } + } } return &TSTimestampValueSliceCmd{ baseCmd: cmd.cloneBaseCmd(), @@ -799,6 +904,7 @@ func (c cmdable) TSMRange(ctx context.Context, fromTimestamp int, toTimestamp in // For more information - https://redis.io/commands/ts.mrange/ func (c cmdable) TSMRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRangeOptions) *MapStringSliceInterfaceCmd { args := []interface{}{"TS.MRANGE", fromTimestamp, toTimestamp} + multiAggregationCount := 0 if options != nil { if options.Latest { args = append(args, "LATEST") @@ -828,8 +934,15 @@ func (c cmdable) TSMRangeWithArgs(ctx context.Context, fromTimestamp int, toTime if options.Align != nil { args = append(args, "ALIGN", options.Align) } - if options.Aggregator != 0 { - args = append(args, "AGGREGATION", options.Aggregator.String()) + aggregationArg, count, err := formatAggregationArgs(options.Aggregator, options.Aggregators) + if err != nil { + cmd := NewMapStringSliceInterfaceCmd(ctx, args...) + cmd.SetErr(err) + return cmd + } + multiAggregationCount = count + if aggregationArg != "" { + args = append(args, "AGGREGATION", aggregationArg) } if options.BucketDuration != 0 { args = append(args, options.BucketDuration) @@ -846,6 +959,11 @@ func (c cmdable) TSMRangeWithArgs(ctx context.Context, fromTimestamp int, toTime args = append(args, f) } if options != nil { + if multiAggregationCount > 1 && (options.GroupByLabel != nil || options.Reducer != nil) { + cmd := NewMapStringSliceInterfaceCmd(ctx, args...) + cmd.SetErr(errTSMultiAggregationGroupBy) + return cmd + } if options.GroupByLabel != nil { args = append(args, "GROUPBY", options.GroupByLabel) } @@ -878,6 +996,7 @@ func (c cmdable) TSMRevRange(ctx context.Context, fromTimestamp int, toTimestamp // For more information - https://redis.io/commands/ts.mrevrange/ func (c cmdable) TSMRevRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRevRangeOptions) *MapStringSliceInterfaceCmd { args := []interface{}{"TS.MREVRANGE", fromTimestamp, toTimestamp} + multiAggregationCount := 0 if options != nil { if options.Latest { args = append(args, "LATEST") @@ -907,8 +1026,15 @@ func (c cmdable) TSMRevRangeWithArgs(ctx context.Context, fromTimestamp int, toT if options.Align != nil { args = append(args, "ALIGN", options.Align) } - if options.Aggregator != 0 { - args = append(args, "AGGREGATION", options.Aggregator.String()) + aggregationArg, count, err := formatAggregationArgs(options.Aggregator, options.Aggregators) + if err != nil { + cmd := NewMapStringSliceInterfaceCmd(ctx, args...) + cmd.SetErr(err) + return cmd + } + multiAggregationCount = count + if aggregationArg != "" { + args = append(args, "AGGREGATION", aggregationArg) } if options.BucketDuration != 0 { args = append(args, options.BucketDuration) @@ -925,6 +1051,11 @@ func (c cmdable) TSMRevRangeWithArgs(ctx context.Context, fromTimestamp int, toT args = append(args, f) } if options != nil { + if multiAggregationCount > 1 && (options.GroupByLabel != nil || options.Reducer != nil) { + cmd := NewMapStringSliceInterfaceCmd(ctx, args...) + cmd.SetErr(errTSMultiAggregationGroupBy) + return cmd + } if options.GroupByLabel != nil { args = append(args, "GROUPBY", options.GroupByLabel) } diff --git a/vendor/github.com/redis/go-redis/v9/universal.go b/vendor/github.com/redis/go-redis/v9/universal.go index d1347249d..b623460cb 100644 --- a/vendor/github.com/redis/go-redis/v9/universal.go +++ b/vendor/github.com/redis/go-redis/v9/universal.go @@ -137,6 +137,9 @@ type UniversalOptions struct { // Only applies to cluster clients. Default is 15 seconds. FailingTimeoutSeconds int + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications. diff --git a/vendor/github.com/redis/go-redis/v9/vectorset_commands.go b/vendor/github.com/redis/go-redis/v9/vectorset_commands.go index b91300b6b..a88bb1cd9 100644 --- a/vendor/github.com/redis/go-redis/v9/vectorset_commands.go +++ b/vendor/github.com/redis/go-redis/v9/vectorset_commands.go @@ -15,8 +15,8 @@ type VectorSetCmdable interface { VEmb(ctx context.Context, key, element string, raw bool) *SliceCmd VGetAttr(ctx context.Context, key, element string) *StringCmd VInfo(ctx context.Context, key string) *MapStringInterfaceCmd - VLinks(ctx context.Context, key, element string) *StringSliceCmd - VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceCmd + VLinks(ctx context.Context, key, element string) *StringSliceSliceCmd + VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceSliceCmd VRandMember(ctx context.Context, key string) *StringCmd VRandMemberCount(ctx context.Context, key string, count int) *StringSliceCmd VRem(ctx context.Context, key, element string) *BoolCmd @@ -39,6 +39,11 @@ type Vector interface { const ( vectorFormatFP32 string = "FP32" vectorFormatValues string = "Values" + vectorFormatF16 string = "FLOAT16" + vectorFormatBF16 string = "BFLOAT16" + vectorFormatF64 string = "FLOAT64" + vectorFormatI8 string = "INT8" + vectorFormatU8 string = "UINT8" ) type VectorFP32 struct { @@ -51,6 +56,66 @@ func (v *VectorFP32) Value() []any { var _ Vector = (*VectorFP32)(nil) +// VectorFloat16 represents a FLOAT16-encoded vector blob. +// note: intended for search/index query commands such as FT.HYBRID. +type VectorFloat16 struct { + Val []byte +} + +func (v *VectorFloat16) Value() []any { + return []any{vectorFormatF16, v.Val} +} + +var _ Vector = (*VectorFloat16)(nil) + +// VectorBFloat16 represents a BFLOAT16-encoded vector blob. +// note: intended for search/index query commands such as FT.HYBRID. +type VectorBFloat16 struct { + Val []byte +} + +func (v *VectorBFloat16) Value() []any { + return []any{vectorFormatBF16, v.Val} +} + +var _ Vector = (*VectorBFloat16)(nil) + +// VectorFloat64 represents a FLOAT64-encoded vector blob. +// note: intended for search/index query commands such as FT.HYBRID. +type VectorFloat64 struct { + Val []byte +} + +func (v *VectorFloat64) Value() []any { + return []any{vectorFormatF64, v.Val} +} + +var _ Vector = (*VectorFloat64)(nil) + +// VectorInt8 represents an INT8-encoded vector blob. +// note: intended for search/index query commands such as FT.HYBRID. +type VectorInt8 struct { + Val []byte +} + +func (v *VectorInt8) Value() []any { + return []any{vectorFormatI8, v.Val} +} + +var _ Vector = (*VectorInt8)(nil) + +// VectorUint8 represents a UINT8-encoded vector blob. +// note: intended for search/index query commands such as FT.HYBRID. +type VectorUint8 struct { + Val []byte +} + +func (v *VectorUint8) Value() []any { + return []any{vectorFormatU8, v.Val} +} + +var _ Vector = (*VectorUint8)(nil) + type VectorValues struct { Val []float64 } @@ -207,16 +272,16 @@ func (c cmdable) VInfo(ctx context.Context, key string) *MapStringInterfaceCmd { // `VLINKS key element` // note: the API is experimental and may be subject to change. -func (c cmdable) VLinks(ctx context.Context, key, element string) *StringSliceCmd { - cmd := NewStringSliceCmd(ctx, "vlinks", key, element) +func (c cmdable) VLinks(ctx context.Context, key, element string) *StringSliceSliceCmd { + cmd := NewStringSliceSliceCmd(ctx, "vlinks", key, element) _ = c(ctx, cmd) return cmd } // `VLINKS key element WITHSCORES` // note: the API is experimental and may be subject to change. -func (c cmdable) VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceCmd { - cmd := NewVectorInfoSliceCmd(ctx, "vlinks", key, element, "withscores") +func (c cmdable) VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceSliceCmd { + cmd := NewVectorScoreSliceSliceCmd(ctx, "vlinks", key, element, "withscores") _ = c(ctx, cmd) return cmd } diff --git a/vendor/github.com/redis/go-redis/v9/version.go b/vendor/github.com/redis/go-redis/v9/version.go index 5005aa984..0a4702b18 100644 --- a/vendor/github.com/redis/go-redis/v9/version.go +++ b/vendor/github.com/redis/go-redis/v9/version.go @@ -2,5 +2,5 @@ package redis // Version is the current release version. func Version() string { - return "9.19.0" + return "9.20.0" } diff --git a/vendor/modules.txt b/vendor/modules.txt index b77c2720d..c14141288 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -438,7 +438,7 @@ github.com/prometheus/common/model github.com/prometheus/procfs github.com/prometheus/procfs/internal/fs github.com/prometheus/procfs/internal/util -# github.com/redis/go-redis/v9 v9.19.0 +# github.com/redis/go-redis/v9 v9.20.0 ## explicit; go 1.24 github.com/redis/go-redis/v9 github.com/redis/go-redis/v9/auth @@ -451,7 +451,6 @@ github.com/redis/go-redis/v9/internal/maintnotifications/logs github.com/redis/go-redis/v9/internal/otel github.com/redis/go-redis/v9/internal/pool github.com/redis/go-redis/v9/internal/proto -github.com/redis/go-redis/v9/internal/rand github.com/redis/go-redis/v9/internal/routing github.com/redis/go-redis/v9/internal/util github.com/redis/go-redis/v9/maintnotifications