From 89b361d11b4ee3de47544e74ff29b024f629bc44 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux[bot]" <126015336+red-hat-konflux[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:22:26 +0000 Subject: [PATCH] chore(deps): update module github.com/redis/go-redis/v9 to v9.21.0 Signed-off-by: red-hat-konflux <126015336+red-hat-konflux[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 +- vendor/github.com/redis/go-redis/v9/Makefile | 4 +- vendor/github.com/redis/go-redis/v9/README.md | 33 +- .../redis/go-redis/v9/RELEASE-NOTES.md | 159 +++ .../redis/go-redis/v9/array_commands.go | 387 ++++++++ .../github.com/redis/go-redis/v9/command.go | 820 +++++++++++++++- .../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 | 22 +- .../redis/go-redis/v9/internal/pool/pool.go | 77 +- .../go-redis/v9/internal/proto/reader.go | 265 +++-- .../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 +- .../go-redis/v9/maintnotifications/manager.go | 51 + .../v9/maintnotifications/pool_hook.go | 10 +- .../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 | 61 +- vendor/github.com/redis/go-redis/v9/redis.go | 45 +- vendor/github.com/redis/go-redis/v9/ring.go | 16 +- .../redis/go-redis/v9/search_commands.go | 908 ++++++++++++++++-- .../github.com/redis/go-redis/v9/sentinel.go | 24 +- .../redis/go-redis/v9/sortedset_commands.go | 2 +- .../redis/go-redis/v9/stream_commands.go | 140 ++- .../redis/go-redis/v9/string_commands.go | 169 ++++ .../redis/go-redis/v9/timeseries_commands.go | 209 +++- vendor/github.com/redis/go-redis/v9/tx.go | 51 +- .../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 +- 37 files changed, 3425 insertions(+), 376 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 5a4c6b073..a1675b144 100644 --- a/go.mod +++ b/go.mod @@ -114,7 +114,7 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.19.0 // indirect github.com/redis/go-redis/extra/redisotel/v9 v9.19.0 // indirect - github.com/redis/go-redis/v9 v9.19.0 // indirect + github.com/redis/go-redis/v9 v9.21.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/sigstore/fulcio v1.8.5 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect diff --git a/go.sum b/go.sum index c0d6e70c5..ce2b44a9b 100644 --- a/go.sum +++ b/go.sum @@ -294,8 +294,8 @@ github.com/redis/go-redis/extra/rediscmd/v9 v9.19.0 h1:QL3vQTj64ZQpxiDZx6bFYS7oN github.com/redis/go-redis/extra/rediscmd/v9 v9.19.0/go.mod h1:kGroOkFJzE2Si+mojCi3PCvuAnGnzEh1FAzy1Oh9mI8= github.com/redis/go-redis/extra/redisotel/v9 v9.19.0 h1:yXeFe+EFMUirnzzy8MI5iazoqlpBdzVC6pk+K2Mu7do= github.com/redis/go-redis/extra/redisotel/v9 v9.19.0/go.mod h1:GgAFS1Cg26tQEiHzDd8cHXPKUzzTineQ91Ei9glAxQs= -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.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/vendor/github.com/redis/go-redis/v9/Makefile b/vendor/github.com/redis/go-redis/v9/Makefile index 098ff3cfa..1322bd4f3 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) && \ @@ -75,7 +75,7 @@ bench: export RE_CLUSTER=$(RE_CLUSTER) && \ export RCE_DOCKER=$(RCE_DOCKER) && \ export REDIS_VERSION=$(REDIS_VERSION) && \ - go test ./... -test.run=NONE -test.bench=. -test.benchmem -skip Example + go test ./... -test.run=NONE -test.bench=. -test.benchmem -skip Example -timeout 11m test.e2e: @echo "Running E2E tests with auto-start proxy..." 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..a125344da 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,164 @@ # Release Notes +# 9.21.0 (2026-06-18) + +This is a minor release adding new features and bug fixes. There are no breaking changes; upgrading from 9.20.x is a drop-in replacement. + +## ๐Ÿš€ Highlights + +### Zero-copy `GetToBuffer` / `SetFromBuffer` + +Two new `StringCmdable` methods let callers read and write Redis string values directly into and from pre-allocated byte buffers, eliminating the per-call payload allocation that `Get`/`Set` incur: + +```go +GetToBuffer(ctx, key, buf) *ZeroCopyStringCmd // reads into buf; ZeroCopyStringCmd { Val() int; Bytes() []byte; Result() (int, error) } +SetFromBuffer(ctx, key, buf) *StatusCmd +``` + +`GetToBuffer` decodes the bulk reply straight into the caller-owned `buf` (no intermediate allocation); a buffer that is too small returns an error after draining the payload, so the connection stays aligned for the next reply. `SetFromBuffer` is provided for API symmetry โ€” it dispatches to the same `[]byte` writer path as `Set(ctx, key, buf, 0)` and produces byte-identical output on the wire. Available on `*Client`, `*ClusterClient`, `*Ring`, `*Conn` and `Pipeliner`. + +([#3834](https://github.com/redis/go-redis/pull/3834)) by [@ndyakov](https://github.com/ndyakov) + +### Explicit `LIMIT 0` for stream trimming + +Redis treats `XTRIM`/`XADD` approximate-trim (`~`) `LIMIT 0` as "disable the trimming effort cap entirely", which differs from omitting `LIMIT` (the implicit `100 * stream-node-max-entries` default). The command builders previously only emitted `LIMIT` when `limit > 0`, so callers could never send an explicit `LIMIT 0`. Following the `KeepTTL = -1` precedent, the new `XTrimLimitDisabled = -1` sentinel now emits an explicit `LIMIT 0`; `limit == 0` keeps the historical no-`LIMIT` behavior, so existing callers produce byte-identical commands. + +([#3848](https://github.com/redis/go-redis/pull/3848)) by [@TheRealMal](https://github.com/TheRealMal) + +## โœจ New Features + +- **Zero-copy buffer string commands**: new `GetToBuffer` / `SetFromBuffer` on `StringCmdable` and the `ZeroCopyStringCmd` result type, reading/writing string values into caller-owned buffers without per-call payload allocation ([#3834](https://github.com/redis/go-redis/pull/3834)) by [@ndyakov](https://github.com/ndyakov) +- **`XTrimLimitDisabled` sentinel**: `XTRIM`/`XADD` approximate trimming can now send an explicit `LIMIT 0` to disable the trim effort cap, via the new `XTrimLimitDisabled = -1` sentinel ([#3848](https://github.com/redis/go-redis/pull/3848)) by [@TheRealMal](https://github.com/TheRealMal) +- **PubSub health-check timeouts**: `channel.initHealthCheck` now bounds the `Ping` it issues with a fresh per-check timeout context (the exported `pingTimeout` / `reconnectTimeout`) instead of `context.TODO()`, so a stuck health-check Ping can no longer block indefinitely ([#3819](https://github.com/redis/go-redis/pull/3819)) by [@abdellani](https://github.com/abdellani) +- **Skip redundant `UNWATCH` in `Tx.Close`**: a transaction now tracks whether a `WATCH` is still active (`watchArmed`) and only issues `UNWATCH` on `Close` when it is, removing an extra round trip on the common `WATCH`/.../`EXEC` and no-key `Watch` paths while never returning a connection to the pool with an active watch ([#3854](https://github.com/redis/go-redis/pull/3854)) by [@fcostaoliveira](https://github.com/fcostaoliveira) + +## ๐Ÿ› Bug Fixes + +- **`maintnotifications` `ModeAuto` fail-open**: `ModeAuto` now stays fail-open when the server does not support maintenance notifications โ€” connections are retired and tracking is guarded during downgrade so the client keeps working instead of erroring ([#3853](https://github.com/redis/go-redis/pull/3853)) by [@terrorobe](https://github.com/terrorobe) + +## ๐Ÿ‘ฅ Contributors + +We'd like to thank all the contributors who worked on this release! + +[@abdellani](https://github.com/abdellani), [@fcostaoliveira](https://github.com/fcostaoliveira), [@ndyakov](https://github.com/ndyakov), [@terrorobe](https://github.com/terrorobe), [@TheRealMal](https://github.com/TheRealMal) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.20.1...v9.21.0 + +# 9.20.1 (2026-06-11) + +This is a patch release containing bug fixes only. There are no new features or breaking changes; upgrading from 9.20.0 is a drop-in replacement. + +## ๐Ÿš€ Highlights + +### RESP3 pub/sub message loss fixed + +`PeekPushNotificationName` previously inspected only the bytes already buffered by `bufio`, so when a push frame header straddled a buffer fill boundary it could return a **truncated** notification name (e.g. `"messa"` instead of `"message"`). The push processor then mis-routed the frame and `ReadReply` silently dropped it, causing intermittent RESP3 pub/sub message loss. The peek now grows its window (36 bytes โ†’ up to 4 KiB) and reads more from the connection until the header is complete, cleanly separating incomplete prefixes from corrupt frames (including overflow-safe bulk-length handling). Fixes [#3839](https://github.com/redis/go-redis/issues/3839). + +([#3842](https://github.com/redis/go-redis/pull/3842)) by [@ndyakov](https://github.com/ndyakov) + +## ๐Ÿ› Bug Fixes + +- **RESP3 push peeking**: `PeekPushNotificationName` no longer returns a truncated notification name when a push frame header spans a buffer boundary, preventing silent RESP3 pub/sub message loss (fixes [#3839](https://github.com/redis/go-redis/issues/3839)) ([#3842](https://github.com/redis/go-redis/pull/3842)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.HYBRID` vector params**: Vector data is now always sent via `PARAMS` with auto-generated param names (`__vector_param_N`, with collision avoidance) when `VectorParamName` is omitted, since Redis no longer accepts inline vector blobs; the `FTHybridOptions.Params` map is no longer mutated, so the same options struct can be reused across calls ([#3844](https://github.com/redis/go-redis/pull/3844)) by [@ndyakov](https://github.com/ndyakov) +- **`CLUSTER SHARDS` forward compatibility**: Unknown shard- and node-level attributes in the `CLUSTER SHARDS` reply are now skipped via `DiscardNext()` instead of erroring, so clients keep working when the server introduces new fields ([#3843](https://github.com/redis/go-redis/pull/3843)) by [@madolson](https://github.com/madolson) +- **PubSub double reconnect**: `PubSub.releaseConn` no longer reconnects twice when a connection is both unusable (or pending handoff) and reports a bad-connection error, avoiding a wasted connection establish-then-close cycle ([#3833](https://github.com/redis/go-redis/pull/3833)) by [@cxljs](https://github.com/cxljs) + +## ๐Ÿ‘ฅ Contributors + +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@madolson](https://github.com/madolson), [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.20.0...v9.20.1 + +# 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..ae0158b3a 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 } @@ -862,6 +880,115 @@ func (cmd *RawWriteToCmd) Clone() Cmder { //------------------------------------------------------------------------------ +// ZeroCopyStringCmd reads a bulk string response directly into a user-provided +// buffer, avoiding intermediate allocations. The RESP header is parsed through +// the buffered reader, then bulk data is read straight into the caller's buffer +// via proto.Reader.ReadStringInto โ€” for values larger than the bufio buffer, +// this is effectively zero-copy from the socket to the user buffer. +// +// The buffer must be sized to fit the value; if it is too small an error is +// returned and the payload plus trailing CRLF are drained from the reader so +// the connection stays aligned for subsequent commands. +type ZeroCopyStringCmd struct { + baseCmd + buf []byte // user-provided buffer to read into + n int // number of bytes read into buf + cloned bool // set by Clone(); causes readReply to drain + error +} + +var _ Cmder = (*ZeroCopyStringCmd)(nil) + +func NewZeroCopyStringCmd(ctx context.Context, buf []byte, args ...interface{}) *ZeroCopyStringCmd { + return &ZeroCopyStringCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeString, + }, + buf: buf, + } +} + +func (cmd *ZeroCopyStringCmd) SetVal(n int) { + cmd.n = n +} + +func (cmd *ZeroCopyStringCmd) Val() int { + return cmd.n +} + +// Result returns the number of bytes read and any error. +func (cmd *ZeroCopyStringCmd) Result() (int, error) { + return cmd.n, cmd.err +} + +// Bytes returns the slice of the user-provided buffer containing the read data. +func (cmd *ZeroCopyStringCmd) Bytes() []byte { + return cmd.buf[:cmd.n] +} + +func (cmd *ZeroCopyStringCmd) String() string { + return cmdString(cmd, cmd.n) +} + +func (cmd *ZeroCopyStringCmd) readReply(rd *proto.Reader) error { + // Reset the byte count before reading so a previous successful run + // can't leak its data through Bytes() if this call errors out before + // updating cmd.n. + cmd.n = 0 + if cmd.cloned { + // A cloned ZeroCopyStringCmd has no usable destination buffer + // (see Clone for the rationale). Drain the network reply so the + // connection stays aligned for the next command, then surface a + // clear error rather than silently producing a wrong result. + if err := rd.DiscardNext(); err != nil { + return err + } + return fmt.Errorf("redis: ZeroCopyStringCmd cannot be cloned (cmd writes into caller-owned memory)") + } + n, err := rd.ReadStringInto(cmd.buf) + if err != nil { + return err + } + cmd.n = n + return nil +} + +// NoRetry returns true because the response is written directly into the +// caller's buffer. A retry could leave partial data from a failed attempt in +// the buffer, so the caller must handle retries explicitly if needed. +func (cmd *ZeroCopyStringCmd) NoRetry() bool { + return true +} + +// Clone returns a clone that is intentionally non-functional. Cloning a +// ZeroCopyStringCmd has no well-defined semantics: the cmd writes into +// caller-owned memory (the buf passed to GetToBuffer), and a clone can +// neither safely share that buf (concurrent writes from sibling clones +// would race, last-writer wins) nor allocate its own buf (the result +// would be invisible to whoever asked for the original cmd's reply). +// +// The Cmder interface requires Clone, so we return a clone marked so +// that its readReply drains the network reply (keeping the connection +// aligned) and fails the cmd with a clear error. Whoever processes the +// clone gets an explicit error instead of silently-wrong bytes. +// +// In practice this path is unreachable through normal client flows: +// Clone is only called from cluster fan-out routing +// (osscluster_router.go) for multi-shard commands like DBSIZE / KEYS / +// FLUSHDB, and ZeroCopyStringCmd is only produced by GetToBuffer which +// issues GET โ€” a single-key command routed to one shard, never fanned +// out. Combined with NoRetry() returning true, the retry path also will +// not clone this cmd. +func (cmd *ZeroCopyStringCmd) Clone() Cmder { + return &ZeroCopyStringCmd{ + baseCmd: cmd.cloneBaseCmd(), + cloned: true, + } +} + +//------------------------------------------------------------------------------ + type SliceCmd struct { baseCmd @@ -1037,6 +1164,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 +1341,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 +1878,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 +2982,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 +3013,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 +5203,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 +5243,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 @@ -6793,7 +7234,9 @@ func (cmd *ClusterShardsCmd) readReply(rd *proto.Reader) error { case "health": cmd.val[i].Nodes[k].Health, err = rd.ReadString() default: - return fmt.Errorf("redis: unexpected key %q in CLUSTER SHARDS node reply", nodeKey) + if err = rd.DiscardNext(); err != nil { + return err + } } if err != nil { @@ -6802,7 +7245,9 @@ func (cmd *ClusterShardsCmd) readReply(rd *proto.Reader) error { } } default: - return fmt.Errorf("redis: unexpected key %q in CLUSTER SHARDS reply", key) + if err = rd.DiscardNext(); err != nil { + return err + } } } } @@ -7711,6 +8156,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 +8507,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 +8592,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 +8707,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 +8940,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 +8975,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 +9033,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..1e836ad44 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 @@ -112,6 +114,11 @@ type Conn struct { // to inform the goroutine using the connection why the connection was closed. closeReason uberatomic.String + // closeOnPutReason marks an in-use connection for removal when it is returned + // to the pool. The socket is left open for the in-flight command and closed + // by ConnPool.Put. + closeOnPutReason uberatomic.String + // maintenanceNotifications upgrade support: relaxed timeouts during migrations/failovers // Using atomic operations for lock-free access to avoid mutex contention @@ -435,6 +442,17 @@ func (cn *Conn) IsPooled() bool { return cn.pooled } +// MarkCloseOnPut marks the connection for removal when it is returned to the pool. +func (cn *Conn) MarkCloseOnPut(reason string) { + cn.closeOnPutReason.Store(reason) +} + +// CloseOnPutReason returns a non-empty reason when the connection should be +// removed instead of pooled on Put. +func (cn *Conn) CloseOnPutReason() string { + return cn.closeOnPutReason.Load() +} + // IsPubSub returns true if the connection is used for PubSub. func (cn *Conn) IsPubSub() bool { return cn.pubsub 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..8f648ffbf 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. @@ -33,6 +33,11 @@ const ( // CloseReasonFailover indicates the connection was closed due to a failover event. CloseReasonFailover = "failover" + + // CloseReasonMaintNotificationsDisabled indicates the connection enabled + // maintenance notifications, but the client later downgraded and disabled + // maintenance notification handling for the pool. + CloseReasonMaintNotificationsDisabled = "maintnotifications_disabled" ) // Metric state constants for connection state tracking. @@ -315,6 +320,10 @@ type Stats struct { PubSubStats PubSubStats } +type ConnRetirer interface { + RetireConns(ctx context.Context, conns []*Conn, reason string) +} + type Pooler interface { NewConn(context.Context) (*Conn, error) CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error @@ -388,7 +397,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 +428,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(), @@ -1219,6 +1226,11 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { shouldRemove := false var err error + if reason := cn.CloseOnPutReason(); reason != "" { + p.removeConnInternal(ctx, cn, errors.New(reason), freeTurn) + return + } + if cn.HasBufferedData() { // Peek at the reply type to check if it's a push notification if replyType, err := cn.PeekReplyTypeSafe(); err != nil || replyType != proto.RespPush { @@ -1447,6 +1459,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. @@ -1560,6 +1576,47 @@ func (p *ConnPool) closed() bool { return atomic.LoadUint32(&p._closed) == 1 } +func (p *ConnPool) RetireConns(ctx context.Context, conns []*Conn, reason string) { + if len(conns) == 0 { + return + } + + idleConnSet := make(map[*Conn]struct{}) + toClose := make([]*Conn, 0, len(conns)) + + p.connsMu.Lock() + for _, ic := range p.idleConns { + idleConnSet[ic] = struct{}{} + } + for _, cn := range conns { + if cn == nil { + continue + } + if _, ok := p.conns[cn.GetID()]; !ok { + continue + } + if _, isIdle := idleConnSet[cn]; isIdle { + if p.removeConn(cn) { + toClose = append(toClose, cn) + } + continue + } + cn.MarkCloseOnPut(reason) + } + p.connsMu.Unlock() + + if hookManager := p.hookManager.Load(); hookManager != nil { + for _, cn := range toClose { + hookManager.ProcessOnRemove(ctx, cn, errors.New(reason)) + } + } + for _, cn := range toClose { + p.recordConnectionMetrics(ctx, cn, reason, MetricStateIdle) + _ = p.closeConn(cn) + } + p.checkMinIdleConns() +} + func (p *ConnPool) Filter(fn func(*Conn) bool) error { ctx := context.Background() @@ -1616,9 +1673,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/proto/reader.go b/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go index 79373d3af..33b027f79 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go +++ b/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go @@ -100,9 +100,20 @@ func (r *Reader) PeekReplyType() (byte, error) { return b[0], nil } +// PeekPushNotificationName returns the notification name of the next RESP3 +// push frame without consuming it. The caller is expected to have already +// verified that the next reply is a push notification (e.g. via PeekReplyType +// returning RespPush). +// +// To identify the name the method may block briefly reading more bytes from +// the underlying connection. That is safe: once the push marker '>' has been +// observed, the server is committed to sending the rest of the frame, so +// fetching the next few header bytes does not race with anything the caller +// could be waiting on. Blocking is preferred to a truncated peek, which would +// silently misidentify the notification and cause the caller's ReadReply to +// consume (and drop) the frame; see issue #3839. func (r *Reader) PeekPushNotificationName() (string, error) { - // "prime" the buffer by peeking at the next byte - c, err := r.Peek(1) + c, err := r.rd.Peek(1) if err != nil { return "", err } @@ -110,80 +121,138 @@ func (r *Reader) PeekPushNotificationName() (string, error) { return "", fmt.Errorf("redis: can't peek push notification name, next reply is not a push notification") } - // peek 36 bytes at most, should be enough to read the push notification name - toPeek := 36 - buffered := r.Buffered() - if buffered == 0 { - return "", fmt.Errorf("redis: can't peek push notification name, no data available") - } - if buffered < toPeek { - toPeek = buffered + // Start with a peek window that covers every Redis-defined notification + // header (MOVING, MIGRATING, FAILED_OVER, message, pmessage, smessage, + // subscribe, unsubscribe, ...). If a longer name is encountered, grow + // the window up to maxPushHeaderPeek before giving up. + const initialPeek = 36 + const maxPushHeaderPeek = 4096 + + peekSize := initialPeek + for { + buf, peekErr := r.rd.Peek(peekSize) + name, complete, parseErr := parsePushNotificationName(buf) + if parseErr != nil { + return "", parseErr + } + if complete { + return name, nil + } + // Parser ran out of bytes. Surface a failed underlying read before + // growing further; otherwise grow the peek window and retry. + if peekErr != nil { + return "", peekErr + } + if peekSize >= maxPushHeaderPeek { + return "", fmt.Errorf("redis: push notification header exceeds %d bytes", maxPushHeaderPeek) + } + peekSize *= 2 + if peekSize > maxPushHeaderPeek { + peekSize = maxPushHeaderPeek + } } - buf, err := r.rd.Peek(toPeek) - if err != nil { - return "", err +} + +// parsePushNotificationName extracts the notification name from a buffered +// RESP3 push frame prefix. The three return values are: +// +// - (name, true, nil): the full name is in buf. +// - ("", false, nil): buf is a valid prefix but too short to determine the +// name; the caller should fetch more bytes and retry. +// - ("", _, err): buf is malformed. +// +// This split lets PeekPushNotificationName tell "incomplete header" apart +// from "corrupt frame" without ever returning a truncated string. +func parsePushNotificationName(buf []byte) (string, bool, error) { + // Need at least ">N\r" before any meaningful work. + if len(buf) < 3 { + return "", false, nil } if buf[0] != RespPush { - return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + return "", false, fmt.Errorf("redis: can't parse push notification: %q", buf) } - if len(buf) < 3 { - return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + // Skip the array length line ">N\r\n". + const arrayLenStart = 1 // first byte after the '>' marker + pos, ok, err := skipDigitsThenCRLF(buf, arrayLenStart) + if err != nil { + return "", false, fmt.Errorf("redis: can't parse push notification: %w", err) } - - // remove push notification type - buf = buf[1:] - // remove first line - e.g. >2\r\n - for i := 0; i < len(buf)-1; i++ { - if buf[i] == '\r' && buf[i+1] == '\n' { - buf = buf[i+2:] - break - } else { - if buf[i] < '0' || buf[i] > '9' { - return "", fmt.Errorf("redis: can't parse push notification: %q", buf) - } - } + if !ok { + return "", false, nil + } + // Reject ">\r\n": RESP requires at least one digit for the array length. + // Without this check the empty length looks like a valid prefix and the + // caller would block fetching more bytes for a frame that is already + // malformed. + if pos-2 == arrayLenStart { + return "", false, fmt.Errorf("redis: empty push notification array length") } - if len(buf) < 2 { - return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + + // First element type byte: '$' (bulk) or '+' (simple-string). + if pos >= len(buf) { + return "", false, nil } - // next line should be $\r\n or +\r\n - // should have the type of the push notification name and it's length - if buf[0] != RespString && buf[0] != RespStatus { - return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) + typeOfName := buf[pos] + if typeOfName != RespString && typeOfName != RespStatus { + return "", false, fmt.Errorf("redis: can't parse push notification name: %q", buf[pos:]) } - typeOfName := buf[0] - // remove the type of the push notification name - buf = buf[1:] + pos++ + if typeOfName == RespString { - // remove the length of the string - if len(buf) < 2 { - return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) - } - for i := 0; i < len(buf)-1; i++ { - if buf[i] == '\r' && buf[i+1] == '\n' { - buf = buf[i+2:] - break - } else { - if buf[i] < '0' || buf[i] > '9' { - return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) - } - } + // Read "$M\r\n" then the M-byte name. + lenStart := pos + next, ok, err := skipDigitsThenCRLF(buf, pos) + if err != nil { + return "", false, fmt.Errorf("redis: can't parse push notification name length: %w", err) } + if !ok { + return "", false, nil + } + if next-2 == lenStart { + return "", false, fmt.Errorf("redis: empty push notification name length") + } + nameLen, err := util.Atoi(buf[lenStart : next-2]) + if err != nil { + return "", false, fmt.Errorf("redis: invalid push notification name length %q: %w", buf[lenStart:next-2], err) + } + if nameLen < 0 { + return "", false, fmt.Errorf("redis: negative push notification name length: %d", nameLen) + } + // Compare against the remaining bytes instead of computing + // next+nameLen: a hugely advertised length on malformed input could + // overflow int, wrap negative, slip past an "end > len(buf)" guard and + // panic the slice below. next <= len(buf) here, so the subtraction is + // safe. + if nameLen > len(buf)-next { + return "", false, nil + } + return util.BytesToString(buf[next : next+nameLen]), true, nil } - if len(buf) < 2 { - return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) - } - // keep only the notification name - for i := 0; i < len(buf)-1; i++ { + // RespStatus: scan for the terminating CRLF. + for i := pos; i < len(buf)-1; i++ { if buf[i] == '\r' && buf[i+1] == '\n' { - buf = buf[:i] - break + return util.BytesToString(buf[pos:i]), true, nil } } + return "", false, nil +} - return util.BytesToString(buf), nil +// skipDigitsThenCRLF advances past zero-or-more ASCII digits and the +// terminating "\r\n" starting at offset start in buf. It returns the position +// after the "\r\n" and true on success; (pos, false, nil) if buf is too +// short; or an error if a non-digit non-CR byte is encountered before the CRLF. +func skipDigitsThenCRLF(buf []byte, start int) (int, bool, error) { + for pos := start; pos < len(buf)-1; pos++ { + if buf[pos] == '\r' && buf[pos+1] == '\n' { + return pos + 2, true, nil + } + if buf[pos] < '0' || buf[pos] > '9' { + return pos, false, fmt.Errorf("expected digit or CRLF, got %q", buf[pos]) + } + } + return len(buf), false, nil } // ReadLine Return a valid reply, it will check the protocol or redis error, @@ -464,6 +533,84 @@ func (r *Reader) ReadFloat() (float64, error) { return 0, fmt.Errorf("redis: can't parse float reply: %.100q", line) } +// ReadStringInto reads a string-typed reply directly into buf, avoiding the +// per-call allocation that ReadString incurs. It returns the number of bytes +// written to buf. +// +// Supported reply types: +// - $\r\n\r\n bulk string (the GET path; payload is read +// straight into buf via bufio.Reader โ€” for payloads larger than the +// bufio buffer this is effectively zero-copy from the socket) +// - +\r\n simple string, copied from the header line +// - :\r\n integer, copied as its ASCII representation +// - ,\r\n float, copied as its ASCII representation +// +// Errors, nil, push notifications, and RESP3 attributes are intercepted +// by ReadLine and surfaced through err. RESP3 verbatim strings +// (=\r\n\r\n) are intentionally not handled โ€” they are +// never returned by GET-family commands, and including them re-introduces +// a hazard class where the response-type byte read from a stale `line[0]` +// after a bufio refill can be misinterpreted as the verbatim format tag. +// +// If the bulk payload does not fit in buf, an error is returned and the +// payload plus the trailing CRLF are drained from the reader so the +// connection stays aligned for the next reply. For simple-string / integer +// / float responses the payload lives in the (already-consumed) header +// line, so no drain is needed. +func (r *Reader) ReadStringInto(buf []byte) (int, error) { + line, err := r.ReadLine() + if err != nil { + return 0, err + } + + switch line[0] { + case RespStatus: + // Simple string โ€” data is in the line itself. + s := line[1:] + if len(s) > len(buf) { + return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", len(s), len(buf)) + } + return copy(buf, s), nil + + case RespString: + n, err := replyLen(line) + if err != nil { + return 0, err + } + if n > len(buf) { + // Drain the payload + trailing \r\n so the next read on this + // connection sees the start of the next reply rather than the + // tail of this one. Otherwise the unread bytes corrupt the + // stream and the bad connection gets handed back to the pool. + if _, derr := r.rd.Discard(n + 2); derr != nil { + return 0, derr + } + return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", n, len(buf)) + } + // Read data directly into the user's buffer through the bufio.Reader. + // bufio.Reader.Read first drains its internal buffer, then for + // remaining data larger than its buffer size reads directly from the + // underlying reader (socket) โ€” effectively zero-copy. + if _, err := io.ReadFull(r.rd, buf[:n]); err != nil { + return 0, err + } + // Discard trailing \r\n. + if _, err := r.rd.Discard(2); err != nil { + return 0, err + } + return n, nil + + case RespInt, RespFloat: + s := line[1:] + if len(s) > len(buf) { + return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", len(s), len(buf)) + } + return copy(buf, s), nil + } + + return 0, fmt.Errorf("redis: can't parse reply=%.100q reading string into buffer", line) +} + func (r *Reader) ReadString() (string, error) { line, err := r.ReadLine() if err != nil { 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/maintnotifications/manager.go b/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go index 3f9478e1b..ff54c717d 100644 --- a/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go +++ b/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go @@ -82,6 +82,10 @@ type Manager struct { hooksMu sync.RWMutex // Protects hooks slice poolHooksRef *PoolHook + // Connections that successfully enabled maintnotifications. These need to be + // retired before the pool-level listeners are removed. + maintNotificationsConns sync.Map // connID -> *pool.Conn + // Cluster state reload callback for SMIGRATED notifications clusterStateReloadCallback ClusterStateReloadCallback } @@ -251,6 +255,49 @@ func (hm *Manager) MarkSMigratedSeqIDProcessed(seqID int64) bool { return !alreadyProcessed // Return true if NOT already processed } +// TrackMaintNotificationsConn records a connection that successfully enabled +// maintnotifications so it can be retired if the feature is later disabled for +// the pool. +func (hm *Manager) TrackMaintNotificationsConn(cn *pool.Conn) { + if cn == nil { + return + } + hm.maintNotificationsConns.Store(cn.GetID(), cn) +} + +// UntrackMaintNotificationsConn removes a connection from the enabled +// maintnotifications set. +func (hm *Manager) UntrackMaintNotificationsConn(connID uint64) { + hm.maintNotificationsConns.Delete(connID) +} + +func (hm *Manager) maintNotificationsConnSnapshot() []*pool.Conn { + var conns []*pool.Conn + hm.maintNotificationsConns.Range(func(_, value interface{}) bool { + if cn, ok := value.(*pool.Conn); ok { + conns = append(conns, cn) + } + return true + }) + return conns +} + +func (hm *Manager) retireMaintNotificationsConns(ctx context.Context) { + conns := hm.maintNotificationsConnSnapshot() + if len(conns) == 0 || hm.pool == nil { + return + } + + if retirer, ok := hm.pool.(pool.ConnRetirer); ok { + retirer.RetireConns(ctx, conns, pool.CloseReasonMaintNotificationsDisabled) + return + } + + for _, cn := range conns { + _ = hm.pool.CloseConn(ctx, cn, pool.CloseReasonMaintNotificationsDisabled, pool.MetricStateIdle) + } +} + // Close closes the manager. func (hm *Manager) Close() error { // Use atomic operation for thread-safe close check @@ -258,6 +305,10 @@ func (hm *Manager) Close() error { return nil // Already closed } + // Retire connections that enabled maintnotifications before removing the + // pool-level listeners that process those push notifications. + hm.retireMaintNotificationsConns(context.Background()) + // Shutdown the pool hook if it exists if hm.poolHooksRef != nil { // Use a timeout to prevent hanging indefinitely diff --git a/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go b/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go index 9ea0558bf..752abc711 100644 --- a/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go +++ b/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go @@ -17,6 +17,10 @@ type OperationsManagerInterface interface { UntrackOperationWithConnID(seqID int64, connID uint64) } +type maintNotificationsConnTracker interface { + UntrackMaintNotificationsConn(connID uint64) +} + // HandoffRequest represents a request to handoff a connection to a new endpoint type HandoffRequest struct { Conn *pool.Conn @@ -172,8 +176,10 @@ func (ph *PoolHook) OnPut(ctx context.Context, conn *pool.Conn) (shouldPool bool return true, false, nil } -func (ph *PoolHook) OnRemove(_ context.Context, _ *pool.Conn, _ error) { - // Not used +func (ph *PoolHook) OnRemove(_ context.Context, conn *pool.Conn, _ error) { + if tracker, ok := ph.operationsManager.(maintNotificationsConnTracker); ok && conn != nil { + tracker.UntrackMaintNotificationsConn(conn.GetID()) + } } // Shutdown gracefully shuts down the processor, waiting for workers to complete 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..f9c30a639 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) @@ -164,6 +173,7 @@ func (c *PubSub) releaseConn(ctx context.Context, cn *pool.Conn, err error, allo if !cn.IsUsable() || cn.ShouldHandoff() { c.reconnect(ctx, fmt.Errorf("pubsub: connection is not usable")) + return } if isBadConn(err, allowTimeout, c.opt.Addr) { @@ -649,6 +659,25 @@ func WithChannelSendTimeout(d time.Duration) ChannelOption { } } +// WithChannelPingTimeout specifies the timeout for the health-check ping. +// +// The default is 5 seconds. +func WithChannelPingTimeout(d time.Duration) ChannelOption { + return func(c *channel) { + c.pingTimeout = d + } +} + +// WithChannelReconnectTimeout specifies the timeout for reconnecting after +// a failed health-check ping. +// +// The default is 10 seconds. +func WithChannelReconnectTimeout(d time.Duration) ChannelOption { + return func(c *channel) { + c.reconnectTimeout = d + } +} + type channel struct { pubSub *PubSub @@ -656,18 +685,22 @@ type channel struct { allCh chan interface{} ping chan struct{} - chanSize int - chanSendTimeout time.Duration - checkInterval time.Duration + chanSize int + chanSendTimeout time.Duration + checkInterval time.Duration + pingTimeout time.Duration + reconnectTimeout time.Duration } func newChannel(pubSub *PubSub, opts ...ChannelOption) *channel { c := &channel{ pubSub: pubSub, - chanSize: 100, - chanSendTimeout: time.Minute, - checkInterval: 3 * time.Second, + chanSize: 100, + chanSendTimeout: time.Minute, + checkInterval: 3 * time.Second, + pingTimeout: 5 * time.Second, + reconnectTimeout: 10 * time.Second, } for _, opt := range opts { opt(c) @@ -679,7 +712,6 @@ func newChannel(pubSub *PubSub, opts ...ChannelOption) *channel { } func (c *channel) initHealthCheck() { - ctx := context.TODO() c.ping = make(chan struct{}, 1) go func() { @@ -690,13 +722,20 @@ func (c *channel) initHealthCheck() { timer.Reset(c.checkInterval) select { case <-c.ping: - if !timer.Stop() { - <-timer.C + select { + case <-timer.C: + default: } case <-timer.C: - if pingErr := c.pubSub.Ping(ctx); pingErr != nil { + ctx, cancel := context.WithTimeout(context.Background(), c.pingTimeout) + pingErr := c.pubSub.Ping(ctx) + cancel() + + if pingErr != nil { c.pubSub.mu.Lock() - c.pubSub.reconnect(ctx, pingErr) + reconnectCtx, reconnectCancel := context.WithTimeout(context.Background(), c.reconnectTimeout) + c.pubSub.reconnect(reconnectCtx, pingErr) + reconnectCancel() c.pubSub.mu.Unlock() } case <-c.pubSub.exit: diff --git a/vendor/github.com/redis/go-redis/v9/redis.go b/vendor/github.com/redis/go-redis/v9/redis.go index 923bad62a..3bb759c34 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. @@ -693,11 +701,24 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { var maintNotifHandshakeErr error if maintNotifEnabled && protocol == 3 { + // Hold the manager read lock across the handshake and tracking so a + // concurrent downgrade cannot remove pool-level listeners before a + // successfully enabled connection is tracked for retirement. + c.maintNotificationsManagerLock.RLock() + manager := c.maintNotificationsManager maintNotifHandshakeErr = conn.ClientMaintNotifications( ctx, true, endpointType.String(), ).Err() + // A successful handshake enables maintnotifications for this connection, + // but must not promote ModeAuto to ModeEnabled. ModeEnabled is the + // explicit fail-closed policy; ModeAuto must remain able to downgrade if a + // later reconnect/failover reaches an endpoint that rejects the command. + if maintNotifHandshakeErr == nil && manager != nil { + manager.TrackMaintNotificationsConn(cn) + } + c.maintNotificationsManagerLock.RUnlock() if maintNotifHandshakeErr != nil { if !isRedisError(maintNotifHandshakeErr) { // if not redis error, fail the connection @@ -730,13 +751,6 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { internal.Logger.Printf(ctx, "failed to disable maintnotifications in auto mode: %v", initErr) } } - } else { - // handshake was executed successfully - // to make sure that the handshake will be executed on other connections as well if it was successfully - // executed on this connection, we will force the handshake to be executed on all connections - c.optLock.Lock() - c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeEnabled - c.optLock.Unlock() } } @@ -936,16 +950,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) { @@ -1068,7 +1075,9 @@ func (c *baseClient) disableMaintNotificationsUpgrades() error { if c.maintNotificationsManager != nil { // Closing the manager will also shutdown the pool hook // and remove it from the pool - c.maintNotificationsManager.Close() + if err := c.maintNotificationsManager.Close(); err != nil { + return err + } c.maintNotificationsManager = nil } return nil 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..b13aa5bef 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" @@ -407,11 +408,13 @@ const ( type FTHybridVectorExpression struct { VectorField string VectorData Vector - // VectorParamName specifies the parameter name for passing vector data via PARAMS mechanism. - // REQUIRED for Redis 8.6+ (inline vector blobs are not supported in 8.6+). - // Optional for Redis 8.4-8.5 (both inline and PARAMS are supported). - // When set, the vector blob will be passed as: VSIM @field $VectorParamName PARAMS ... VectorParamName - // When empty, the vector blob will be inlined: VSIM @field (fails on Redis 8.6+) + // VectorParamName optionally specifies the parameter name used to pass the + // vector data via the PARAMS mechanism. + // Vector data is always passed via PARAMS because inline vector blobs are no + // longer supported by Redis. When left empty, the library generates a unique + // parameter name automatically (e.g. "__vector_param_0") without mutating + // FTHybridOptions.Params and without colliding with any explicit names. + // The vector blob is passed as: VSIM @field $VectorParamName ... PARAMS ... VectorParamName VectorParamName string Method FTHybridVectorMethod MethodParams []interface{} @@ -481,9 +484,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 +619,9 @@ type SpellCheckSuggestion struct { } type FTSearchResult struct { - Total int - Docs []Document + Total int + Docs []Document + Warnings []string } type Document struct { @@ -949,15 +956,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 +1099,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 +1689,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 +2014,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 +2062,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 +2095,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 +2150,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 +2207,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 +2353,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 +2669,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 +2831,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 +3484,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 +3544,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,8 +3666,68 @@ 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 +} + +// generateVectorParamName returns a parameter name that is not already present +// in params. It is used to pass vector data via the PARAMS mechanism when the +// caller does not provide a VectorParamName, since inline vector blobs are no +// longer supported by Redis. +func generateVectorParamName(params map[string]interface{}) string { + for i := 0; ; i++ { + name := fmt.Sprintf("__vector_param_%d", i) + if _, ok := params[name]; !ok { + return name + } + } +} + // FTHybridWithArgs - Executes a hybrid search with advanced options // FTHybridWithArgs is still experimental, the command behaviour and signature may change +// +// Vector data is always sent through the PARAMS mechanism, because inline vector +// blobs are no longer supported by Redis. For every vector expression whose +// VectorParamName is empty, a unique name is generated (e.g. "__vector_param_0") +// and the corresponding blob is passed via PARAMS. +// +// options.Params is never mutated: the command is built from a local copy that +// combines the caller-provided params with any generated vector parameters. This +// makes it safe to reuse the same *FTHybridOptions across multiple calls. Generated +// names are also reserved against all explicit VectorParamName values, so they never +// collide with explicit names (even those following the "__vector_param_N" pattern). func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd { args := []interface{}{"FT.HYBRID", index} @@ -3028,35 +3748,43 @@ func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FT } } + // Vector data is always passed via the PARAMS mechanism (inline vector blobs + // are no longer supported by Redis). When vectors are present, build a local + // copy of the caller-provided params so options.Params is never mutated, and + // pre-reserve any explicit VectorParamName values so generated names never + // collide with them. + params := options.Params + if len(options.VectorExpressions) > 0 { + params = make(map[string]interface{}, len(options.Params)+len(options.VectorExpressions)) + for k, v := range options.Params { + params[k] = v + } + for _, vectorExpr := range options.VectorExpressions { + if vectorExpr.VectorParamName != "" { + params[vectorExpr.VectorParamName] = nil + } + } + } // Add vector expressions 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+) - // If not provided, inline the vector blob (works on Redis 8.4/8.5, fails on 8.6+) - if vectorExpr.VectorParamName != "" { - // Use PARAMS mechanism - args = append(args, "$"+vectorExpr.VectorParamName) - if options.Params == nil { - options.Params = make(map[string]interface{}) - } - options.Params[vectorExpr.VectorParamName] = vectorBlob - } else { - // Inline the vector blob (deprecated in Redis 8.6+) - args = append(args, vectorBlob) + // When VectorParamName is not provided, generate a unique name. Generated + // names are tracked only in the local params map, never written back to + // options.Params. + paramName := vectorExpr.VectorParamName + if paramName == "" { + paramName = generateVectorParamName(params) } + args = append(args, "$"+paramName) + params[paramName] = vectorBlob if vectorExpr.Method != "" { args = append(args, vectorExpr.Method) @@ -3167,11 +3895,13 @@ func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FT } // Add PARAMS - if len(options.Params) > 0 { - args = append(args, "PARAMS", len(options.Params)*2) - for key, value := range options.Params { - // Parameter keys should already have '$' prefix from the user - // Don't add it again if it's already there + // Emit from the local params map, which contains the caller-provided params + // plus any generated vector parameter names. options.Params is left untouched. + if len(params) > 0 { + args = append(args, "PARAMS", len(params)*2) + for key, value := range params { + // PARAMS entries are passed without a '$' prefix; they are referenced in + // the query and clauses using "$". args = append(args, key, value) } } 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..e2b2a9e2f 100644 --- a/vendor/github.com/redis/go-redis/v9/stream_commands.go +++ b/vendor/github.com/redis/go-redis/v9/stream_commands.go @@ -9,6 +9,33 @@ import ( "github.com/redis/go-redis/v9/internal/otel" ) +// XTrimLimitDisabled is a sentinel value for the LIMIT argument of stream +// trimming (XAddArgs.Limit and the XTrim*Approx* commands). Passing it emits +// an explicit "LIMIT 0", which tells Redis to disable the trimming effort cap +// entirely. This differs from passing 0, which keeps the historical behavior: +// no LIMIT clause is sent and Redis applies its implicit default +// (100 * stream-node-max-entries examined entries). +// +// LIMIT is only valid together with the "~" (approximate) trimming flag; +// Redis rejects LIMIT used with exact ("=") trimming. +const XTrimLimitDisabled = -1 + +// appendXTrimLimit appends the LIMIT clause used by XADD and XTRIM trimming: +// - limit > 0 emits "LIMIT "; +// - limit < 0 (see XTrimLimitDisabled) emits "LIMIT 0", disabling the +// trimming effort cap; +// - limit == 0 omits the clause, so Redis applies its implicit default. +func appendXTrimLimit(args []interface{}, limit int64) []interface{} { + switch { + case limit > 0: + return append(args, "limit", limit) + case limit < 0: + return append(args, "limit", int64(0)) + default: + return args + } +} + type StreamCmdable interface { XAdd(ctx context.Context, a *XAddArgs) *StringCmd XAckDel(ctx context.Context, stream string, group string, mode string, ids ...string) *SliceCmd @@ -29,11 +56,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 @@ -71,7 +100,14 @@ type XAddArgs struct { MaxLen int64 // MAXLEN N MinID string // Approx causes MaxLen and MinID to use "~" matcher (instead of "="). - Approx bool + Approx bool + // Limit caps the trimming effort: + // - 0 omits the LIMIT clause (Redis applies its implicit default); + // - a positive value emits "LIMIT "; + // - a negative value (see XTrimLimitDisabled) emits "LIMIT 0", + // disabling the effort cap entirely. + // LIMIT requires Approx to be true; Redis rejects LIMIT together with + // exact ("=") trimming. Limit int64 Mode string ID string @@ -116,9 +152,7 @@ func (c cmdable) XAdd(ctx context.Context, a *XAddArgs) *StringCmd { args = append(args, "minid", "=", a.MinID) } } - if a.Limit > 0 { - args = append(args, "limit", a.Limit) - } + args = appendXTrimLimit(args, a.Limit) if a.ID != "" { args = append(args, a.ID) @@ -359,6 +393,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 +505,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") @@ -468,6 +574,10 @@ func xClaimArgs(a *XClaimArgs) []interface{} { // XTRIM key MAXLEN/MINID ~ threshold LIMIT limit. // // The redis-server version is lower than 6.2, please set limit to 0. +// +// limit == 0 omits the LIMIT clause, a positive limit emits "LIMIT ", +// and a negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable +// the trimming effort cap. LIMIT requires approx; Redis rejects it otherwise. func (c cmdable) xTrim( ctx context.Context, key, strategy string, approx bool, threshold interface{}, limit int64, @@ -480,9 +590,7 @@ func (c cmdable) xTrim( args = append(args, "=") } args = append(args, threshold) - if limit > 0 { - args = append(args, "limit", limit) - } + args = appendXTrimLimit(args, limit) cmd := NewIntCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -494,6 +602,12 @@ func (c cmdable) XTrimMaxLen(ctx context.Context, key string, maxLen int64) *Int return c.xTrim(ctx, key, "maxlen", false, maxLen, 0) } +// XTrimMaxLenApprox trims the stream using the `~` rule. +// cmd: XTRIM key MAXLEN ~ maxLen [LIMIT limit] +// +// limit == 0 omits the LIMIT clause, limit > 0 emits "LIMIT ", and a +// negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable the +// trimming effort cap. func (c cmdable) XTrimMaxLenApprox(ctx context.Context, key string, maxLen, limit int64) *IntCmd { return c.xTrim(ctx, key, "maxlen", true, maxLen, limit) } @@ -502,10 +616,18 @@ func (c cmdable) XTrimMinID(ctx context.Context, key string, minID string) *IntC return c.xTrim(ctx, key, "minid", false, minID, 0) } +// XTrimMinIDApprox trims the stream using the `~` rule. +// cmd: XTRIM key MINID ~ minID [LIMIT limit] +// +// limit == 0 omits the LIMIT clause, limit > 0 emits "LIMIT ", and a +// negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable the +// trimming effort cap. func (c cmdable) XTrimMinIDApprox(ctx context.Context, key string, minID string, limit int64) *IntCmd { return c.xTrim(ctx, key, "minid", true, minID, limit) } +// xTrimMode is xTrim with a trailing trimming mode argument (e.g. KEEPREF). +// The limit semantics are the same as xTrim's. func (c cmdable) xTrimMode( ctx context.Context, key, strategy string, approx bool, threshold interface{}, limit int64, @@ -519,9 +641,7 @@ func (c cmdable) xTrimMode( args = append(args, "=") } args = append(args, threshold) - if limit > 0 { - args = append(args, "limit", limit) - } + args = appendXTrimLimit(args, limit) args = append(args, mode) cmd := NewIntCmd(ctx, args...) _ = c(ctx, cmd) 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..6731c09f5 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" ) @@ -17,9 +18,12 @@ type StringCmdable interface { GetSet(ctx context.Context, key string, value interface{}) *StringCmd GetEx(ctx context.Context, key string, expiration time.Duration) *StringCmd GetDel(ctx context.Context, key string) *StringCmd + GetToBuffer(ctx context.Context, key string, buf []byte) *ZeroCopyStringCmd 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 @@ -28,6 +32,7 @@ type StringCmdable interface { Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd + SetFromBuffer(ctx context.Context, key string, buf []byte) *StatusCmd SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd @@ -179,6 +184,24 @@ func (c cmdable) GetDel(ctx context.Context, key string) *StringCmd { return cmd } +// GetToBuffer executes GET and reads the reply directly into buf, avoiding +// the intermediate string allocation that *StringCmd would produce. For values +// larger than the connection's read buffer, the payload is read straight from +// the socket into buf โ€” effectively zero-copy on the receive path. +// +// The returned *ZeroCopyStringCmd reports the number of bytes read via Val(), +// the populated slice via Bytes() (which is buf[:Val()]), and any error +// (including redis.Nil when the key does not exist) via Err(). If buf is too +// small to hold the value, Err() returns a "buffer too small" error. +// +// This command opts out of automatic retries because partial data from a +// failed attempt would already be sitting in the caller's buffer. +func (c cmdable) GetToBuffer(ctx context.Context, key string, buf []byte) *ZeroCopyStringCmd { + cmd := NewZeroCopyStringCmd(ctx, buf, "get", key) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) Incr(ctx context.Context, key string) *IntCmd { cmd := NewIntCmd(ctx, "incr", key) _ = c(ctx, cmd) @@ -197,6 +220,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 +365,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 { @@ -427,6 +575,27 @@ func (c cmdable) SetEx(ctx context.Context, key string, value interface{}, expir return cmd } +// SetFromBuffer executes SET writing the value directly from buf. For values +// larger than the connection's write buffer, bufio.Writer.Write flushes its +// internal buffer (containing the RESP header) and then writes buf straight +// to the socket โ€” effectively zero-copy on the send path. +// +// Expiration is not supported; use Expire separately if a TTL is required. +// +// Note: SetFromBuffer is exposed for API symmetry with GetToBuffer and is +// functionally equivalent to Set(ctx, key, buf, 0) โ€” both dispatch to the +// same []byte case in the RESP writer and produce identical bytes on the +// wire. The zero-copy property on the send path comes from +// bufio.Writer.Write bypassing its internal buffer for large payloads, +// which Set([]byte) gets automatically. Prefer SetFromBuffer in code that +// also uses GetToBuffer so the buffer-based pattern reads coherently; +// otherwise Set(ctx, key, buf, 0) is equally efficient. +func (c cmdable) SetFromBuffer(ctx context.Context, key string, buf []byte) *StatusCmd { + cmd := NewStatusCmd(ctx, "set", key, buf) + _ = c(ctx, cmd) + return cmd +} + // SetNX sets the value of a key only if the key does not exist. // // Zero expiration means the key has no expiration time. 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/tx.go b/vendor/github.com/redis/go-redis/v9/tx.go index b433b4024..179230e30 100644 --- a/vendor/github.com/redis/go-redis/v9/tx.go +++ b/vendor/github.com/redis/go-redis/v9/tx.go @@ -2,6 +2,7 @@ package redis import ( "context" + "errors" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" @@ -19,6 +20,24 @@ type Tx struct { baseClient cmdable statefulCmdable + + // watchArmed reports whether a WATCH issued through Tx.Watch may still be + // active on the connection. It is set on a successful WATCH and cleared once + // the watched keys are discarded server-side: on UNWATCH (Tx.Unwatch) and on + // EXEC, including an aborted EXEC (the TxPipeline closure). Close uses it to + // skip an otherwise redundant UNWATCH round trip. + // + // Only Tx.Watch, Tx.Unwatch and the TxPipeline EXEC closure maintain this + // flag. go-redis never issues a standalone DISCARD (Pipeline.Discard is a + // client-side buffer reset, not a server command). Issuing a WATCH directly + // via Process bypasses tracking, so Close would not release it and the watch + // would leak onto the pooled connection; that is the one case where skipping + // UNWATCH is unsafe, and using raw Process for WATCH/EXEC/UNWATCH is therefore + // unsupported. + // + // Tx is not safe for concurrent use (see above), so watchArmed is accessed + // only from the goroutine that owns the Tx and needs no synchronization. + watchArmed bool } func (c *Client) newTx() *Tx { @@ -70,7 +89,13 @@ func (c *Client) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) // Close closes the transaction, releasing any open resources. func (c *Tx) Close(ctx context.Context) error { - _ = c.Unwatch(ctx).Err() + // UNWATCH is only needed while a WATCH is still active. EXEC discards the + // watched keys server-side on both commit and abort, so the common + // WATCH/.../EXEC paths leave nothing to release and avoid the extra round + // trip. + if c.watchArmed { + _ = c.Unwatch(ctx).Err() + } return c.baseClient.Close() } @@ -84,6 +109,11 @@ func (c *Tx) Watch(ctx context.Context, keys ...string) *StatusCmd { } cmd := NewStatusCmd(ctx, args...) _ = c.Process(ctx, cmd) + // A successful WATCH leaves keys watched on the connection that Close must + // later release with UNWATCH. + if cmd.Err() == nil { + c.watchArmed = true + } return cmd } @@ -96,6 +126,10 @@ func (c *Tx) Unwatch(ctx context.Context, keys ...string) *StatusCmd { } cmd := NewStatusCmd(ctx, args...) _ = c.Process(ctx, cmd) + // The watched keys have been released, so Close need not UNWATCH again. + if cmd.Err() == nil { + c.watchArmed = false + } return cmd } @@ -133,7 +167,20 @@ func (c *Tx) TxPipeline() Pipeliner { pipe := Pipeline{ exec: func(ctx context.Context, cmds []Cmder) error { cmds = wrapMultiExec(ctx, cmds) - return c.processTxPipelineHook(ctx, cmds) + err := c.processTxPipelineHook(ctx, cmds) + // EXEC discards the watched keys server-side, so the watch is + // cleared only when EXEC actually ran: a nil error (committed), + // TxFailedErr (a watched key changed, EXEC returned nil), or an + // EXECABORT error (a queued command was rejected, so EXEC discarded + // the transaction). All three release the watched keys. Any other + // error may be reported before EXEC executes (for example -LOADING on + // the MULTI reply) or on a broken connection, so leave watchArmed set + // and let Close send UNWATCH rather than risk leaving a watch on a + // pooled connection. + if err == nil || errors.Is(err, TxFailedErr) || IsExecAbortError(err) { + c.watchArmed = false + } + return err }, } pipe.init() 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..c6cabc694 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.21.0" } diff --git a/vendor/modules.txt b/vendor/modules.txt index 0f47368f5..ea93ffb7c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -562,7 +562,7 @@ github.com/redis/go-redis/extra/rediscmd/v9 # github.com/redis/go-redis/extra/redisotel/v9 v9.19.0 ## explicit; go 1.25.0 github.com/redis/go-redis/extra/redisotel/v9 -# github.com/redis/go-redis/v9 v9.19.0 +# github.com/redis/go-redis/v9 v9.21.0 ## explicit; go 1.24 github.com/redis/go-redis/v9 github.com/redis/go-redis/v9/auth @@ -575,7 +575,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