diff --git a/go.mod b/go.mod index c3b78593e58..2c760965f20 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 github.com/prometheus/prometheus v0.311.3-0.20260415124738-34cebfe9536c - github.com/redis/go-redis/v9 v9.18.0 + github.com/redis/go-redis/v9 v9.19.0 github.com/segmentio/fasthash v1.0.3 github.com/sony/gobreaker/v2 v2.4.0 github.com/spf13/afero v1.15.0 @@ -343,7 +343,6 @@ require ( github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/dennwc/varint v1.0.0 github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect diff --git a/go.sum b/go.sum index 29b5401d037..a2b69161343 100644 --- a/go.sum +++ b/go.sum @@ -296,8 +296,6 @@ github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/digitalocean/godo v1.178.0 h1:+B4xGOaoFwwwpM7TKhoyGHdmFg5eF9zDB1YfOLvNJ2E= github.com/digitalocean/godo v1.178.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= @@ -878,8 +876,8 @@ github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuX github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU= github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo= github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardartoul/molecule v1.0.0 h1:+LFA9cT7fn8KF39zy4dhOnwcOwRoqKiBkPqKqya+8+U= diff --git a/vendor/github.com/dgryski/go-rendezvous/LICENSE b/vendor/github.com/dgryski/go-rendezvous/LICENSE deleted file mode 100644 index 22080f736a4..00000000000 --- a/vendor/github.com/dgryski/go-rendezvous/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017-2020 Damian Gryski - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/vendor/github.com/dgryski/go-rendezvous/rdv.go b/vendor/github.com/dgryski/go-rendezvous/rdv.go deleted file mode 100644 index 7a6f8203c67..00000000000 --- a/vendor/github.com/dgryski/go-rendezvous/rdv.go +++ /dev/null @@ -1,79 +0,0 @@ -package rendezvous - -type Rendezvous struct { - nodes map[string]int - nstr []string - nhash []uint64 - hash Hasher -} - -type Hasher func(s string) uint64 - -func New(nodes []string, hash Hasher) *Rendezvous { - r := &Rendezvous{ - nodes: make(map[string]int, len(nodes)), - nstr: make([]string, len(nodes)), - nhash: make([]uint64, len(nodes)), - hash: hash, - } - - for i, n := range nodes { - r.nodes[n] = i - r.nstr[i] = n - r.nhash[i] = hash(n) - } - - return r -} - -func (r *Rendezvous) Lookup(k string) string { - // short-circuit if we're empty - if len(r.nodes) == 0 { - return "" - } - - khash := r.hash(k) - - var midx int - var mhash = xorshiftMult64(khash ^ r.nhash[0]) - - for i, nhash := range r.nhash[1:] { - if h := xorshiftMult64(khash ^ nhash); h > mhash { - midx = i + 1 - mhash = h - } - } - - return r.nstr[midx] -} - -func (r *Rendezvous) Add(node string) { - r.nodes[node] = len(r.nstr) - r.nstr = append(r.nstr, node) - r.nhash = append(r.nhash, r.hash(node)) -} - -func (r *Rendezvous) Remove(node string) { - // find index of node to remove - nidx := r.nodes[node] - - // remove from the slices - l := len(r.nstr) - r.nstr[nidx] = r.nstr[l] - r.nstr = r.nstr[:l] - - r.nhash[nidx] = r.nhash[l] - r.nhash = r.nhash[:l] - - // update the map - delete(r.nodes, node) - moved := r.nstr[nidx] - r.nodes[moved] = nidx -} - -func xorshiftMult64(x uint64) uint64 { - x ^= x >> 12 // a - x ^= x << 25 // b - x ^= x >> 27 // c - return x * 2685821657736338717 -} diff --git a/vendor/github.com/redis/go-redis/v9/Makefile b/vendor/github.com/redis/go-redis/v9/Makefile index 370f3880c5d..098ff3cfa1c 100644 --- a/vendor/github.com/redis/go-redis/v9/Makefile +++ b/vendor/github.com/redis/go-redis/v9/Makefile @@ -1,8 +1,8 @@ GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort) -REDIS_VERSION ?= 8.6 +REDIS_VERSION ?= 8.8 RE_CLUSTER ?= false RCE_DOCKER ?= true -CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:custom-21860421418-debian-amd64 +CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.8-m02 docker.start: export RE_CLUSTER=$(RE_CLUSTER) && \ @@ -49,7 +49,7 @@ test.ci: export RE_CLUSTER=$(RE_CLUSTER) && \ export RCE_DOCKER=$(RCE_DOCKER) && \ export REDIS_VERSION=$(REDIS_VERSION) && \ - go mod tidy -compat=1.18 && \ + go mod tidy && \ go vet && \ go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race -skip Example); \ done @@ -63,7 +63,7 @@ test.ci.skip-vectorsets: export RE_CLUSTER=$(RE_CLUSTER) && \ export RCE_DOCKER=$(RCE_DOCKER) && \ export REDIS_VERSION=$(REDIS_VERSION) && \ - go mod tidy -compat=1.18 && \ + go mod tidy && \ go vet && \ go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race \ -run '^(?!.*(?:VectorSet|vectorset|ExampleClient_vectorset)).*$$' -skip Example); \ @@ -118,5 +118,5 @@ go_mod_tidy: echo "go mod tidy in $${dir}"; \ (cd "$${dir}" && \ go get -u ./... && \ - go mod tidy -compat=1.18); \ + go mod tidy); \ done diff --git a/vendor/github.com/redis/go-redis/v9/README.md b/vendor/github.com/redis/go-redis/v9/README.md index 160714ab099..3ac008f033d 100644 --- a/vendor/github.com/redis/go-redis/v9/README.md +++ b/vendor/github.com/redis/go-redis/v9/README.md @@ -21,9 +21,8 @@ In `go-redis` we are aiming to support the last three releases of Redis. Current - [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 -Although the `go.mod` states it requires at minimum `go 1.21`, our CI is configured to run the tests against all three -versions of Redis and multiple versions of Go ([1.21](https://go.dev/doc/devel/release#go1.21.0), -[1.23](https://go.dev/doc/devel/release#go1.23.0), oldstable, and stable). We observe that some modules related test may not pass with +Although the `go.mod` states it requires at minimum `go 1.24`, our CI is configured to run the tests against all three +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. @@ -136,6 +135,29 @@ func ExampleClient() { } ``` +### Dial retries and backoff + +Connection establishment can be retried by the connection pool when dialing fails. + +- **`DialerRetries`**: maximum number of dial attempts (default: 5). +- **`DialerRetryTimeout`**: default delay between attempts when no custom backoff is provided (default: 100ms). +- **`DialerRetryBackoff`**: optional function hook to control the delay between attempts. + +Example: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + + DialerRetries: 5, + DialerRetryTimeout: 100 * time.Millisecond, // used when DialerRetryBackoff is nil + + // Optional: exponential backoff with jitter and a cap. + DialerRetryBackoff: redis.DialRetryBackoffExponential(100*time.Millisecond, 2*time.Second), +}) +defer rdb.Close() +``` + ### Authentication The Redis client supports multiple ways to provide authentication credentials, with a clear priority order. Here are the available options: 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 7b705ee68b5..927d045f127 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,96 @@ # Release Notes +# 9.19.0 (2026-04-27) + +## ๐Ÿš€ Highlights + +### FIPS-Compatible Script Helper + +`Script` now supports a FIPS-safe execution mode that avoids client-side SHA-1 computation, which is blocked in strict FIPS environments. A new `NewScriptServerSHA` constructor uses `SCRIPT LOAD` to obtain and cache the digest from the server, then runs commands via `EVALSHA`/`EVALSHA_RO`. Falls back to `EVAL`/`EVALRO` if loading fails, and transparently retries once on `NOSCRIPT`. The default behavior is unchanged for existing users. + +([#3700](https://github.com/redis/go-redis/pull/3700)) by [@chaitanyabodlapati](https://github.com/chaitanyabodlapati) + +### FT.AGGREGATE Step-Based Pipeline Builder + +Added a new step-based `FT.AGGREGATE` pipeline API via `FTAggregateOptions.Steps`, allowing `LOAD`, `APPLY`, `GROUPBY`, and `SORTBY` (with per-step `MAX`) to be repeated and interleaved in arbitrary order โ€” matching Redis's native multi-stage aggregation semantics. The legacy `Load`/`Apply`/`GroupBy`/`SortBy`/`SortByMax` fields are now deprecated. + +([#3782](https://github.com/redis/go-redis/pull/3782)) by [@ndyakov](https://github.com/ndyakov) + +### Raw RESP Protocol Access + +Added `DoRaw` and `DoRawWriteTo` methods for executing arbitrary commands and reading the raw RESP response. Useful for proxying, custom protocol inspection, and working with commands not yet wrapped by go-redis. + +([#3713](https://github.com/redis/go-redis/pull/3713)) by [@ofekshenawa](https://github.com/ofekshenawa) + +### Configurable Dial Retry Backoff + +Added `DialerRetryBackoff` option (plumbed through `Options`, `ClusterOptions`, `RingOptions`, `FailoverOptions`) to let callers customize the delay between failed dial attempts. Helpers `DialRetryBackoffConstant` and `DialRetryBackoffExponential` (with jitter and cap) are provided out of the box. Dial timeout is now also applied **per attempt** rather than across all retries. + +([#3706](https://github.com/redis/go-redis/pull/3706), [#3705](https://github.com/redis/go-redis/pull/3705)) by [@mwhooker](https://github.com/mwhooker) + +## โœจ New Features + +- **FT.AGGREGATE Steps**: Step-based pipeline builder for `FT.AGGREGATE` with support for repeated/interleaved `LOAD`, `APPLY`, `GROUPBY`, and `SORTBY` stages ([#3782](https://github.com/redis/go-redis/pull/3782)) by [@ndyakov](https://github.com/ndyakov) +- **VectorSet commands**: Added `VISMEMBER` and `WITHATTRIBS` support ([#3753](https://github.com/redis/go-redis/pull/3753)) by [@romanpovol](https://github.com/romanpovol) +- **FIPS-safe Script**: `NewScriptServerSHA` uses `SCRIPT LOAD` to obtain the digest from the server, avoiding client-side SHA-1 ([#3700](https://github.com/redis/go-redis/pull/3700)) by [@chaitanyabodlapati](https://github.com/chaitanyabodlapati) +- **Raw RESP access**: `DoRaw` and `DoRawWriteTo` for raw RESP protocol access ([#3713](https://github.com/redis/go-redis/pull/3713)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Dial retry backoff**: `DialerRetryBackoff` function option with constant and exponential helpers ([#3706](https://github.com/redis/go-redis/pull/3706)) by [@mwhooker](https://github.com/mwhooker) +- **Typed NOSCRIPT error**: Redis `NOSCRIPT` replies are now surfaced as a typed error for easier handling ([#3738](https://github.com/redis/go-redis/pull/3738)) by [@LINKIWI](https://github.com/LINKIWI) +- **PubSub ClientSetName**: Added `ClientSetName` method to `PubSub` ([#3727](https://github.com/redis/go-redis/pull/3727)) by [@Flack74](https://github.com/Flack74) +- **ReplicaOf**: New `ReplicaOf` method replaces the deprecated `SlaveOf` ([#3720](https://github.com/redis/go-redis/pull/3720)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **HSCAN BinaryUnmarshaler**: `HScan` now supports types implementing `encoding.BinaryUnmarshaler` ([#3768](https://github.com/redis/go-redis/pull/3768)) by [@Aaditya-dubey1](https://github.com/Aaditya-dubey1) + +## ๐Ÿ› Bug Fixes + +- **Auto hostname type detection**: Improved endpoint type detection for maintenance notifications using DNS-based classification; handles empty hosts and expanded private-IP ranges ([#3789](https://github.com/redis/go-redis/pull/3789)) by [@ndyakov](https://github.com/ndyakov) +- **HELLO fallback**: Don't send `CLIENT MAINT_NOTIFICATIONS` handshake when `HELLO` fails and connection falls back to RESP2; fail fast when explicitly enabled with RESP3 ([#3788](https://github.com/redis/go-redis/pull/3788)) by [@ndyakov](https://github.com/ndyakov) +- **Dial TCP retry**: `ShouldRetry` now treats `net.OpError` with `Op == "dial"` timeout errors as safe to retry since no command was sent ([#3787](https://github.com/redis/go-redis/pull/3787)) by [@vladisa88](https://github.com/vladisa88) +- **wrappedOnClose leak**: Fixed resource leak caused by repeatedly wrapping `baseClient` close logic; replaced with a bounded, concurrency-safe named-hook registry ([#3785](https://github.com/redis/go-redis/pull/3785)) by [@ndyakov](https://github.com/ndyakov) +- **Pool Close() on stale connections**: Suppress close errors (e.g., TLS `closeNotify` timeouts) for connections already dropped by the server due to idle timeout ([#3778](https://github.com/redis/go-redis/pull/3778)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **FIFO waiter ordering**: Fixed race in `ConnStateMachine.notifyWaiters` that could wake multiple waiters under a single mutex hold and violate FIFO ordering ([#3777](https://github.com/redis/go-redis/pull/3777)) by [@0x48core](https://github.com/0x48core) +- **Lua READONLY detection**: Detect `READONLY` errors embedded in Lua script error messages on read-only replicas so commands are correctly retried ([#3769](https://github.com/redis/go-redis/pull/3769)) by [@zhengjilei](https://github.com/zhengjilei) +- **VectorScoreSliceCmd RESP2**: Fixed `VSimWithScores`, `VSimWithArgsWithScores`, and `VLinksWithScores` which were broken on RESP2 connections returning flat arrays instead of maps ([#3767](https://github.com/redis/go-redis/pull/3767)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **Closed connection handling**: Two fixes for closed connection handling in the pool ([#3764](https://github.com/redis/go-redis/pull/3764)) by [@cxljs](https://github.com/cxljs) +- **ZRangeArgs Rev**: Fixed `ZRangeArgs` with `Rev` + `ByScore`/`ByLex` incorrectly swapping `Start`/`Stop`, breaking `ZRANGESTORE` ([#3751](https://github.com/redis/go-redis/pull/3751)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **OTel metric instrument types**: Fixed metric instrument types in `redisotel-native` ([#3743](https://github.com/redis/go-redis/pull/3743)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Options.clone() data race**: Fixed data race when cloning `Options` ([#3739](https://github.com/redis/go-redis/pull/3739)) by [@rubensayshi](https://github.com/rubensayshi) +- **Connection closure metrics**: Fixed connection closure metrics and enabled all metric groups by default in `redisotel-native` ([#3735](https://github.com/redis/go-redis/pull/3735)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **OTel semconv v1.38.0**: Use metric definition from `otel/semconv/v1.38.0` in `redisotel-native` ([#3731](https://github.com/redis/go-redis/pull/3731)) by [@wzy9607](https://github.com/wzy9607) +- **SETNX semantics**: Use `SET ... NX` instead of the deprecated `SETNX` command ([#3723](https://github.com/redis/go-redis/pull/3723)) by [@ndyakov](https://github.com/ndyakov) +- **TIME keyless routing**: Mark `TIME` as a keyless command for correct cluster routing ([#3722](https://github.com/redis/go-redis/pull/3722)) by [@fatal10110](https://github.com/fatal10110) +- **Dial timeout per retry**: Dial timeout now applies per attempt instead of across all retry attempts combined ([#3705](https://github.com/redis/go-redis/pull/3705)) by [@mwhooker](https://github.com/mwhooker) +- **Cluster metrics attributes**: Fixed `pool.name` being appended per node, which corrupted and dropped user-provided custom attributes ([#3699](https://github.com/redis/go-redis/pull/3699)) by [@Jesse-Bonfire](https://github.com/Jesse-Bonfire) +- **initConn nil dereference**: Fixed nil pointer dereference and potential deadlock in `*baseClient.initConn()`; added explicit nil option guards to client constructors ([#3676](https://github.com/redis/go-redis/pull/3676)) by [@olde-ducke](https://github.com/olde-ducke) + +## โšก Performance + +- **RESP reader**: Optimized RESP reader by eliminating intermediate string allocations ([#3774](https://github.com/redis/go-redis/pull/3774)) by [@Aaditya-dubey1](https://github.com/Aaditya-dubey1) +- **Inline rendezvous hashing**: Replaced `github.com/dgryski/go-rendezvous` dependency with an in-repo implementation in `internal/hashtag`, reducing the dependency graph while preserving algorithm parity ([#3762](https://github.com/redis/go-redis/pull/3762)) by [@bigsk05](https://github.com/bigsk05) + +## ๐Ÿงช Testing & Infrastructure + +- **Release automation**: Added `repository`, `ref`, and `client-libs-test-image-tag` inputs to the `run-tests` composite action; `redis-version` is now optional so unstable builds use `REDIS_VERSION` from the Makefile ([#3749](https://github.com/redis/go-redis/pull/3749)) by [@dariaguy](https://github.com/dariaguy) +- **Go 1.24**: Updated minimum Go version to 1.24 and use `-compat=1.24` in release scripts ([#3714](https://github.com/redis/go-redis/pull/3714), [#3754](https://github.com/redis/go-redis/pull/3754)) by [@ndyakov](https://github.com/ndyakov), [@cxljs](https://github.com/cxljs) + +## ๐Ÿงฐ Maintenance + +- **Pool state machine**: Removed redundant `Conn.closed` atomic field in favor of the state machine's `StateClosed` ([#3783](https://github.com/redis/go-redis/pull/3783)) by [@cxljs](https://github.com/cxljs) +- **OTel SDK**: Updated OpenTelemetry SDK dependencies in `redisotel`/`redisotel-native` ([#3770](https://github.com/redis/go-redis/pull/3770)) by [@ndyakov](https://github.com/ndyakov) +- **Go 1.21+ built-ins**: Use `maps.Keys`, `slices.Collect`, `slices.Contains`, `clear()`, and `slices.SortFunc` instead of custom helpers ([#3758](https://github.com/redis/go-redis/pull/3758), [#3746](https://github.com/redis/go-redis/pull/3746)) by [@cxljs](https://github.com/cxljs) +- **HGetAll docs**: Added Go doc comment to `HGetAll` describing behavior and complexity ([#3776](https://github.com/redis/go-redis/pull/3776)) by [@0x48core](https://github.com/0x48core) +- **Docs links**: Fixed irrelevant docs links ([#3724](https://github.com/redis/go-redis/pull/3724)) by [@olzhas-sabiyev](https://github.com/olzhas-sabiyev) +- **Examples cleanup**: Removed throughput binary from examples ([#3733](https://github.com/redis/go-redis/pull/3733)) by [@ndyakov](https://github.com/ndyakov) + +## ๐Ÿ‘ฅ Contributors + +We'd like to thank all the contributors who worked on this release! + +[@0x48core](https://github.com/0x48core), [@Aaditya-dubey1](https://github.com/Aaditya-dubey1), [@Copilot](https://github.com/apps/copilot-swe-agent), [@Flack74](https://github.com/Flack74), [@Jesse-Bonfire](https://github.com/Jesse-Bonfire), [@LINKIWI](https://github.com/LINKIWI), [@bigsk05](https://github.com/bigsk05), [@chaitanyabodlapati](https://github.com/chaitanyabodlapati), [@cxljs](https://github.com/cxljs), [@dariaguy](https://github.com/dariaguy), [@fatal10110](https://github.com/fatal10110), [@mwhooker](https://github.com/mwhooker), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@olde-ducke](https://github.com/olde-ducke), [@olzhas-sabiyev](https://github.com/olzhas-sabiyev), [@romanpovol](https://github.com/romanpovol), [@rubensayshi](https://github.com/rubensayshi), [@vladisa88](https://github.com/vladisa88), [@wzy9607](https://github.com/wzy9607), [@zhengjilei](https://github.com/zhengjilei) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.18.0...v9.19.0 + # 9.18.0 (2026-02-16) ## ๐Ÿš€ Highlights diff --git a/vendor/github.com/redis/go-redis/v9/RELEASING.md b/vendor/github.com/redis/go-redis/v9/RELEASING.md index 1115db4e3e5..033ec100bec 100644 --- a/vendor/github.com/redis/go-redis/v9/RELEASING.md +++ b/vendor/github.com/redis/go-redis/v9/RELEASING.md @@ -1,15 +1,146 @@ # Releasing -1. Run `release.sh` script which updates versions in go.mod files and pushes a new branch to GitHub: +This document is the runbook for cutting a go-redis release. It is intended +for maintainers with write/tag access to the repository. + +For the format and style of the release notes themselves, see +[.github/RELEASE_NOTES_TEMPLATE.md](./.github/RELEASE_NOTES_TEMPLATE.md). + +## Versioning + +go-redis follows [Semantic Versioning](https://semver.org/): + +- **Patch** (`vX.Y.Z+1`) โ€” bug fixes, no API changes. +- **Minor** (`vX.Y+1.0`) โ€” backwards-compatible new features, deprecations. +- **Major** (`vX+1.0.0`) โ€” breaking changes. Coordinate with the team first. + +Pre-releases use `vX.Y.Z-beta.N` / `vX.Y.Z-rc.N`. + +## Pre-release checklist + +- [ ] Target branch is `master` and CI is green on the latest commit. +- [ ] All PRs intended for this release are merged. +- [ ] There are no open issues in the release milestone (if used). +- [ ] `CHANGELOG` / release notes have been considered; dependabot-only + and doc-only changes are excluded per the template. +- [ ] Confirm the next version number and decide if it's a patch / minor / major. + +## 1. Draft the release notes + +1. Open the draft release auto-generated by + [release-drafter](.github/release-drafter-config.yml) on GitHub. +2. Prepend a new section to [`RELEASE-NOTES.md`](./RELEASE-NOTES.md) using + [`.github/RELEASE_NOTES_TEMPLATE.md`](./.github/RELEASE_NOTES_TEMPLATE.md) + as the format. Keep the file in chronological order (newest first). +3. Pick 3โ€“5 **Highlights** โ€” the most user-facing, impactful changes. +4. Remove dependabot bumps and doc-only typo fixes from the lists. +5. Verify every PR has a contributor attribution and link. +6. Open a PR with just the release-notes change if you want review before + bumping versions, otherwise include it in the release PR below. + +## 2. Bump versions and open the release PR + +Create a release branch from `master`: + +```shell +git checkout master && git pull --ff-only +git checkout -b release/vX.Y.Z +``` + +Run the release script on that branch: ```shell -TAG=v1.0.0 ./scripts/release.sh +TAG=vX.Y.Z ./scripts/release.sh ``` -2. Open a pull request and wait for the build to finish. +What the script does (and explicitly does **not** do): -3. Merge the pull request and run `tag.sh` to create tags for packages: +- โœ… Validates `TAG` matches the semver regex and isn't already a git tag. +- โœ… Rewrites every `redis/go-redis*` line in every sub-module `go.mod` to + point at the new `TAG`. Trailing `// indirect` markers are preserved. +- โœ… Runs `go mod tidy -compat=1.24` in each sub-module. +- โœ… Updates the return value in [`version.go`](./version.go). +- โŒ Does **not** switch branches (runs in your current branch). +- โŒ Does **not** require a clean working tree (so you can mix it with + release-notes edits in the same branch). +- โŒ Does **not** commit, tag, or push anything. + +Review and commit the changes yourself: ```shell -TAG=v1.0.0 ./scripts/tag.sh +git diff # sanity-check the bumps +git add -u +git commit -m "chore: release vX.Y.Z" +git push origin release/vX.Y.Z ``` + +Then on GitHub: + +- [ ] Open a PR from `release/vX.Y.Z` into `master`. +- [ ] Wait for all required CI checks (build, golangci-lint, spellcheck, + doctests, e2e where applicable) to pass. +- [ ] Get at least one maintainer approval. +- [ ] Merge the PR (use a merge commit โ€” the tag will point at the merge SHA). + +## 3. Tag the release + +After the release PR is merged, pull the latest `master` and dry-run the +tagger: + +```shell +git checkout master && git pull --ff-only +TAG=vX.Y.Z ./scripts/tag.sh vX.Y.Z +``` + +The script defaults to **dry-run** and prints the commands it would run. +Verify the output, then apply for real with `-t`: + +```shell +./scripts/tag.sh vX.Y.Z -t +``` + +This creates and pushes: +- The top-level tag `vX.Y.Z`. +- A per-module tag `/vX.Y.Z` for each public sub-module + (skipping `example/*` and `internal/*`). + +## 4. Publish the GitHub release + +1. On GitHub, open the draft release created by release-drafter. +2. Set the tag to `vX.Y.Z` and the target to `master`. +3. Replace the auto-generated body with the curated notes from + `RELEASE-NOTES.md` for this version. +4. For pre-releases, check **"Set as a pre-release"**. +5. Publish. + +## 5. Post-release + +- [ ] Verify the release appears on + [pkg.go.dev](https://pkg.go.dev/github.com/redis/go-redis/v9) within + a few minutes (trigger a fetch by visiting the version URL if needed). +- [ ] Announce on Discord (see the link in `CONTRIBUTING.md`). +- [ ] Close the release milestone if one was used. +- [ ] Open follow-up issues for anything deferred from this release. + +## Hotfix / patch release + +For an urgent fix on top of the latest release: + +1. Branch from the latest release tag: `git checkout -b hotfix/vX.Y.Z+1 vX.Y.Z`. +2. Cherry-pick (or re-apply) only the required fix commits. +3. Follow the normal release flow above with `TAG=vX.Y.Z+1`. +4. Make sure the fix is also present on `master` (forward-port if necessary). + +## Troubleshooting + +- **`release.sh` fails with "tag already exists"** โ€” the tag has already + been created. Pick the next version, or delete the local tag first if + it was created by mistake. +- **`tag.sh` reports version mismatch in a `go.mod`** โ€” a sub-module was + not updated by `release.sh`. Fix the `go.mod` manually (or re-run + `release.sh`), amend the release PR, and re-run the tagger. +- **`version.go` does not contain the tag** โ€” `release.sh` did not run or + the bump was reverted. Re-run `release.sh` on the release branch. +- **pkg.go.dev does not show the new version** โ€” visit + `https://pkg.go.dev/github.com/redis/go-redis/v9@vX.Y.Z` once to trigger + a fetch from the module proxy. diff --git a/vendor/github.com/redis/go-redis/v9/auth/auth.go b/vendor/github.com/redis/go-redis/v9/auth/auth.go index 1f5c8022485..21667a1284b 100644 --- a/vendor/github.com/redis/go-redis/v9/auth/auth.go +++ b/vendor/github.com/redis/go-redis/v9/auth/auth.go @@ -9,12 +9,30 @@ type StreamingCredentialsProvider interface { // Subscribe subscribes to the credentials provider for updates. // It returns the current credentials, a cancel function to unsubscribe from the provider, // and an error if any. + // + // Implementations MUST be idempotent with respect to listener identity: + // subscribing the same listener value more than once must not produce + // duplicate notifications and must not create multiple independent + // subscriptions that each need to be cancelled separately. Every + // UnsubscribeFunc returned for a given listener must cancel that + // listener's subscription; calling any one of them must be sufficient to + // stop updates to that listener, and calling subsequent ones must be a + // safe no-op. Callers (including go-redis internals) may retain only + // the most recently returned UnsubscribeFunc and rely on it to fully + // unsubscribe the listener. + // // TODO(ndyakov): Should we add context to the Subscribe method? Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error) } // UnsubscribeFunc is a function that is used to cancel the subscription to the credentials provider. // It is used to unsubscribe from the provider when the credentials are no longer needed. +// +// Per the StreamingCredentialsProvider.Subscribe contract, if the same +// listener is subscribed multiple times, every UnsubscribeFunc returned for +// that listener must fully unsubscribe it on first invocation, and +// subsequent invocations (from any of the equivalent UnsubscribeFuncs) must +// be a safe no-op. type UnsubscribeFunc func() error // CredentialsListener is an interface that defines the methods for a credentials listener. diff --git a/vendor/github.com/redis/go-redis/v9/command.go b/vendor/github.com/redis/go-redis/v9/command.go index a2a2f05196e..8931f81c82c 100644 --- a/vendor/github.com/redis/go-redis/v9/command.go +++ b/vendor/github.com/redis/go-redis/v9/command.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "io" "maps" "net" "regexp" @@ -65,6 +66,7 @@ var keylessCommands = map[string]struct{}{ "subscribe": {}, "swapdb": {}, "sync": {}, + "time": {}, "unsubscribe": {}, "unwatch": {}, "wait": {}, @@ -214,6 +216,11 @@ type Cmder interface { SetErr(error) Err() error + // NoRetry returns true if the command should not be retried on failure. + // Commands that write directly to an io.Writer should return true since + // partial writes cannot be undone on retry. + NoRetry() bool + // GetCmdType returns the command type for fast value extraction GetCmdType() CmdType } @@ -235,6 +242,18 @@ func cmdsFirstErr(cmds []Cmder) error { return nil } +// cmdsContainNoRetry returns true if any command in the slice has NoRetry() == true. +// If a pipeline contains a non-retryable command (e.g., RawWriteToCmd), the entire +// pipeline must not be retried to prevent data corruption from partial writes. +func cmdsContainNoRetry(cmds []Cmder) bool { + for _, cmd := range cmds { + if cmd.NoRetry() { + return true + } + } + return false +} + func writeCmds(wr *proto.Writer, cmds []Cmder) error { for _, cmd := range cmds { if err := writeCmd(wr, cmd); err != nil { @@ -397,6 +416,14 @@ func (cmd *baseCmd) readRawReply(rd *proto.Reader) (err error) { return err } +// NoRetry returns true if the command should not be retried on failure. +// By default, commands can be retried. Commands that write directly to an +// io.Writer (like RawWriteToCmd) should override this to return true since +// partial writes cannot be undone on retry. +func (cmd *baseCmd) NoRetry() bool { + return false +} + func (cmd *baseCmd) GetCmdType() CmdType { return cmd.cmdType } @@ -719,6 +746,122 @@ func (cmd *Cmd) Clone() Cmder { //------------------------------------------------------------------------------ +// RawCmd returns raw RESP protocol bytes without parsing. +type RawCmd struct { + baseCmd + val []byte +} + +var _ Cmder = (*RawCmd)(nil) + +func NewRawCmd(ctx context.Context, args ...interface{}) *RawCmd { + return &RawCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeneric, + }, + } +} + +func (cmd *RawCmd) SetVal(val []byte) { + cmd.val = val +} + +func (cmd *RawCmd) Val() []byte { + return cmd.val +} + +func (cmd *RawCmd) Result() ([]byte, error) { + return cmd.val, cmd.err +} + +func (cmd *RawCmd) Bytes() ([]byte, error) { + return cmd.val, cmd.err +} + +func (cmd *RawCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *RawCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadRawReply() + return err +} + +func (cmd *RawCmd) Clone() Cmder { + var val []byte + if cmd.val != nil { + val = make([]byte, len(cmd.val)) + copy(val, cmd.val) + } + return &RawCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +// RawWriteToCmd streams raw RESP protocol bytes directly to an io.Writer without intermediate allocations. +type RawWriteToCmd struct { + baseCmd + w io.Writer + written int64 +} + +var _ Cmder = (*RawWriteToCmd)(nil) + +func NewRawWriteToCmd(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd { + return &RawWriteToCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeneric, + }, + w: w, + } +} + +func (cmd *RawWriteToCmd) SetVal(written int64) { + cmd.written = written +} + +func (cmd *RawWriteToCmd) Val() int64 { + return cmd.written +} + +func (cmd *RawWriteToCmd) Result() (int64, error) { + return cmd.written, cmd.err +} + +func (cmd *RawWriteToCmd) String() string { + return cmdString(cmd, cmd.written) +} + +func (cmd *RawWriteToCmd) readReply(rd *proto.Reader) (err error) { + cmd.written, err = rd.ReadRawReplyWriteTo(cmd.w) + return err +} + +// NoRetry returns true because RawWriteToCmd writes directly to an io.Writer. +// If a retry occurs, partial data from failed attempts would be appended to +// the writer, causing data corruption. The caller must handle retries manually +// if needed, using a fresh writer for each attempt. +func (cmd *RawWriteToCmd) NoRetry() bool { + return true +} + +func (cmd *RawWriteToCmd) Clone() Cmder { + return &RawWriteToCmd{ + baseCmd: cmd.cloneBaseCmd(), + w: cmd.w, + written: cmd.written, + } +} + +//------------------------------------------------------------------------------ + type SliceCmd struct { baseCmd @@ -2075,10 +2218,7 @@ func (cmd *XMessageSliceCmd) Clone() Cmder { 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 - } + val[i].Values = maps.Clone(msg.Values) } } } @@ -2727,7 +2867,10 @@ func (cmd *XInfoConsumersCmd) readReply(rd *proto.Reader) error { inactive, err = rd.ReadInt() cmd.val[i].Inactive = time.Duration(inactive) * time.Millisecond default: - return fmt.Errorf("redis: unexpected content %s in XINFO CONSUMERS reply", key) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } } if err != nil { return err @@ -2856,7 +2999,10 @@ func (cmd *XInfoGroupsCmd) readReply(rd *proto.Reader) error { group.Lag = -1 } default: - return fmt.Errorf("redis: unexpected key %q in XINFO GROUPS reply", key) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } } } } @@ -3025,7 +3171,10 @@ func (cmd *XInfoStreamCmd) readReply(rd *proto.Reader) error { return err } default: - return fmt.Errorf("redis: unexpected key %q in XINFO STREAM reply", key) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } } } return nil @@ -3101,6 +3250,7 @@ type XInfoStreamGroup struct { EntriesRead int64 Lag int64 PelCount int64 + NackedCount uint64 // redis version 8.8, number of NACK'd messages in the group Pending []XInfoStreamGroupPending Consumers []XInfoStreamConsumer } @@ -3245,7 +3395,10 @@ func (cmd *XInfoStreamFullCmd) readReply(rd *proto.Reader) error { return err } default: - return fmt.Errorf("redis: unexpected key %q in XINFO STREAM FULL reply", key) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } } } return nil @@ -3299,6 +3452,11 @@ func readStreamGroups(rd *proto.Reader) ([]XInfoStreamGroup, error) { if err != nil { return nil, err } + case "nacked-count": + group.NackedCount, err = rd.ReadUint() + if err != nil { + return nil, err + } case "pending": group.Pending, err = readXInfoStreamGroupPending(rd) if err != nil { @@ -3310,7 +3468,10 @@ func readStreamGroups(rd *proto.Reader) ([]XInfoStreamGroup, error) { return nil, err } default: - return nil, fmt.Errorf("redis: unexpected key %q in XINFO STREAM FULL reply", key) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return nil, err + } } } @@ -3435,8 +3596,10 @@ func readXInfoStreamConsumers(rd *proto.Reader) ([]XInfoStreamConsumer, error) { c.Pending = append(c.Pending, p) } default: - return nil, fmt.Errorf("redis: unexpected content %s "+ - "in XINFO STREAM FULL reply", cKey) + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return nil, err + } } if err != nil { return nil, err @@ -6833,6 +6996,9 @@ type ClientInfo struct { Resp int // redis version 7.0, client RESP protocol version LibName string // redis version 7.2, client library name LibVer string // redis version 7.2, client library version + ReadEvents uint64 // redis version 8.8, number of read events processed + AvgPipelineLenSum uint64 // redis version 8.8, sum of pipeline lengths + AvgPipelineLenCnt uint64 // redis version 8.8, count of pipeline operations } type ClientInfoCmd struct { @@ -7013,8 +7179,14 @@ func parseClientInfo(txt string) (info *ClientInfo, err error) { info.LibVer = val case "io-thread": info.IoThread, err = strconv.Atoi(val) + case "read-events": + info.ReadEvents, err = strconv.ParseUint(val, 10, 64) + case "avg-pipeline-len-sum": + info.AvgPipelineLenSum, err = strconv.ParseUint(val, 10, 64) + case "avg-pipeline-len-cnt": + info.AvgPipelineLenCnt, err = strconv.ParseUint(val, 10, 64) default: - return nil, fmt.Errorf("redis: unexpected client info key(%s)", key) + // skip unknown fields } if err != nil { @@ -7061,6 +7233,9 @@ func (cmd *ClientInfoCmd) Clone() Cmder { Resp: cmd.val.Resp, LibName: cmd.val.LibName, LibVer: cmd.val.LibVer, + ReadEvents: cmd.val.ReadEvents, + AvgPipelineLenSum: cmd.val.AvgPipelineLenSum, + AvgPipelineLenCnt: cmd.val.AvgPipelineLenCnt, } } return &ClientInfoCmd{ @@ -7167,7 +7342,10 @@ func (cmd *ACLLogCmd) readReply(rd *proto.Reader) error { case "timestamp-last-updated": entry.TimestampLastUpdated, err = rd.ReadInt() default: - return fmt.Errorf("redis: unexpected key %q in ACL LOG reply", key) + // skip unknown fields + if err := rd.DiscardNext(); err != nil { + return err + } } if err != nil { @@ -7231,6 +7409,9 @@ func (cmd *ACLLogCmd) Clone() Cmder { Resp: entry.ClientInfo.Resp, LibName: entry.ClientInfo.LibName, LibVer: entry.ClientInfo.LibVer, + ReadEvents: entry.ClientInfo.ReadEvents, + AvgPipelineLenSum: entry.ClientInfo.AvgPipelineLenSum, + AvgPipelineLenCnt: entry.ClientInfo.AvgPipelineLenCnt, } } } @@ -7451,7 +7632,7 @@ type VectorScoreSliceCmd struct { var _ Cmder = (*VectorScoreSliceCmd)(nil) -func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { +func NewVectorScoreSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { return &VectorScoreSliceCmd{ baseCmd: baseCmd{ ctx: ctx, @@ -7460,6 +7641,11 @@ func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCm } } +// NewVectorInfoSliceCmd is an alias for NewVectorScoreSliceCmd kept for backwards compatibility. +func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { + return NewVectorScoreSliceCmd(ctx, args...) +} + func (cmd *VectorScoreSliceCmd) SetVal(val []VectorScore) { cmd.val = val } @@ -7477,11 +7663,29 @@ func (cmd *VectorScoreSliceCmd) String() string { } func (cmd *VectorScoreSliceCmd) readReply(rd *proto.Reader) error { - n, err := rd.ReadMapLen() + typ, err := rd.PeekReplyType() if err != nil { return err } + var n int + if typ == proto.RespMap { + n, err = rd.ReadMapLen() + if err != nil { + return err + } + } else { + // RESP2 returns a flat array [name, score, name, score, ...] + n, err = rd.ReadArrayLen() + if err != nil { + return err + } + if n%2 != 0 { + return fmt.Errorf("redis: VectorScoreSliceCmd expects even number of elements, got %d", n) + } + n /= 2 + } + cmd.val = make([]VectorScore, n) for i := 0; i < n; i++ { name, err := rd.ReadString() @@ -7507,6 +7711,208 @@ func (cmd *VectorScoreSliceCmd) Clone() Cmder { } } +func readVectorAttribStringOrNil(rd *proto.Reader) (*string, error) { + v, err := rd.ReadReply() + if err != nil { + if err == proto.Nil { + return nil, nil + } + return nil, err + } + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("redis: can't parse reply=%T reading string", v) + } + return &s, nil +} + +type VectorAttribSliceCmd struct { + baseCmd + + val []VectorAttrib +} + +var _ Cmder = (*VectorAttribSliceCmd)(nil) + +func NewVectorAttribSliceCmd(ctx context.Context, args ...any) *VectorAttribSliceCmd { + return &VectorAttribSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorAttribSliceCmd) SetVal(val []VectorAttrib) { + cmd.val = val +} + +func (cmd *VectorAttribSliceCmd) Val() []VectorAttrib { + return cmd.val +} + +func (cmd *VectorAttribSliceCmd) Result() ([]VectorAttrib, error) { + return cmd.val, cmd.err +} + +func (cmd *VectorAttribSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorAttribSliceCmd) readReply(rd *proto.Reader) error { + replyType, err := rd.PeekReplyType() + if err != nil { + return err + } + + if replyType == proto.RespMap { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val = make([]VectorAttrib, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib} + } + return nil + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n%2 != 0 { + return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 2", n) + } + cmd.val = make([]VectorAttrib, n/2) + for i := range cmd.val { + name, err := rd.ReadString() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib} + } + return nil +} + +func (cmd *VectorAttribSliceCmd) Clone() Cmder { + return &VectorAttribSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +type VectorScoreAttribSliceCmd struct { + baseCmd + + val []VectorScoreAttrib +} + +var _ Cmder = (*VectorScoreAttribSliceCmd)(nil) + +func NewVectorScoreAttribSliceCmd(ctx context.Context, args ...any) *VectorScoreAttribSliceCmd { + return &VectorScoreAttribSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorScoreAttribSliceCmd) SetVal(val []VectorScoreAttrib) { + cmd.val = val +} + +func (cmd *VectorScoreAttribSliceCmd) Val() []VectorScoreAttrib { + return cmd.val +} + +func (cmd *VectorScoreAttribSliceCmd) Result() ([]VectorScoreAttrib, error) { + return cmd.val, cmd.err +} + +func (cmd *VectorScoreAttribSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreAttribSliceCmd) readReply(rd *proto.Reader) error { + replyType, err := rd.PeekReplyType() + if err != nil { + return err + } + + if replyType == proto.RespMap { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val = make([]VectorScoreAttrib, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib} + } + return nil + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n%3 != 0 { + return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 3", n) + } + cmd.val = make([]VectorScoreAttrib, n/3) + for i := range cmd.val { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib} + } + return nil +} + +func (cmd *VectorScoreAttribSliceCmd) Clone() Cmder { + return &VectorScoreAttribSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + func (cmd *MonitorCmd) Clone() Cmder { // MonitorCmd cannot be safely cloned due to channels and goroutines // Return a new MonitorCmd with the same channel diff --git a/vendor/github.com/redis/go-redis/v9/commands.go b/vendor/github.com/redis/go-redis/v9/commands.go index 219fe464b4d..b3b6badc685 100644 --- a/vendor/github.com/redis/go-redis/v9/commands.go +++ b/vendor/github.com/redis/go-redis/v9/commands.go @@ -215,6 +215,7 @@ type Cmdable interface { ShutdownSave(ctx context.Context) *StatusCmd ShutdownNoSave(ctx context.Context) *StatusCmd SlaveOf(ctx context.Context, host, port string) *StatusCmd + ReplicaOf(ctx context.Context, host, port string) *StatusCmd SlowLogGet(ctx context.Context, num int64) *SlowLogCmd SlowLogLen(ctx context.Context) *IntCmd SlowLogReset(ctx context.Context) *StatusCmd @@ -448,6 +449,20 @@ func (c cmdable) Do(ctx context.Context, args ...interface{}) *Cmd { return cmd } +// DoRaw executes a command and returns the raw RESP protocol bytes without parsing. +func (c cmdable) DoRaw(ctx context.Context, args ...interface{}) *RawCmd { + cmd := NewRawCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// DoRawWriteTo executes a command and streams raw RESP bytes directly to w without intermediate allocations. +func (c cmdable) DoRawWriteTo(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd { + cmd := NewRawWriteToCmd(ctx, w, args...) + _ = c(ctx, cmd) + return cmd +} + // Quit closes the connection. // // Deprecated: Just close the connection instead as of Redis 7.2.0. @@ -682,6 +697,13 @@ func (c cmdable) SlaveOf(ctx context.Context, host, port string) *StatusCmd { return cmd } +// ReplicaOf sets a Redis server as a replica of another, or promotes it to being a master. +func (c cmdable) ReplicaOf(ctx context.Context, host, port string) *StatusCmd { + cmd := NewStatusCmd(ctx, "replicaof", host, port) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) SlowLogGet(ctx context.Context, num int64) *SlowLogCmd { cmd := NewSlowLogCmd(context.Background(), "slowlog", "get", num) _ = c(ctx, cmd) diff --git a/vendor/github.com/redis/go-redis/v9/dial_retry_backoff.go b/vendor/github.com/redis/go-redis/v9/dial_retry_backoff.go new file mode 100644 index 00000000000..bb3e8bf2ae1 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/dial_retry_backoff.go @@ -0,0 +1,39 @@ +package redis + +import ( + "time" + + "github.com/redis/go-redis/v9/internal" +) + +// DialRetryBackoffConstant returns a dial retry backoff function that always returns d. +// attempt is 0-based: attempt=0 is the delay after the 1st failed dial. +func DialRetryBackoffConstant(d time.Duration) func(attempt int) time.Duration { + if d < 0 { + d = 0 + } + return func(int) time.Duration { return d } +} + +// DialRetryBackoffExponential returns a dial retry backoff function that uses exponential +// backoff with jitter and a cap, using internal.RetryBackoff. +// +// attempt is 0-based: attempt=0 is the delay after the 1st failed dial. +func DialRetryBackoffExponential(minBackoff, maxBackoff time.Duration) func(attempt int) time.Duration { + if minBackoff < 0 { + minBackoff = 0 + } + if maxBackoff < 0 { + maxBackoff = 0 + } + if minBackoff > maxBackoff { + minBackoff = maxBackoff + } + return func(attempt int) time.Duration { + // internal.RetryBackoff expects retry >= 0. + if attempt < 0 { + attempt = 0 + } + return internal.RetryBackoff(attempt, minBackoff, maxBackoff) + } +} 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 8299fd9dabd..16005aa040b 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.6.0} +x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-m02} services: redis: @@ -164,9 +164,9 @@ services: - PORT=6390 command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""} ports: - - 6390:6390 - - 6391:6391 - - 6392:6392 + - "6390:6390" + - "6391:6391" + - "6392:6392" volumes: - "./dockers/ring:/redis/work" profiles: diff --git a/vendor/github.com/redis/go-redis/v9/error.go b/vendor/github.com/redis/go-redis/v9/error.go index d2462a492f0..6e6c27487ed 100644 --- a/vendor/github.com/redis/go-redis/v9/error.go +++ b/vendor/github.com/redis/go-redis/v9/error.go @@ -28,6 +28,11 @@ var ErrPoolTimeout = pool.ErrPoolTimeout // is used on a ClusterClient with keys in different slots. var ErrCrossSlot = proto.RedisError("CROSSSLOT Keys in request don't hash to the same slot") +// ErrNoScript is returned when EVALSHA is requested for a script digest that +// is not available in the script cache. Note that this error text is reproduced +// literally from that used by Redis. +var ErrNoScript = proto.RedisError("NOSCRIPT No matching script. Please use EVAL.") + // HasErrorPrefix checks if the err is a Redis error and the message contains a prefix. func HasErrorPrefix(err error, prefix string) bool { var rErr Error @@ -100,6 +105,12 @@ 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 @@ -139,6 +150,9 @@ func shouldRetry(err error, retryTimeout bool) bool { if strings.HasPrefix(s, "READONLY ") { return true } + if strings.Contains(s, "-READONLY You can't write against a read only replica") { + return true + } if strings.HasPrefix(s, "CLUSTERDOWN ") { return true } diff --git a/vendor/github.com/redis/go-redis/v9/hash_commands.go b/vendor/github.com/redis/go-redis/v9/hash_commands.go index b78860a5a1c..256b8746b43 100644 --- a/vendor/github.com/redis/go-redis/v9/hash_commands.go +++ b/vendor/github.com/redis/go-redis/v9/hash_commands.go @@ -70,6 +70,13 @@ func (c cmdable) HGet(ctx context.Context, key, field string) *StringCmd { return cmd } +// HGetAll returns a map of all fields and values stored at key. +// +// Returns an empty map when key does not exist. +// +// Time complexity: O(N) where N is the size of the hash. +// +// See https://redis.io/commands/hgetall/ func (c cmdable) HGetAll(ctx context.Context, key string) *MapStringStringCmd { cmd := NewMapStringStringCmd(ctx, "hgetall", key) _ = c(ctx, cmd) 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 ea56fd6c7ca..2f81c14f1f8 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 @@ -11,7 +11,7 @@ const slotNumber = 16384 // CRC16 implementation according to CCITT standards. // Copyright 2001-2010 Georges Menie (www.menie.org) // Copyright 2013 The Go Authors. All rights reserved. -// http://redis.io/topics/cluster-spec#appendix-a-crc16-reference-implementation-in-ansi-c +// https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec#appendix-a-crc16-reference-implementation-in-ansi-c. var crc16tab = [256]uint16{ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, diff --git a/vendor/github.com/redis/go-redis/v9/internal/hashtag/rendezvous.go b/vendor/github.com/redis/go-redis/v9/internal/hashtag/rendezvous.go new file mode 100644 index 00000000000..214f7e8ea95 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/internal/hashtag/rendezvous.go @@ -0,0 +1,54 @@ +package hashtag + +import "github.com/cespare/xxhash/v2" + +// RendezvousHash implements HRW (Highest Random Weight) hashing. +type RendezvousHash struct { + nodes []node +} + +type node struct { + name string + hash uint64 +} + +// NewRendezvousHash builds a hash from shard names. +func NewRendezvousHash(shards []string) *RendezvousHash { + n := make([]node, len(shards)) + for i, s := range shards { + n[i] = node{ + name: s, + hash: xxhash.Sum64String(s), + } + } + return &RendezvousHash{nodes: n} +} + +// Get returns the shard name for the given key. +func (r *RendezvousHash) Get(key string) string { + if len(r.nodes) == 0 { + return "" + } + + kh := xxhash.Sum64String(key) + + bestIdx := 0 + bestScore := mix64(kh ^ r.nodes[0].hash) + + for i := 1; i < len(r.nodes); i++ { + if score := mix64(kh ^ r.nodes[i].hash); score > bestScore { + bestScore = score + bestIdx = i + } + } + + return r.nodes[bestIdx].name +} + +// mix64 is a xorshift-based mixing function. +func mix64(x uint64) uint64 { + x ^= x >> 12 + x ^= x << 25 + x ^= x >> 27 + return x * 2685821657736338717 +} diff --git a/vendor/github.com/redis/go-redis/v9/internal/hscan/structmap.go b/vendor/github.com/redis/go-redis/v9/internal/hscan/structmap.go index 1a560e4a399..408ce0e4b3c 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/hscan/structmap.go +++ b/vendor/github.com/redis/go-redis/v9/internal/hscan/structmap.go @@ -109,6 +109,8 @@ func (s StructValue) Scan(key string, value string) error { return scan.ScanRedis(value) case encoding.TextUnmarshaler: return scan.UnmarshalText(util.StringToBytes(value)) + case encoding.BinaryUnmarshaler: + return scan.UnmarshalBinary(util.StringToBytes(value)) } } diff --git a/vendor/github.com/redis/go-redis/v9/internal/otel/metrics.go b/vendor/github.com/redis/go-redis/v9/internal/otel/metrics.go index a4840825c17..a3f23fffe5c 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/otel/metrics.go +++ b/vendor/github.com/redis/go-redis/v9/internal/otel/metrics.go @@ -85,6 +85,17 @@ type Recorder interface { // consumerGroup: name of the consumer group // consumerName: name of the consumer RecordStreamLag(ctx context.Context, lag time.Duration, cn *pool.Conn, streamName, consumerGroup, consumerName string) + + // RecordConnectionCount records a change in connection count (UpDownCounter) + // delta: +1 when connection added, -1 when connection removed + // state: connection state (e.g., "idle", "used") + // isPubSub: true if this is a PubSub connection + RecordConnectionCount(ctx context.Context, delta int, cn *pool.Conn, state string, isPubSub bool) + + // RecordPendingRequests records a change in pending requests (UpDownCounter) + // delta: +1 when request starts waiting, -1 when request stops waiting + // poolName is passed explicitly because we may not have a connection yet when request starts + RecordPendingRequests(ctx context.Context, delta int, cn *pool.Conn, poolName string) } type PubSubPooler interface { @@ -193,6 +204,12 @@ func SetGlobalRecorder(r Recorder) { ConnectionClosed: func(ctx context.Context, cn *pool.Conn, reason string, err error) { getRecorder().RecordConnectionClosed(ctx, cn, reason, err) }, + ConnectionCount: func(ctx context.Context, delta int, cn *pool.Conn, state string, isPubSub bool) { + getRecorder().RecordConnectionCount(ctx, delta, cn, state, isPubSub) + }, + PendingRequests: func(ctx context.Context, delta int, cn *pool.Conn, poolName string) { + getRecorder().RecordPendingRequests(ctx, delta, cn, poolName) + }, }) } @@ -246,6 +263,8 @@ func (noopRecorder) RecordPubSubMessage(context.Context, *pool.Conn, string, str func (noopRecorder) RecordStreamLag(context.Context, time.Duration, *pool.Conn, string, string, string) { } +func (noopRecorder) RecordConnectionCount(context.Context, int, *pool.Conn, string, bool) {} +func (noopRecorder) RecordPendingRequests(context.Context, int, *pool.Conn, string) {} // RegisterPools registers connection pools with the global recorder. func RegisterPools(connPool pool.Pooler, pubSubPool PubSubPooler, addr string) { 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 f0af63c6a5b..6abb587392c 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 @@ -14,6 +14,7 @@ import ( "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/maintnotifications/logs" "github.com/redis/go-redis/v9/internal/proto" + uberatomic "go.uber.org/atomic" ) var noDeadline = time.Time{} @@ -102,11 +103,15 @@ type Conn struct { pooled bool pubsub bool - closed atomic.Bool createdAt time.Time expiresAt time.Time poolName string // Name of the pool this connection belongs to (for metrics) + // When a goroutine closes a connection, it usually knows the reason, so closeReason is not needed. + // closeReason is only used when an in-use connection is closed by another goroutine, + // to inform the goroutine using the connection why the connection was closed. + closeReason uberatomic.String + // maintenanceNotifications upgrade support: relaxed timeouts during migrations/failovers // Using atomic operations for lock-free access to avoid mutex contention @@ -576,6 +581,41 @@ func (cn *Conn) getEffectiveWriteTimeout(normalTimeout time.Duration) time.Durat } } +// SetOnClose installs fn as the callback invoked exactly once when this +// connection is closed (via Conn.Close). +// +// IMPORTANT: SetOnClose OVERWRITES any previously installed callback โ€” it +// does not compose, chain, or deduplicate. A Conn has room for a single +// onClose hook by design, because its lifecycle is bounded (a Conn is +// created, optionally re-initialized on its own net.Conn, and then closed +// once) and the pool's OnRemove hooks handle any registry-level cleanup +// that must survive the net.Conn being swapped. +// +// This has a subtle implication for per-connection subscriptions such as +// the unsubscribe function returned by StreamingCredentialsProvider +// (e.g. EntraID token rotation): if SetOnClose is called twice on the +// same Conn with DIFFERENT unsubscribe closures โ€” for example because +// initConn ran a second time and obtained a fresh Subscribe() โ€” +// the previous unsubscribe is dropped and will NEVER run, leaking a +// subscription on the provider. Callers must therefore ensure either: +// +// - the provider's Subscribe is idempotent for the same listener (the +// streaming credentials Manager deduplicates listeners by connection +// id, so re-Subscribe returns an equivalent unsubscribe), OR +// - the previous callback has already been invoked before SetOnClose is +// called again. +// +// Design note: unlike the client-level onCloseHooks registry (see +// redis.baseClient), there is intentionally NO named-hook dedup or +// multi-callback support on Conn. This is a deliberate trade-off to keep +// the Conn object slim โ€” a pool can hold thousands of Conn values and +// each one is a hot allocation, so paying for a sync.Mutex plus a +// map[string]func() error per connection to support a feature that would +// only be used by at most one subsystem today (streaming credentials) is +// not worth the per-connection memory and allocation cost. For a single +// Conn there is at most one meaningful close callback at any point in +// time, and a richer registry here would not even solve the "stale +// closure" hazard described above. func (cn *Conn) SetOnClose(fn func() error) { cn.onClose = fn } @@ -882,18 +922,20 @@ func (cn *Conn) WithWriter( } func (cn *Conn) IsClosed() bool { - return cn.closed.Load() || cn.stateMachine.GetState() == StateClosed + return cn.stateMachine.GetState() == StateClosed } func (cn *Conn) Close() error { - cn.closed.Store(true) - + if cn.IsClosed() { + return nil + } // Transition to CLOSED state cn.stateMachine.Transition(StateClosed) if cn.onClose != nil { // ignore error _ = cn.onClose() + cn.onClose = nil } // Lock-free netConn access for better performance diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go b/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go index afdc631ccb4..7dee4c49dc5 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go @@ -297,45 +297,38 @@ func (sm *ConnStateMachine) notifyWaiters() { return } - // Process waiters in FIFO order until no more can be processed - // We loop instead of recursing to avoid stack overflow and mutex issues + // Track state locally so we only consider transitions made within this + // call, not concurrent transitions from woken goroutines. Re-reading the + // atomic would let a fast goroutine's Transition(StateIdle) leak into our + // view, causing us to wake multiple waiters at once and breaking FIFO + // execution ordering. + currentState := sm.GetState() + for { processed := false - // Find the first waiter that can proceed for elem := sm.waiters.Front(); elem != nil; elem = elem.Next() { w := elem.Value.(*waiter) - // Read current state inside the loop to get the latest value - currentState := sm.GetState() - - // Check if current state is valid for this waiter if _, valid := w.validStates[currentState]; valid { - // Remove from queue first sm.waiters.Remove(elem) sm.waiterCount.Add(-1) - // Use CAS to ensure state hasn't changed since we checked - // This prevents race condition where another thread changes state - // between our check and our transition if sm.state.CompareAndSwap(uint32(currentState), uint32(w.targetState)) { - // Successfully transitioned - notify waiter w.done <- nil + currentState = w.targetState processed = true break } else { - // State changed - re-add waiter to front of queue to maintain FIFO ordering - // This waiter was first in line and should retain priority sm.waiters.PushFront(w) sm.waiterCount.Add(1) - // Continue to next iteration to re-read state + currentState = sm.GetState() processed = true break } } } - // If we didn't process any waiter, we're done if !processed { break } 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 aaca530cb13..19381c3139c 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 @@ -13,6 +13,41 @@ import ( "github.com/redis/go-redis/v9/internal/rand" ) +// Connection close reason constants for metrics. +// These are used as the "reason" parameter in CloseConn() calls. +const ( + // CloseReasonStale indicates the connection was closed because it exceeded + // the idle timeout or max lifetime. + CloseReasonStale = "stale" + + // CloseReasonHookError indicates the connection was closed due to an error + // in a pool hook (OnGet or OnPut). + CloseReasonHookError = "hook_error" + + // CloseReasonAuthError indicates the connection was closed due to an + // authentication error during re-authentication. + CloseReasonAuthError = "auth_error" + + // CloseReasonTest is used in tests when closing connections. + CloseReasonTest = "test" + + // CloseReasonFailover indicates the connection was closed due to a failover event. + CloseReasonFailover = "failover" +) + +// Metric state constants for connection state tracking. +// These represent the logical state of a connection from a metrics perspective, +// not the internal state machine state (ConnState). +const ( + // MetricStateIdle indicates the connection is idle in the pool, + // ready to be acquired. + MetricStateIdle = "idle" + + // MetricStateUsed indicates the connection is currently being used + // by a client operation. + MetricStateUsed = "used" +) + var ( // ErrClosed performs any operation on the closed client will return this error. ErrClosed = errors.New("redis: client is closed") @@ -69,6 +104,15 @@ var ( // Parameters: ctx, cn, reason, err metricConnectionClosedCallback func(ctx context.Context, cn *Conn, reason string, err error) + // Global metric callback for connection count changes (UpDownCounter) + // Parameters: ctx, delta (+1/-1), cn, state, isPubSub + metricConnectionCountCallback func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool) + + // Global metric callback for pending requests changes (UpDownCounter) + // Parameters: ctx, delta (+1/-1), cn, poolName + // poolName is passed explicitly because we may not have a connection yet when request starts + metricPendingRequestsCallback func(ctx context.Context, delta int, cn *Conn, poolName string) + // errPanicInDial is returned when a panic occurs in the dial function. errPanicInQueuedNewConn = errors.New("panic in queuedNewConn") @@ -114,6 +158,17 @@ type MetricCallbacks struct { // ConnectionClosed is called when a connection is closed ConnectionClosed func(ctx context.Context, cn *Conn, reason string, err error) + + // ConnectionCount is called when connection count changes (UpDownCounter) + // delta: +1 when connection added, -1 when connection removed + // state: connection state (e.g., "idle", "used") + // isPubSub: true if this is a PubSub connection + ConnectionCount func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool) + + // PendingRequests is called when pending requests count changes (UpDownCounter) + // delta: +1 when request starts waiting, -1 when request stops waiting + // poolName is passed explicitly because we may not have a connection yet when request starts + PendingRequests func(ctx context.Context, delta int, cn *Conn, poolName string) } // SetAllMetricCallbacks sets all metric callbacks atomically. @@ -138,6 +193,8 @@ func SetAllMetricCallbacks(callbacks *MetricCallbacks) { metricMaintenanceNotificationCallback = nil metricConnectionWaitTimeCallback = nil metricConnectionClosedCallback = nil + metricConnectionCountCallback = nil + metricPendingRequestsCallback = nil return } @@ -148,6 +205,8 @@ func SetAllMetricCallbacks(callbacks *MetricCallbacks) { metricMaintenanceNotificationCallback = callbacks.MaintenanceNotification metricConnectionWaitTimeCallback = callbacks.ConnectionWaitTime metricConnectionClosedCallback = callbacks.ConnectionClosed + metricConnectionCountCallback = callbacks.ConnectionCount + metricPendingRequestsCallback = callbacks.PendingRequests } // getMetricConnectionStateChangeCallback returns the metric callback for connection state changes. @@ -223,6 +282,22 @@ func getMetricConnectionClosedCallback() func(ctx context.Context, cn *Conn, rea return cb } +// getMetricConnectionCountCallback returns the metric callback for connection count changes (UpDownCounter). +func getMetricConnectionCountCallback() func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool) { + metricCallbackMu.RLock() + cb := metricConnectionCountCallback + metricCallbackMu.RUnlock() + return cb +} + +// getMetricPendingRequestsCallback returns the metric callback for pending requests changes (UpDownCounter). +func getMetricPendingRequestsCallback() func(ctx context.Context, delta int, cn *Conn, poolName string) { + metricCallbackMu.RLock() + cb := metricPendingRequestsCallback + metricCallbackMu.RUnlock() + return cb +} + // Stats contains pool state information and accumulated stats. type Stats struct { Hits uint32 // number of times free connection was found in the pool @@ -242,7 +317,7 @@ type Stats struct { type Pooler interface { NewConn(context.Context) (*Conn, error) - CloseConn(*Conn) error + CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error Get(context.Context) (*Conn, error) Put(context.Context, *Conn) @@ -294,6 +369,10 @@ type Options struct { // Default: 100ms DialerRetryTimeout time.Duration + // DialerRetryBackoff controls the delay between dial retry attempts. + // If nil, dial retry backoff is constant and equals DialerRetryTimeout (default: 100ms). + DialerRetryBackoff func(attempt int) time.Duration + // Name is a unique identifier for this pool, used in metrics. // Format: addr_uniqueID (e.g., "localhost:6379_a1b2c3d4") Name string @@ -456,10 +535,8 @@ func (p *ConnPool) checkMinIdleConns() { } func (p *ConnPool) addIdleConn() error { - ctx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) - defer cancel() - - cn, err := p.dialConn(ctx, true) + // Do not apply DialTimeout via context here; dialConn applies DialTimeout per attempt. + cn, err := p.dialConn(context.Background(), true) if err != nil { return err } @@ -479,6 +556,12 @@ func (p *ConnPool) addIdleConn() error { p.conns[cn.GetID()] = cn p.idleConns = append(p.idleConns, cn) + + // Record connection count increment (new idle connection from min-idle prewarm) + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(context.Background(), 1, cn, "idle", false) + } + return nil } @@ -505,9 +588,9 @@ func (p *ConnPool) newConn(ctx context.Context, pooled bool) (*Conn, error) { ctx = context.Background() } - dialCtx, cancel := context.WithTimeout(ctx, p.cfg.DialTimeout) - defer cancel() - cn, err := p.dialConn(dialCtx, pooled) + // Do not apply DialTimeout via context here; dialConn applies DialTimeout per attempt. + // We still propagate ctx so callers can cancel explicitly. + cn, err := p.dialConn(ctx, pooled) if err != nil { return nil, err } @@ -543,9 +626,14 @@ func (p *ConnPool) newConn(ctx context.Context, pooled bool) (*Conn, error) { } } - // Notify metrics: new connection created and idle + // All new connections start as "used" metrically. For the miss path in getConn, + // this is the final state. For putIdleConn (undelivered conn), a usedโ†’idle + // transition is emitted when it's added to idleConns. if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, cn, "", "idle") + cb(ctx, cn, "", MetricStateUsed) + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, 1, cn, "used", false) } return cn, nil @@ -569,16 +657,12 @@ func (p *ConnPool) dialConn(ctx context.Context, pooled bool) (*Conn, error) { } // Retry dialing with backoff - // the context timeout is already handled by the context passed in - // so we may never reach the max retries, higher values don't hurt + // Dial timeout is applied per attempt (so retries/backoff don't eat into the next + // attempt's dial budget), while still honoring caller cancellation via ctx. maxRetries := p.cfg.DialerRetries if maxRetries <= 0 { maxRetries = 5 // Default value } - backoffDuration := p.cfg.DialerRetryTimeout - if backoffDuration <= 0 { - backoffDuration = 100 * time.Millisecond // Default value - } var lastErr error shouldLoop := true @@ -587,16 +671,32 @@ func (p *ConnPool) dialConn(ctx context.Context, pooled bool) (*Conn, error) { // instead of a generic context deadline exceeded error attempt := 0 for attempt = 0; (attempt < maxRetries) && shouldLoop; attempt++ { - netConn, err := p.cfg.Dialer(ctx) + attemptCtx := ctx + var cancel context.CancelFunc + if p.cfg.DialTimeout > 0 { + // Apply DialTimeout per attempt, but never extend an existing earlier deadline. + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > p.cfg.DialTimeout { + attemptCtx, cancel = context.WithTimeout(ctx, p.cfg.DialTimeout) + } + } + + netConn, err := p.cfg.Dialer(attemptCtx) + if cancel != nil { + cancel() + } if err != nil { lastErr = err // Add backoff delay for retry attempts // (not for the first attempt, do at least one) - select { - case <-ctx.Done(): - shouldLoop = false - case <-time.After(backoffDuration): - // Continue with retry + // Do not sleep after the last attempt. + if attempt+1 < maxRetries { + backoffDuration := p.dialRetryBackoff(attempt) + select { + case <-ctx.Done(): + shouldLoop = false + case <-time.After(backoffDuration): + // Continue with retry + } } continue } @@ -623,6 +723,22 @@ func (p *ConnPool) dialConn(ctx context.Context, pooled bool) (*Conn, error) { return nil, lastErr } +func (p *ConnPool) dialRetryBackoff(attempt int) time.Duration { + if p.cfg.DialerRetryBackoff != nil { + d := p.cfg.DialerRetryBackoff(attempt) + if d < 0 { + return 0 + } + return d + } + + base := p.cfg.DialerRetryTimeout + if base <= 0 { + base = 100 * time.Millisecond + } + return base +} + // calcConnExpiresAt calculates the expiration time for a connection. // It applies random jitter to prevent all connections from expiring simultaneously, // avoiding the "thundering herd" problem where all connections expire at once. @@ -648,19 +764,26 @@ func (p *ConnPool) tryDial() { return } - ctx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) + // Probe dialing even when dialErrorsNum is saturated. Apply DialTimeout per probe + // attempt so custom dialers can't hang indefinitely. + ctx := context.Background() + var cancel context.CancelFunc + if p.cfg.DialTimeout > 0 { + ctx, cancel = context.WithTimeout(ctx, p.cfg.DialTimeout) + } conn, err := p.cfg.Dialer(ctx) + if cancel != nil { + cancel() + } if err != nil { p.setLastDialError(err) time.Sleep(time.Second) - cancel() continue } atomic.StoreUint32(&p.dialErrorsNum, 0) _ = conn.Close() - cancel() return } } @@ -689,12 +812,21 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { } // Track pending requests in pool stats - // NOTE: We only track in stats, not via callback. The AsyncGauge reads stats directly. atomic.AddUint32(&p.stats.PendingRequests, 1) + // Record pending request increment (UpDownCounter) + // Pass pool name explicitly since we don't have a connection yet + poolName := p.cfg.Name + if cb := getMetricPendingRequestsCallback(); cb != nil { + cb(ctx, 1, nil, poolName) + } defer func() { if err != nil { // Failed to get connection, decrement pending requests atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1 + // Record pending request decrement on failure + if cb := getMetricPendingRequestsCallback(); cb != nil { + cb(ctx, -1, nil, poolName) + } } }() @@ -732,6 +864,17 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { p.connsMu.Lock() cn, err = p.popIdle() + if cn != nil { + // Emit idleโ†’used transition inside the lock so Close() sees + // consistent state (conn removed from idleConns = "used"). + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateIdle, MetricStateUsed) + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "idle", false) + cb(ctx, 1, cn, "used", false) + } + } p.connsMu.Unlock() if err != nil { @@ -744,7 +887,8 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { } if !p.isHealthyConn(cn, nowNs) { - _ = p.CloseConn(cn) + // Connection was already transitioned to MetricStateUsed under the lock above. + _ = p.CloseConn(ctx, cn, CloseReasonStale, MetricStateUsed) continue } @@ -755,11 +899,13 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { if hookErr != nil || !acceptConn { if hookErr != nil { internal.Logger.Printf(ctx, "redis: connection pool: failed to process idle connection by hook: %v", hookErr) - _ = p.CloseConn(cn) + // Connection was already transitioned to MetricStateUsed under the lock above. + _ = p.CloseConn(ctx, cn, CloseReasonHookError, MetricStateUsed) } else { internal.Logger.Printf(ctx, "redis: connection pool: conn[%d] rejected by hook, returning to pool", cn.GetID()) + // Connection is already in MetricStateUsed (transitioned under the lock above). // Return connection to pool without freeing the turn that this Get() call holds. - // We use putConnWithoutTurn() to run all the Put hooks and logic without freeing a turn. + // putConnWithoutTurn will emit usedโ†’idle transition. p.putConnWithoutTurn(ctx, cn) cn = nil } @@ -769,19 +915,17 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { atomic.AddUint32(&p.stats.Hits, 1) - // Notify metrics: connection moved from idle to used - if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, cn, "idle", "used") - } - // Record wait time (use cached callback from above) if waitTimeCallback != nil { waitTimeCallback(ctx, waitDuration, cn) } // Decrement pending requests (connection acquired successfully) - // NOTE: We only track in stats, not via callback. The AsyncGauge reads stats directly. atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1 + // Record pending request decrement (UpDownCounter) + if cb := getMetricPendingRequestsCallback(); cb != nil { + cb(ctx, -1, cn, poolName) + } return cn, nil } @@ -802,26 +946,38 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) { // both errors and accept=false mean a hook rejected the connection // this should not happen with a new connection, but we handle it gracefully if err != nil || !acceptConn { - // Failed to process connection, discard it internal.Logger.Printf(ctx, "redis: connection pool: failed to process new connection conn[%d] by hook: accept=%v, err=%v", newcn.GetID(), acceptConn, err) - _ = p.CloseConn(newcn) + // newConn emitted +1 used; CloseConn will emit -1 used if we own the removal. + _ = p.CloseConn(ctx, newcn, CloseReasonHookError, MetricStateUsed) return nil, err } - } - // Notify metrics: new connection is created and used - if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, newcn, "", "used") + // Record connection creation time metric when hooks are used. + // When hookManager is set, ProcessOnGet initializes the connection (AUTH/HELLO), + // causing IsInited()=true. This means _getConn() in redis.go will take the + // early return path and never reach its create time recording. + // When hookManager is nil, _getConn() handles both initialization and create time recording. + if dialStartNs := newcn.GetDialStartNs(); dialStartNs > 0 { + if cb := GetMetricConnectionCreateTimeCallback(); cb != nil { + duration := time.Duration(time.Now().UnixNano() - dialStartNs) + cb(ctx, duration, newcn) + } + } } + // newConn already emitted +1 used, so no transition needed here. + // Record wait time (use cached callback from above) if waitTimeCallback != nil { waitTimeCallback(ctx, waitDuration, newcn) } // Decrement pending requests (connection acquired successfully) - // NOTE: We only track in stats, not via callback. The AsyncGauge reads stats directly. atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1 + // Record pending request decrement (UpDownCounter) + if cb := getMetricPendingRequestsCallback(); cb != nil { + cb(ctx, -1, newcn, poolName) + } return newcn, nil } @@ -835,7 +991,8 @@ func (p *ConnPool) queuedNewConn(ctx context.Context) (*Conn, error) { return nil, ctx.Err() } - dialCtx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) + // Don't apply DialTimeout via context here; dialConn applies DialTimeout per attempt. + dialCtx, cancel := context.WithCancel(context.Background()) w := &wantConn{ ctx: dialCtx, @@ -919,14 +1076,24 @@ func (p *ConnPool) putIdleConn(ctx context.Context, cn *Conn) bool { defer p.connsMu.Unlock() if p.closed() { - _ = cn.Close() + // Don't close here โ€” this connection is still in p.conns and Close() + // will handle closing it and emitting the correct metric decrements. + // We just skip adding it to idleConns. return true } - // poolSize is increased in newConn p.idleConns = append(p.idleConns, cn) p.idleConnsLen.Add(1) + // Connection was created as "used" in newConn; transition to idle. + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, MetricStateIdle) + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + cb(ctx, 1, cn, "idle", false) + } + return true } @@ -1087,6 +1254,7 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { } var shouldCloseConn bool + var removedFromPool bool if p.cfg.MaxIdleConns == 0 || p.idleConnsLen.Load() < p.cfg.MaxIdleConns { // Hot path optimization: try fast IN_USE โ†’ IDLE transition @@ -1111,7 +1279,7 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { case StateClosed: internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, closing it", cn.GetID(), currentState) shouldCloseConn = true - p.removeConnWithLock(cn) + removedFromPool = p.removeConnWithLock(cn) default: // Pool as-is internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, pooling as-is", cn.GetID(), currentState) @@ -1122,34 +1290,73 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { // put them at the opposite end of the queue // Optimization: if we just transitioned to IDLE, we know it's usable - skip the check if !transitionedToIdle && !cn.IsUsable() { - if p.cfg.PoolFIFO { - p.connsMu.Lock() - p.idleConns = append(p.idleConns, cn) + p.connsMu.Lock() + // Check if Close() already removed this connection from p.conns. + // If so, skip the append and metrics โ€” Close() already accounted for it. + if _, inPool := p.conns[cn.GetID()]; inPool { + if p.cfg.PoolFIFO { + p.idleConns = append(p.idleConns, cn) + } else { + p.idleConns = append([]*Conn{cn}, p.idleConns...) + } + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, MetricStateIdle) + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + cb(ctx, 1, cn, "idle", false) + } p.connsMu.Unlock() + p.idleConnsLen.Add(1) } else { - p.connsMu.Lock() - p.idleConns = append([]*Conn{cn}, p.idleConns...) + shouldCloseConn = true p.connsMu.Unlock() } - p.idleConnsLen.Add(1) } else if !shouldCloseConn { p.connsMu.Lock() - p.idleConns = append(p.idleConns, cn) - p.connsMu.Unlock() - p.idleConnsLen.Add(1) + if _, inPool := p.conns[cn.GetID()]; inPool { + p.idleConns = append(p.idleConns, cn) + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, MetricStateIdle) + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + cb(ctx, 1, cn, "idle", false) + } + p.connsMu.Unlock() + p.idleConnsLen.Add(1) + } else { + shouldCloseConn = true + p.connsMu.Unlock() + } } - // Notify metrics: connection moved from used to idle - if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, cn, "used", "idle") + if shouldCloseConn { + // Connection was removed (e.g., hook set state to StateClosed). + // Only emit if we actually removed it from the map (not already taken by Close()). + if removedFromPool { + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, "") + } + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + } + } } } else { shouldCloseConn = true - p.removeConnWithLock(cn) + removedFromPool = p.removeConnWithLock(cn) - // Notify metrics: connection removed (used -> nothing) - if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, cn, "used", "") + // Only emit if we actually removed it from the map (not already taken by Close()). + if removedFromPool { + // Notify metrics: connection removed (used -> nothing) + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, "") + } + // Record connection count decrement (connection removed while in used state) + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + } } } @@ -1158,6 +1365,17 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { } if shouldCloseConn { + // Only emit connection closed if we actually owned the removal. + // If removedFromPool is false, Close() already emitted connectionClosed for this conn. + if removedFromPool { + if cb := getMetricConnectionClosedCallback(); cb != nil { + reason := "conn_pool_close" + if r := cn.closeReason.Load(); r != "" { + reason = r + } + cb(ctx, cn, reason, nil) + } + } _ = p.closeConn(cn) } @@ -1185,24 +1403,35 @@ func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason erro hookManager.ProcessOnRemove(ctx, cn, reason) } - p.removeConnWithLock(cn) + removed := p.removeConnWithLock(cn) if freeTurn { p.freeTurn() } - // Notify metrics: connection removed (assume from used state) - if cb := getMetricConnectionStateChangeCallback(); cb != nil { - cb(ctx, cn, "used", "") + // Only emit metric decrements if we actually removed the connection from the map. + // If removed is false, Close() already removed it and emitted the -1 delta. + if removed { + // Notify metrics: connection removed (assume from used state) + if cb := getMetricConnectionStateChangeCallback(); cb != nil { + cb(ctx, cn, MetricStateUsed, "") + } + // Record connection count decrement (connection removed, assume from used state) + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(ctx, -1, cn, "used", false) + } } - // Record connection closed - if cb := getMetricConnectionClosedCallback(); cb != nil { - reasonStr := "unknown" - if reason != nil { - reasonStr = reason.Error() + // Only emit connection closed if we actually owned the removal. + // If removed is false, Close() already emitted connectionClosed for this conn. + if removed { + if cb := getMetricConnectionClosedCallback(); cb != nil { + reasonStr := "unknown" + if reason != nil { + reasonStr = reason.Error() + } + cb(ctx, cn, reasonStr, reason) } - cb(ctx, cn, reasonStr, reason) } _ = p.closeConn(cn) @@ -1211,19 +1440,60 @@ func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason erro p.checkMinIdleConns() } -func (p *ConnPool) CloseConn(cn *Conn) error { - p.removeConnWithLock(cn) +// CloseConn closes a connection and records metrics. +// Parameters: +// - ctx: context for metric callbacks (enables trace-to-metric correlation) +// - cn: the connection to close +// - 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 { + removed := p.removeConnWithLock(cn) + + // Only emit UpDownCounter decrements if we actually removed the connection. + // If removed is false, Close() already removed it and emitted the -1 delta. + // Only emit connection closed if we actually owned the removal. + // If removed is false, Close() already emitted connectionClosed for this conn. + if removed { + p.recordConnectionMetrics(ctx, cn, reason, fromState) + } + return p.closeConn(cn) } -func (p *ConnPool) removeConnWithLock(cn *Conn) { +func (p *ConnPool) recordConnectionMetrics(ctx context.Context, cn *Conn, reason string, fromState string) { + // Record connection state change: connection is being removed from the specified state + if cb := getMetricConnectionStateChangeCallback(); cb != nil && fromState != "" { + cb(ctx, cn, fromState, "") + } + + // Record connection count decrement (UpDownCounter) for the state the connection was in + if cb := getMetricConnectionCountCallback(); cb != nil && fromState != "" { + cb(ctx, -1, cn, fromState, false) + } + + if cb := getMetricConnectionClosedCallback(); cb != nil { + cb(ctx, cn, reason, nil) + } +} + +// removeConnWithLock removes a connection from the pool under the connsMu lock. +// Returns true if the connection was actually present in p.conns and was removed, +// false if it was already gone (e.g., removed by Close()). Callers must use the +// return value to decide whether to emit metric decrements โ€” this eliminates the +// shutdown race between Close() and concurrent removal paths. +func (p *ConnPool) removeConnWithLock(cn *Conn) bool { p.connsMu.Lock() defer p.connsMu.Unlock() - p.removeConn(cn) + return p.removeConn(cn) } -func (p *ConnPool) removeConn(cn *Conn) { +// removeConn removes a connection from the pool's internal data structures. +// Returns true if the connection was present and removed, false otherwise. +func (p *ConnPool) removeConn(cn *Conn) bool { cid := cn.GetID() + if _, exists := p.conns[cid]; !exists { + return false + } delete(p.conns, cid) atomic.AddUint32(&p.stats.StaleConns, 1) @@ -1239,6 +1509,7 @@ func (p *ConnPool) removeConn(cn *Conn) { } } } + return true } func (p *ConnPool) closeConn(cn *Conn) error { @@ -1290,13 +1561,33 @@ func (p *ConnPool) closed() bool { } func (p *ConnPool) Filter(fn func(*Conn) bool) error { + ctx := context.Background() + p.connsMu.Lock() defer p.connsMu.Unlock() + idleConnSet := make(map[*Conn]struct{}, len(p.idleConns)) + for _, ic := range p.idleConns { + idleConnSet[ic] = struct{}{} + } + var firstErr error for _, cn := range p.conns { if fn(cn) { - if err := p.closeConn(cn); err != nil && firstErr == nil { + var err error + if _, isIdle := idleConnSet[cn]; isIdle { + // Idle connection - remove from pool and close. + p.removeConn(cn) + p.recordConnectionMetrics(ctx, cn, CloseReasonFailover, MetricStateIdle) + err = p.closeConn(cn) + } else { + // Used connection - set closeReason and close the connection. + // The connection remains in p.conns. When putConn() is called later, + // it will close the connection instead of pooling it. + cn.closeReason.Store(CloseReasonFailover) + err = cn.Close() + } + if err != nil && firstErr == nil { firstErr = err } } @@ -1310,10 +1601,38 @@ func (p *ConnPool) Close() error { } var firstErr error + nowNs := time.Now().UnixNano() p.connsMu.Lock() + + // Emit -1 for each connection. Since all idleโ†”used transitions happen + // under connsMu, the idleConns slice is the source of truth for state. + cb := getMetricConnectionCountCallback() + idleSet := make(map[uint64]struct{}, len(p.idleConns)) + for _, cn := range p.idleConns { + idleSet[cn.GetID()] = struct{}{} + } + ctx := context.Background() for _, cn := range p.conns { + // 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) + if cb != nil { + if _, isIdle := idleSet[cn.GetID()]; isIdle { + cb(ctx, -1, cn, "idle", false) + } else { + cb(ctx, -1, cn, "used", false) + } + } + if closedCb := getMetricConnectionClosedCallback(); closedCb != nil { + closedCb(ctx, cn, "pool_shutdown", nil) + } if err := p.closeConn(cn); err != nil && firstErr == nil { - firstErr = err + // Suppress close errors for stale connections, consistent + // with how Get() handles them (see CloseReasonStale path). + if healthy { + firstErr = err + } } } p.conns = nil diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go b/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go index 365219a578b..68295906993 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go @@ -33,8 +33,8 @@ func (p *SingleConnPool) NewConn(ctx context.Context) (*Conn, error) { return p.pool.NewConn(ctx) } -func (p *SingleConnPool) CloseConn(cn *Conn) error { - return p.pool.CloseConn(cn) +func (p *SingleConnPool) CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error { + return p.pool.CloseConn(ctx, cn, reason, fromState) } func (p *SingleConnPool) Get(_ context.Context) (*Conn, error) { diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go b/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go index be869b56939..6763299eba9 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go @@ -61,8 +61,8 @@ func (p *StickyConnPool) NewConn(ctx context.Context) (*Conn, error) { return p.pool.NewConn(ctx) } -func (p *StickyConnPool) CloseConn(cn *Conn) error { - return p.pool.CloseConn(cn) +func (p *StickyConnPool) CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error { + return p.pool.CloseConn(ctx, cn, reason, fromState) } func (p *StickyConnPool) Get(ctx context.Context) (*Conn, error) { diff --git a/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go b/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go index e566d42b3a7..8cfa867887e 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go +++ b/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go @@ -53,18 +53,42 @@ func (p *PubSubPool) NewConn(ctx context.Context, network string, addr string, c func (p *PubSubPool) TrackConn(cn *Conn) { atomic.AddUint32(&p.stats.Active, 1) p.activeConns.Store(cn.GetID(), cn) + // Emit +1 used for PubSub connection + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(context.Background(), 1, cn, "used", true) + } } func (p *PubSubPool) UntrackConn(cn *Conn) { + // LoadAndDelete ensures each connection is only decremented once, + // guarding against double-decrement if Close() already untracked it. + if _, loaded := p.activeConns.LoadAndDelete(cn.GetID()); !loaded { + return + } atomic.AddUint32(&p.stats.Active, ^uint32(0)) atomic.AddUint32(&p.stats.Untracked, 1) - p.activeConns.Delete(cn.GetID()) + // Emit -1 used for PubSub connection + if cb := getMetricConnectionCountCallback(); cb != nil { + cb(context.Background(), -1, cn, "used", true) + } } func (p *PubSubPool) Close() error { p.closed.Store(true) + cb := getMetricConnectionCountCallback() p.activeConns.Range(func(key, value interface{}) bool { cn := value.(*Conn) + // Use LoadAndDelete to atomically claim ownership of this entry. + // If a concurrent UntrackConn already removed it, skip to avoid double-decrement. + if _, loaded := p.activeConns.LoadAndDelete(key); !loaded { + return true + } + atomic.AddUint32(&p.stats.Active, ^uint32(0)) + atomic.AddUint32(&p.stats.Untracked, 1) + // Emit -1 used for each PubSub connection being closed + if cb != nil { + cb(context.Background(), -1, cn, "used", true) + } _ = cn.Close() return true }) 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 bac68f79652..79373d3af99 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 @@ -279,8 +279,8 @@ func (r *Reader) ReadReply() (interface{}, error) { } func (r *Reader) readFloat(line []byte) (float64, error) { - v := string(line[1:]) - switch string(line[1:]) { + v := util.BytesToString(line[1:]) + switch v { case "inf": return math.Inf(1), nil case "-inf": @@ -292,7 +292,7 @@ func (r *Reader) readFloat(line []byte) (float64, error) { } func (r *Reader) readBool(line []byte) (bool, error) { - switch string(line[1:]) { + switch util.BytesToString(line[1:]) { case "t": return true, nil case "f": @@ -303,7 +303,7 @@ func (r *Reader) readBool(line []byte) (bool, error) { func (r *Reader) readBigInt(line []byte) (*big.Int, error) { i := new(big.Int) - if i, ok := i.SetString(string(line[1:]), 10); ok { + if i, ok := i.SetString(util.BytesToString(line[1:]), 10); ok { return i, nil } return nil, fmt.Errorf("redis: can't parse bigInt reply: %q", line) @@ -453,7 +453,7 @@ func (r *Reader) ReadFloat() (float64, error) { case RespFloat: return r.readFloat(line) case RespStatus: - return strconv.ParseFloat(string(line[1:]), 64) + return strconv.ParseFloat(util.BytesToString(line[1:]), 64) case RespString: s, err := r.readStringReply(line) if err != nil { @@ -646,3 +646,193 @@ func IsNilReply(line []byte) bool { (line[0] == RespString || line[0] == RespArray) && line[1] == '-' && line[2] == '1' } + +// ReadRawReply reads the next RESP reply and returns it as raw bytes without parsing. +func (r *Reader) ReadRawReply() ([]byte, error) { + return r.readRawReplyBuf(nil) +} + +func (r *Reader) readRawReplyBuf(buf []byte) ([]byte, error) { + line, err := r.readLine() + if err != nil { + return buf, err + } + + buf = append(buf, line...) + buf = append(buf, '\r', '\n') + + switch line[0] { + case RespStatus, RespError, RespInt, RespNil, RespFloat, RespBool, RespBigInt: + return buf, nil + + case RespString, RespVerbatim, RespBlobError: + n, err := replyLen(line) + if err != nil { + if err == Nil { + return buf, nil + } + return buf, err + } + curLen := len(buf) + buf = append(buf, make([]byte, n+2)...) + _, err = io.ReadFull(r.rd, buf[curLen:]) + return buf, err + + case RespArray, RespSet, RespPush: + n, err := replyLen(line) + if err != nil { + if err == Nil { + return buf, nil + } + return buf, err + } + for i := 0; i < n; i++ { + buf, err = r.readRawReplyBuf(buf) + if err != nil { + return buf, err + } + } + return buf, nil + + case RespMap: + n, err := replyLen(line) + if err != nil { + if err == Nil { + return buf, nil + } + return buf, err + } + for i := 0; i < n*2; i++ { + buf, err = r.readRawReplyBuf(buf) + if err != nil { + return buf, err + } + } + return buf, nil + + case RespAttr: + // Per RESP3 spec, an attribute is always followed by the actual command reply. + // We need to read the attribute's key-value pairs AND the following reply. + n, err := replyLen(line) + if err != nil { + if err == Nil { + return buf, nil + } + return buf, err + } + // Read the attribute key-value pairs + for i := 0; i < n*2; i++ { + buf, err = r.readRawReplyBuf(buf) + if err != nil { + return buf, err + } + } + // Read the command reply that follows the attribute + return r.readRawReplyBuf(buf) + } + + return buf, fmt.Errorf("redis: can't read raw reply: %.100q", line) +} + +var crlf = []byte{'\r', '\n'} + +// ReadRawReplyWriteTo streams the next RESP reply directly to w without intermediate allocations. +// Returns the number of bytes written and any error encountered. +func (r *Reader) ReadRawReplyWriteTo(w io.Writer) (int64, error) { + return r.readRawReplyWriteTo(w) +} + +func (r *Reader) readRawReplyWriteTo(w io.Writer) (int64, error) { + line, err := r.readLine() + if err != nil { + return 0, err + } + + var written int64 + n, err := w.Write(line) + written += int64(n) + if err != nil { + return written, err + } + n, err = w.Write(crlf) + written += int64(n) + if err != nil { + return written, err + } + + switch line[0] { + case RespStatus, RespError, RespInt, RespNil, RespFloat, RespBool, RespBigInt: + return written, nil + + case RespString, RespVerbatim, RespBlobError: + dataLen, err := replyLen(line) + if err != nil { + if err == Nil { + return written, nil + } + return written, err + } + copied, err := io.CopyN(w, r.rd, int64(dataLen)+2) + written += copied + return written, err + + case RespArray, RespSet, RespPush: + count, err := replyLen(line) + if err != nil { + if err == Nil { + return written, nil + } + return written, err + } + for i := 0; i < count; i++ { + n, err := r.readRawReplyWriteTo(w) + written += n + if err != nil { + return written, err + } + } + return written, nil + + case RespMap: + count, err := replyLen(line) + if err != nil { + if err == Nil { + return written, nil + } + return written, err + } + for i := 0; i < count*2; i++ { + n, err := r.readRawReplyWriteTo(w) + written += n + if err != nil { + return written, err + } + } + return written, nil + + case RespAttr: + // Per RESP3 spec, an attribute is always followed by the actual command reply. + // We need to read the attribute's key-value pairs AND the following reply. + count, err := replyLen(line) + if err != nil { + if err == Nil { + return written, nil + } + return written, err + } + // Read the attribute key-value pairs + for i := 0; i < count*2; i++ { + n, err := r.readRawReplyWriteTo(w) + written += n + if err != nil { + return written, err + } + } + // Read the command reply that follows the attribute + n, err := r.readRawReplyWriteTo(w) + written += n + return written, err + } + + return written, fmt.Errorf("redis: can't read raw reply: %.100q", line) +} diff --git a/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go b/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go index a28240f5b7c..a75370cf798 100644 --- a/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go +++ b/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go @@ -309,13 +309,25 @@ func IsReadOnlyError(err error) bool { if errors.As(err, &readOnlyErr) { return true } - // Check if wrapped error is a RedisError with READONLY prefix + // Check if wrapped error is a RedisError with READONLY prefix or Lua script READONLY var redisErr RedisError - if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "READONLY ") { - return true + if errors.As(err, &redisErr) { + s := redisErr.Error() + if strings.HasPrefix(s, "READONLY ") { + return true + } + // Lua script wrapped READONLY errors: + // "ERR Error running script (call to f_): @user_script:N: -READONLY You can't write against a read only replica." + if strings.Contains(s, "-READONLY You can't write against a read only replica") { + return true + } } // Fallback to string checking for backward compatibility - return strings.HasPrefix(err.Error(), "READONLY ") + s := err.Error() + if strings.HasPrefix(s, "READONLY ") { + return true + } + return strings.Contains(s, "-READONLY You can't write against a read only replica") } // IsMovedError checks if an error is a MovedError, even if wrapped. diff --git a/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go b/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go index db666f3a5ba..70d5acdcae8 100644 --- a/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go +++ b/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go @@ -4,7 +4,6 @@ import ( "context" "net" "runtime" - "strings" "time" "github.com/redis/go-redis/v9/internal" @@ -364,20 +363,46 @@ func (c *Config) applyWorkerDefaults(poolSize int) { } } +// endpointDetectResolveTimeout bounds the DNS lookup performed by +// DetectEndpointType so a slow or broken resolver cannot block client +// construction for the full system resolver timeout (often 5-30s). +const endpointDetectResolveTimeout = 2 * time.Second + +// cgnatNet is RFC6598 shared address space (100.64.0.0/10), used by many +// cloud/carrier NATs and not covered by net.IP.IsPrivate. +var cgnatNet = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} + +// isPrivateIP reports whether ip belongs to a range that should be treated +// as "internal" for the purpose of endpoint type detection. It extends +// net.IP.IsPrivate (RFC1918 + RFC4193) with loopback, link-local and +// RFC6598 shared address space (CGNAT). +func isPrivateIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return true + } + if v4 := ip.To4(); v4 != nil && cgnatNet.Contains(v4) { + return true + } + return false +} + // DetectEndpointType automatically detects the appropriate endpoint type // based on the connection address and TLS configuration. // -// For IP addresses: +// TLS behaviour: // - If TLS is enabled: requests FQDN for proper certificate validation -// - If TLS is disabled: requests IP for better performance -// -// For hostnames: -// - If TLS is enabled: always requests FQDN for proper certificate validation -// - If TLS is disabled: requests IP for better performance +// (SNI / hostname verification). +// - If TLS is disabled: always requests IP for better performance, even +// when the configured address is a hostname. In that case the hostname +// is resolved to determine whether it belongs to an internal or +// external network range. // // Internal vs External detection: // - For IPs: uses private IP range detection -// - For hostnames: uses heuristics based on common internal naming patterns +// - For hostnames: resolves the hostname to an IP address and uses the IP range detection func DetectEndpointType(addr string, tlsEnabled bool) EndpointType { // Extract host from "host:port" format host, _, err := net.SplitHostPort(addr) @@ -385,6 +410,16 @@ func DetectEndpointType(addr string, tlsEnabled bool) EndpointType { host = addr // Assume no port } + // An empty host (e.g., ":6379") conventionally means the loopback + // interface and is treated as internal. With TLS off we return an IP + // endpoint; with TLS on the caller still needs an FQDN for SNI. + if host == "" { + if tlsEnabled { + return EndpointTypeInternalFQDN + } + return EndpointTypeInternalIP + } + // Check if the host is an IP address or hostname ip := net.ParseIP(host) isIPAddress := ip != nil @@ -392,7 +427,7 @@ func DetectEndpointType(addr string, tlsEnabled bool) EndpointType { if isIPAddress { // Address is an IP - determine if it's private or public - isPrivate := ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() + isPrivate := isPrivateIP(ip) if tlsEnabled { // TLS with IP addresses - still prefer FQDN for certificate validation @@ -410,48 +445,58 @@ func DetectEndpointType(addr string, tlsEnabled bool) EndpointType { } } } else { - // Address is a hostname - isInternalHostname := isInternalHostname(host) - if isInternalHostname { - endpointType = EndpointTypeInternalFQDN + // Address is a hostname - resolve it under a bounded timeout so a + // slow/broken DNS server cannot stall client construction. + ctx, cancel := context.WithTimeout(context.Background(), endpointDetectResolveTimeout) + defer cancel() + + isInternal, err := isInternalHostname(ctx, host) + // Will fallback to external classification if we can't determine + // whether the hostname is internal. + if err != nil && internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(ctx, "Failed to determine if hostname %q is internal: %v", host, err) + } + + if tlsEnabled { + // With TLS the server name must be preserved for certificate + // validation, so request an FQDN endpoint. + if isInternal { + endpointType = EndpointTypeInternalFQDN + } else { + endpointType = EndpointTypeExternalFQDN + } } else { - endpointType = EndpointTypeExternalFQDN + // Without TLS we always prefer IP endpoints for performance, + // even if the configured address is a hostname. + if isInternal { + endpointType = EndpointTypeInternalIP + } else { + endpointType = EndpointTypeExternalIP + } } } return endpointType } -// isInternalHostname determines if a hostname appears to be internal/private. -// This is a heuristic based on common naming patterns. -func isInternalHostname(hostname string) bool { - // Convert to lowercase for comparison - hostname = strings.ToLower(hostname) - - // Common internal hostname patterns - internalPatterns := []string{ - "localhost", - ".local", - ".internal", - ".corp", - ".lan", - ".intranet", - ".private", - } - - // Check for exact match or suffix match - for _, pattern := range internalPatterns { - if hostname == pattern || strings.HasSuffix(hostname, pattern) { - return true - } +// isInternalHostname resolves the hostname (both IPv4 and IPv6) under the +// given context and reports whether every resolved address is in a +// private/internal range. If any address is public the hostname is treated +// as external. A resolution error returns (false, err). An empty result set +// returns (false, nil); callers are expected to fall back to an external +// classification when the hostname cannot be determined to be internal. +func isInternalHostname(ctx context.Context, hostname string) (bool, error) { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname) + if err != nil { + return false, err } - - // Check for RFC 1918 style hostnames (e.g., redis-1, db-server, etc.) - // If hostname doesn't contain dots, it's likely internal - if !strings.Contains(hostname, ".") { - return true + if len(ips) == 0 { + return false, nil } - - // Default to external for fully qualified domain names - return false + for _, ia := range ips { + if !isPrivateIP(ia.IP) { + return false, nil + } + } + return true, nil } diff --git a/vendor/github.com/redis/go-redis/v9/options.go b/vendor/github.com/redis/go-redis/v9/options.go index 5db27102567..af902feafc0 100644 --- a/vendor/github.com/redis/go-redis/v9/options.go +++ b/vendor/github.com/redis/go-redis/v9/options.go @@ -5,10 +5,11 @@ import ( "crypto/tls" "errors" "fmt" + "maps" "net" "net/url" "runtime" - "sort" + "slices" "strconv" "strings" "sync/atomic" @@ -143,6 +144,13 @@ type Options struct { // default: 100 milliseconds DialerRetryTimeout time.Duration + // DialerRetryBackoff controls the delay between dial retry attempts. + // + // attempt is 0-based: attempt=0 is the delay after the 1st failed dial (before the 2nd attempt). + // + // If nil, dial retry backoff is constant and equals DialerRetryTimeout (default: 100ms). + DialerRetryBackoff func(attempt int) time.Duration + // ReadTimeout for socket reads. If reached, commands will fail // with a timeout instead of blocking. Supported values: // @@ -644,11 +652,8 @@ func (o *queryOptions) remaining() []string { if len(o.q) == 0 { return nil } - keys := make([]string, 0, len(o.q)) - for k := range o.q { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Collect(maps.Keys(o.q)) + slices.Sort(keys) return keys } @@ -755,6 +760,7 @@ func newConnPool( DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, MinIdleConns: minIdleConns, MaxIdleConns: maxIdleConns, MaxActiveConns: maxActiveConns, @@ -801,6 +807,7 @@ func newPubSubPool( DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, MinIdleConns: minIdleConns, MaxIdleConns: maxIdleConns, MaxActiveConns: maxActiveConns, diff --git a/vendor/github.com/redis/go-redis/v9/osscluster.go b/vendor/github.com/redis/go-redis/v9/osscluster.go index 6fb51dc2a5f..ca72f68bf27 100644 --- a/vendor/github.com/redis/go-redis/v9/osscluster.go +++ b/vendor/github.com/redis/go-redis/v9/osscluster.go @@ -1,6 +1,7 @@ package redis import ( + "cmp" "context" "crypto/tls" "errors" @@ -9,6 +10,7 @@ import ( "net" "net/url" "runtime" + "slices" "sort" "strings" "sync" @@ -104,6 +106,10 @@ type ClusterOptions struct { // default: 100 milliseconds DialerRetryTimeout time.Duration + // DialerRetryBackoff controls the delay between dial retry attempts. + // See Options.DialerRetryBackoff for details. + DialerRetryBackoff func(attempt int) time.Duration + ReadTimeout time.Duration WriteTimeout time.Duration ContextTimeoutEnabled bool @@ -429,6 +435,7 @@ func (opt *ClusterOptions) clientOptions() *Options { DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -786,20 +793,6 @@ type clusterSlot struct { nodes []*clusterNode } -type clusterSlotSlice []*clusterSlot - -func (p clusterSlotSlice) Len() int { - return len(p) -} - -func (p clusterSlotSlice) Less(i, j int) bool { - return p[i].start < p[j].start -} - -func (p clusterSlotSlice) Swap(i, j int) { - p[i], p[j] = p[j], p[i] -} - type clusterState struct { nodes *clusterNodes Masters []*clusterNode @@ -858,7 +851,9 @@ func newClusterState( }) } - sort.Sort(clusterSlotSlice(c.slots)) + slices.SortFunc(c.slots, func(a, b *clusterSlot) int { + return cmp.Compare(a.start, b.start) + }) time.AfterFunc(time.Minute, func() { nodes.GC(c.generation) @@ -1139,8 +1134,12 @@ type ClusterClient struct { } // NewClusterClient returns a Redis Cluster client as described in -// http://redis.io/topics/cluster-spec. +// https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec. +// Passing nil ClusterOptions will cause a panic. func NewClusterClient(opt *ClusterOptions) *ClusterClient { + if opt == nil { + panic("redis: NewClusterClient nil options") + } opt.init() c := &ClusterClient{ @@ -1185,7 +1184,8 @@ func NewClusterClient(opt *ClusterOptions) *ClusterClient { return c } -// Options returns read-only Options that were used to create the client. +// Options returns read-only *ClusterOptions that were used to create the client. +// Any alteration of the returned *ClusterOptions may result in undefined behaviour. func (c *ClusterClient) Options() *ClusterOptions { return c.opt } @@ -1295,7 +1295,7 @@ func (c *ClusterClient) process(ctx context.Context, cmd Cmder) error { continue } - if shouldRetry(lastErr, cmd.readTimeout() == nil) { + if shouldRetry(lastErr, cmd.readTimeout() == nil) && !cmd.NoRetry() { // First retry the same node. if attempt == 0 { continue @@ -1711,7 +1711,7 @@ func (c *ClusterClient) processPipelineNodeConn( if isBadConn(err, false, node.Client.getAddr()) { node.MarkAsFailing() } - if shouldRetry(err, true) { + if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) { _ = c.mapCmdsByNode(ctx, failedCmds, cmds) } setCmdsErr(cmds, err) @@ -1747,7 +1747,7 @@ func (c *ClusterClient) pipelineReadCmds( } if !isRedisError(err) { - if shouldRetry(err, true) { + if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) { _ = c.mapCmdsByNode(ctx, failedCmds, cmds) } setCmdsErr(cmds[i+1:], err) @@ -1755,7 +1755,7 @@ func (c *ClusterClient) pipelineReadCmds( } } - if err := cmds[0].Err(); err != nil && shouldRetry(err, true) { + if err := cmds[0].Err(); err != nil && shouldRetry(err, true) && !cmdsContainNoRetry(cmds) { _ = c.mapCmdsByNode(ctx, failedCmds, cmds) return err } @@ -1958,7 +1958,7 @@ func (c *ClusterClient) processTxPipelineNodeConn( if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error { return writeCmds(wr, cmds) }); err != nil { - if shouldRetry(err, true) { + if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) { _ = c.mapCmdsByNode(ctx, failedCmds, cmds) } setCmdsErr(cmds, err) diff --git a/vendor/github.com/redis/go-redis/v9/otel.go b/vendor/github.com/redis/go-redis/v9/otel.go index a81377d4104..1ea359364f2 100644 --- a/vendor/github.com/redis/go-redis/v9/otel.go +++ b/vendor/github.com/redis/go-redis/v9/otel.go @@ -79,6 +79,25 @@ type OTelRecorder interface { RecordStreamLag(ctx context.Context, lag time.Duration, cn ConnInfo, streamName, consumerGroup, consumerName string) } +// OTelConnectionCounter is an optional capability interface for recording +// connection count and pending request changes via UpDownCounters. +// Implementations of OTelRecorder can optionally implement this interface +// to receive connection count and pending request delta notifications. +// This is kept separate from OTelRecorder to avoid breaking existing +// third-party implementations when new methods are added. +type OTelConnectionCounter interface { + // RecordConnectionCount records a change in connection count (UpDownCounter) + // delta: +1 when connection added, -1 when connection removed + // state: connection state (e.g., "idle", "used") + // isPubSub: true if this is a PubSub connection + RecordConnectionCount(ctx context.Context, delta int, cn ConnInfo, state string, isPubSub bool) + + // RecordPendingRequests records a change in pending requests (UpDownCounter) + // delta: +1 when request starts waiting, -1 when request stops waiting + // poolName is passed explicitly because we may not have a connection yet when request starts + RecordPendingRequests(ctx context.Context, delta int, cn ConnInfo, poolName string) +} + // This is used for async gauge metrics that need to pull stats from pools periodically. type OTelPoolRegistrar interface { // RegisterPool is called when a new client is created with its main connection pool. @@ -163,6 +182,18 @@ func (a *otelRecorderAdapter) RecordStreamLag(ctx context.Context, lag time.Dura a.recorder.RecordStreamLag(ctx, lag, toConnInfo(cn), streamName, consumerGroup, consumerName) } +func (a *otelRecorderAdapter) RecordConnectionCount(ctx context.Context, delta int, cn *pool.Conn, state string, isPubSub bool) { + if counter, ok := a.recorder.(OTelConnectionCounter); ok { + counter.RecordConnectionCount(ctx, delta, toConnInfo(cn), state, isPubSub) + } +} + +func (a *otelRecorderAdapter) RecordPendingRequests(ctx context.Context, delta int, cn *pool.Conn, poolName string) { + if counter, ok := a.recorder.(OTelConnectionCounter); ok { + counter.RecordPendingRequests(ctx, delta, toConnInfo(cn), poolName) + } +} + func (a *otelRecorderAdapter) RegisterPool(poolName string, p pool.Pooler) { if registrar, ok := a.recorder.(OTelPoolRegistrar); ok { registrar.RegisterPool(poolName, &poolerAdapter{p}) diff --git a/vendor/github.com/redis/go-redis/v9/pipeline.go b/vendor/github.com/redis/go-redis/v9/pipeline.go index 567bf121a31..41b8322137e 100644 --- a/vendor/github.com/redis/go-redis/v9/pipeline.go +++ b/vendor/github.com/redis/go-redis/v9/pipeline.go @@ -49,7 +49,7 @@ type Pipeliner interface { var _ Pipeliner = (*Pipeline)(nil) // Pipeline implements pipelining as described in -// http://redis.io/topics/pipelining. +// https://redis.io/docs/latest/develop/using-commands/pipelining. // Please note: it is not safe for concurrent use by multiple goroutines. type Pipeline struct { cmdable diff --git a/vendor/github.com/redis/go-redis/v9/pubsub.go b/vendor/github.com/redis/go-redis/v9/pubsub.go index 49eec9358c6..15bde54e8ce 100644 --- a/vendor/github.com/redis/go-redis/v9/pubsub.go +++ b/vendor/github.com/redis/go-redis/v9/pubsub.go @@ -3,6 +3,8 @@ package redis import ( "context" "fmt" + "maps" + "slices" "strings" "sync" "time" @@ -15,7 +17,7 @@ import ( ) // PubSub implements Pub/Sub commands as described in -// http://redis.io/topics/pubsub. Message receiving is NOT safe +// https://redis.io/docs/latest/develop/pubsub. Message receiving is NOT safe // for concurrent use by multiple goroutines. // // PubSub automatically reconnects to Redis Server and resubscribes @@ -56,9 +58,9 @@ func (c *PubSub) String() string { c.mu.Lock() defer c.mu.Unlock() - channels := mapKeys(c.channels) - channels = append(channels, mapKeys(c.patterns)...) - channels = append(channels, mapKeys(c.schannels)...) + channels := slices.Collect(maps.Keys(c.channels)) + channels = append(channels, slices.Collect(maps.Keys(c.patterns))...) + channels = append(channels, slices.Collect(maps.Keys(c.schannels))...) return fmt.Sprintf("PubSub(%s)", strings.Join(channels, ", ")) } @@ -85,7 +87,7 @@ func (c *PubSub) conn(ctx context.Context, newChannels []string) (*pool.Conn, er c.opt.Addr = internal.RedisNull } - channels := mapKeys(c.channels) + channels := slices.Collect(maps.Keys(c.channels)) channels = append(channels, newChannels...) cn, err := c.newConn(ctx, c.opt.Addr, channels) @@ -112,18 +114,18 @@ func (c *PubSub) resubscribe(ctx context.Context, cn *pool.Conn) error { var firstErr error if len(c.channels) > 0 { - firstErr = c._subscribe(ctx, cn, "subscribe", mapKeys(c.channels)) + firstErr = c._subscribe(ctx, cn, "subscribe", slices.Collect(maps.Keys(c.channels))) } if len(c.patterns) > 0 { - err := c._subscribe(ctx, cn, "psubscribe", mapKeys(c.patterns)) + err := c._subscribe(ctx, cn, "psubscribe", slices.Collect(maps.Keys(c.patterns))) if err != nil && firstErr == nil { firstErr = err } } if len(c.schannels) > 0 { - err := c._subscribe(ctx, cn, "ssubscribe", mapKeys(c.schannels)) + err := c._subscribe(ctx, cn, "ssubscribe", slices.Collect(maps.Keys(c.schannels))) if err != nil && firstErr == nil { firstErr = err } @@ -132,16 +134,6 @@ func (c *PubSub) resubscribe(ctx context.Context, cn *pool.Conn) error { return firstErr } -func mapKeys(m map[string]struct{}) []string { - s := make([]string, len(m)) - i := 0 - for k := range m { - s[i] = k - i++ - } - return s -} - func (c *PubSub) _subscribe( ctx context.Context, cn *pool.Conn, redisCmd string, channels []string, ) error { @@ -284,9 +276,7 @@ func (c *PubSub) Unsubscribe(ctx context.Context, channels ...string) error { } } else { // Unsubscribe from all channels. - for channel := range c.channels { - delete(c.channels, channel) - } + clear(c.channels) } err := c.subscribe(ctx, "unsubscribe", channels...) @@ -305,9 +295,7 @@ func (c *PubSub) PUnsubscribe(ctx context.Context, patterns ...string) error { } } else { // Unsubscribe from all patterns. - for pattern := range c.patterns { - delete(c.patterns, pattern) - } + clear(c.patterns) } err := c.subscribe(ctx, "punsubscribe", patterns...) @@ -326,9 +314,7 @@ func (c *PubSub) SUnsubscribe(ctx context.Context, channels ...string) error { } } else { // Unsubscribe from all channels. - for channel := range c.schannels { - delete(c.schannels, channel) - } + clear(c.schannels) } err := c.subscribe(ctx, "sunsubscribe", channels...) @@ -366,6 +352,25 @@ func (c *PubSub) Ping(ctx context.Context, payload ...string) error { return err } +// ClientSetName assigns a namee to the PubSub connection using CLIENT SETNAME, +// The name is visible in CLIENT LIST output and is useful for debugging +// and identifying connections in a redis instance. +func (c *PubSub) ClientSetName(ctx context.Context, name string) error { + cmd := NewStatusCmd(ctx, "client", "setname", name) + + c.mu.Lock() + defer c.mu.Unlock() + + cn, err := c.conn(ctx, nil) + if err != nil { + return err + } + + err = c.writeCmd(ctx, cn, cmd) + c.releaseConn(ctx, cn, err, false) + return err +} + // Subscription received after a successful subscription to channel. type Subscription struct { // Can be "subscribe", "unsubscribe", "psubscribe" or "punsubscribe". diff --git a/vendor/github.com/redis/go-redis/v9/redis.go b/vendor/github.com/redis/go-redis/v9/redis.go index 85622e435f0..923bad62a4d 100644 --- a/vendor/github.com/redis/go-redis/v9/redis.go +++ b/vendor/github.com/redis/go-redis/v9/redis.go @@ -215,6 +215,96 @@ func (hs *hooksMixin) processTxPipelineHook(ctx context.Context, cmds []Cmder) e //------------------------------------------------------------------------------ +// Stable identifiers for baseClient.onClose hooks. Each component that +// registers a close callback owns a dedicated id here so the set of known +// hooks is discoverable in one place and id collisions are caught at +// compile time. New ids should be added as additional constants. +const ( + // onCloseHookIDSentinelFailover identifies the close callback installed + // by NewFailoverClient to tear down sentinel failover background work. + onCloseHookIDSentinelFailover = "sentinel-failover" +) + +// onCloseHooks is a small registry of named close callbacks attached to a +// baseClient. Each callback is identified by a stable string id; registering +// the same id twice replaces the previous callback rather than chaining onto +// it. This guarantees the registry stays bounded regardless of how often a +// hook is (re)registered and avoids the unbounded closure chain that +// motivated issue #3772. +// +// Hooks are invoked in registration order. All hooks run regardless of +// individual errors; the first non-nil error is returned. +// +// A zero-value onCloseHooks is ready to use. It is safe for concurrent use. +// Clones of a baseClient share the same *onCloseHooks so registrations and +// close semantics are preserved across WithTimeout / WithContext / etc. +type onCloseHooks struct { + mu sync.Mutex + order []string + hooks map[string]func() error +} + +// register adds or replaces the callback associated with id. Re-registering +// an existing id overwrites the previous callback in place; new ids are +// appended to the invocation order. +func (h *onCloseHooks) register(id string, fn func() error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.hooks == nil { + h.hooks = make(map[string]func() error) + } + if _, exists := h.hooks[id]; !exists { + h.order = append(h.order, id) + } + h.hooks[id] = fn +} + +// unregister removes the callback associated with id, if any. It is kept +// for API symmetry with register so future callers (e.g. dynamic hook +// owners that need to detach before client Close) do not have to +// reinvent it. +// +//nolint:unused // kept for API symmetry with register; see comment above. +func (h *onCloseHooks) unregister(id string) { + h.mu.Lock() + defer h.mu.Unlock() + if _, exists := h.hooks[id]; !exists { + return + } + delete(h.hooks, id) + for i, x := range h.order { + if x == id { + h.order = append(h.order[:i], h.order[i+1:]...) + break + } + } +} + +// run invokes all registered callbacks in registration order and returns +// the first non-nil error encountered. All callbacks are executed even if +// an earlier one returns an error. +func (h *onCloseHooks) run() error { + if h == nil { + return nil + } + h.mu.Lock() + fns := make([]func() error, 0, len(h.order)) + for _, id := range h.order { + if fn := h.hooks[id]; fn != nil { + fns = append(fns, fn) + } + } + h.mu.Unlock() + + var firstErr error + for _, fn := range fns { + if err := fn(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + type baseClient struct { opt *Options optLock sync.RWMutex @@ -222,7 +312,13 @@ type baseClient struct { pubSubPool *pool.PubSubPool hooksMixin - onClose func() error // hook called when client is closed + // onClose holds named callbacks invoked when the client is closed. + // Registering a new callback never removes previously registered ones; + // only re-registering the same id replaces the existing callback. This + // lets composing components (e.g. sentinel failover) add close logic + // safely without fear of overwriting each other and without building + // unbounded closure chains on repeated registration. + onClose *onCloseHooks // Push notification processing pushProcessor push.NotificationProcessor @@ -252,8 +348,17 @@ func (c *baseClient) clone() *baseClient { return clone } +// cloneOpt clones c.opt while holding optLock to prevent races with initConn +// which writes to MaintNotificationsConfig.Mode under the same lock. +func (c *baseClient) cloneOpt() *Options { + c.optLock.RLock() + clone := c.opt.clone() + c.optLock.RUnlock() + return clone +} + func (c *baseClient) withTimeout(timeout time.Duration) *baseClient { - opt := c.opt.clone() + opt := c.cloneOpt() opt.ReadTimeout = timeout opt.WriteTimeout = timeout @@ -347,7 +452,11 @@ func (c *baseClient) onAuthenticationErr() func(poolCn *pool.Conn, err error) { if err != nil { if isBadConn(err, false, c.opt.Addr) { // Close the connection to force a reconnection. - err := c.connPool.CloseConn(poolCn) + // Re-auth happens on connections that were idle in the pool (the pool hook + // waits for IDLE state before transitioning to UNUSABLE for re-auth). + // From metrics perspective, the connection was never "used" by a client. + // Note: Using context.Background() as this callback doesn't have access to caller's context. + err := c.connPool.CloseConn(context.Background(), poolCn, pool.CloseReasonAuthError, pool.MetricStateIdle) if err != nil { internal.Logger.Printf(context.Background(), "redis: failed to close connection: %v", err) // try to close the network connection directly @@ -363,27 +472,6 @@ func (c *baseClient) onAuthenticationErr() func(poolCn *pool.Conn, err error) { } } -func (c *baseClient) wrappedOnClose(newOnClose func() error) func() error { - onClose := c.onClose - return func() error { - var firstErr error - err := newOnClose() - // Even if we have an error we would like to execute the onClose hook - // if it exists. We will return the first error that occurred. - // This is to keep error handling consistent with the rest of the code. - if err != nil { - firstErr = err - } - if onClose != nil { - err = onClose() - if err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr - } -} - func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { // This function is called in two scenarios: // 1. First-time init: Connection is in CREATED state (from pool.Get()) @@ -483,7 +571,22 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { return fmt.Errorf("failed to subscribe to streaming credentials: %w", initErr) } - c.onClose = c.wrappedOnClose(unsubscribeFromCredentialsProvider) + // Per-connection unsubscribe is attached to the connection itself so it + // runs when this specific connection is closed. Do not register it on + // c.onClose: initConn runs for every (re)initialized connection, and + // attaching per-connection state to the shared baseClient registry would + // either leak entries (one per connection id, never trimmed) or โ€” with + // the pre-fix wrappedOnClose approach โ€” build an unbounded closure chain + // retaining every prior connection's unsubscribe (see issue #3772). + // + // Note: pool.Conn.SetOnClose OVERWRITES any prior callback (see the + // doc on that method). That is safe here because the streaming + // credentials Manager deduplicates listeners by connection id, so a + // second initConn on the same cn re-Subscribes the SAME listener and + // the returned unsubscribe is equivalent to the one already installed. + // Any future code path that could hand out a distinct unsubscribe on + // re-initialization must first invoke the existing one to avoid + // orphaning the old subscription on the credentials provider. cn.SetOnClose(unsubscribeFromCredentialsProvider) username, password = credentials.BasicAuth() @@ -501,8 +604,13 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { // for redis-server versions that do not support the HELLO command, // RESP2 will continue to be used. + // helloOK tracks whether HELLO succeeded. If it did not, the connection + // falls back to RESP2 regardless of c.opt.Protocol, and features that + // require RESP3 (e.g. maintenance notifications) must be skipped. + helloOK := false if initErr = conn.Hello(ctx, c.opt.Protocol, username, password, c.opt.ClientName).Err(); initErr == nil { // Authentication successful with HELLO command + helloOK = true } else if !isRedisError(initErr) { // When the server responds with the RESP protocol and the result is not a normal // execution result of the HELLO command, we consider it to be an indication that @@ -551,10 +659,38 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { maintNotifEnabled := c.opt.MaintNotificationsConfig != nil && c.opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled protocol := c.opt.Protocol var endpointType maintnotifications.EndpointType + var maintNotifMode maintnotifications.Mode if maintNotifEnabled { endpointType = c.opt.MaintNotificationsConfig.EndpointType + maintNotifMode = c.opt.MaintNotificationsConfig.Mode } c.optLock.RUnlock() + + // Maintenance notifications require RESP3 push frames. If HELLO failed + // and the connection fell back to RESP2, there is no point in sending + // CLIENT MAINT_NOTIFICATIONS: the server either rejects it (making the + // error misleading) or accepts it silently, leaving the client unable + // to receive any notifications. Decide based on the actual negotiated + // protocol rather than the requested one. + if maintNotifEnabled && protocol == 3 && !helloOK { + if maintNotifMode == maintnotifications.ModeEnabled { + // Explicitly requested - fail fast with a clear reason. + cn.GetStateMachine().Transition(pool.StateClosed) + if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil { + errorCallback(ctx, "HANDSHAKE_FAILED", cn, "HANDSHAKE_FAILED", true, 0) + } + return fmt.Errorf("failed to enable maintnotifications: server does not support RESP3 (HELLO command failed)") + } + // auto/other modes: silently disable maintnotifications for this client. + c.optLock.Lock() + c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeDisabled + c.optLock.Unlock() + if err := c.disableMaintNotificationsUpgrades(); err != nil { + internal.Logger.Printf(ctx, "failed to disable maintnotifications in auto mode: %v", err) + } + maintNotifEnabled = false + } + var maintNotifHandshakeErr error if maintNotifEnabled && protocol == 3 { maintNotifHandshakeErr = conn.ClientMaintNotifications( @@ -703,7 +839,9 @@ func (c *baseClient) process(ctx context.Context, cmd Cmder) error { if cn != nil { lastConn = cn } - if err == nil || !retry { + // Don't retry if command explicitly disables retries (e.g., RawWriteToCmd + // which writes directly to an io.Writer and cannot undo partial writes) + if err == nil || !retry || cmd.NoRetry() { // Record total operation duration if opDurationCallback != nil { operationDuration := time.Since(operationStart) @@ -948,10 +1086,8 @@ func (c *baseClient) Close() error { firstErr = err } - if c.onClose != nil { - if err := c.onClose(); err != nil && firstErr == nil { - firstErr = err - } + if err := c.onClose.run(); err != nil && firstErr == nil { + firstErr = err } // Unregister pools from OTel before closing them @@ -1028,7 +1164,10 @@ func (c *baseClient) generalProcessPipeline( canRetry, err = p(ctx, cn, cmds) return err }) - if lastErr == nil || !canRetry || !shouldRetry(lastErr, true) { + // Don't retry if any command in the pipeline explicitly disables retries + // (e.g., RawWriteToCmd which writes directly to an io.Writer and cannot + // undo partial writes on retry) + if lastErr == nil || !canRetry || !shouldRetry(lastErr, true) || cmdsContainNoRetry(cmds) { // The error should be set here only when failing to obtain the conn. if !isRedisError(lastErr) { setCmdsErr(cmds, lastErr) @@ -1196,6 +1335,7 @@ type Client struct { } // NewClient returns a client to the Redis Server specified by Options. +// Passing nil Options will cause a panic. func NewClient(opt *Options) *Client { if opt == nil { panic("redis: NewClient nil options") @@ -1208,7 +1348,8 @@ func NewClient(opt *Options) *Client { c := Client{ baseClient: &baseClient{ - opt: opt, + opt: opt, + onClose: &onCloseHooks{}, }, } c.init() @@ -1295,7 +1436,8 @@ func (c *Client) Process(ctx context.Context, cmd Cmder) error { return err } -// Options returns read-only Options that were used to create the client. +// Options returns read-only *Options that were used to create the client. +// Any alteration of the returned *Options may result in undefined behaviour. func (c *Client) Options() *Options { return c.opt } @@ -1490,6 +1632,7 @@ func newConn(opt *Options, connPool pool.Pooler, parentHooks *hooksMixin) *Conn baseClient: baseClient{ opt: opt, connPool: connPool, + onClose: &onCloseHooks{}, }, } diff --git a/vendor/github.com/redis/go-redis/v9/ring.go b/vendor/github.com/redis/go-redis/v9/ring.go index d9220ddb550..ab4d00acb1f 100644 --- a/vendor/github.com/redis/go-redis/v9/ring.go +++ b/vendor/github.com/redis/go-redis/v9/ring.go @@ -11,8 +11,6 @@ import ( "sync/atomic" "time" - "github.com/cespare/xxhash/v2" - "github.com/dgryski/go-rendezvous" //nolint "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" @@ -36,16 +34,8 @@ type ConsistentHash interface { Get(string) string } -type rendezvousWrapper struct { - *rendezvous.Rendezvous -} - -func (w rendezvousWrapper) Get(key string) string { - return w.Lookup(key) -} - func newRendezvous(shards []string) ConsistentHash { - return rendezvousWrapper{rendezvous.New(shards, xxhash.Sum64String)} + return hashtag.NewRendezvousHash(shards) } //------------------------------------------------------------------------------ @@ -120,6 +110,10 @@ type RingOptions struct { // default: 100 milliseconds DialerRetryTimeout time.Duration + // DialerRetryBackoff controls the delay between dial retry attempts. + // See Options.DialerRetryBackoff for details. + DialerRetryBackoff func(attempt int) time.Duration + ReadTimeout time.Duration WriteTimeout time.Duration ContextTimeoutEnabled bool @@ -233,6 +227,7 @@ func (opt *RingOptions) clientOptions() *Options { DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, ContextTimeoutEnabled: opt.ContextTimeoutEnabled, @@ -600,6 +595,8 @@ type Ring struct { heartbeatCancelFn context.CancelFunc } +// NewRing returns a Redis Ring client to the Redis Server specified by RingOptions. +// Passing nil RingOptions will cause a panic. func NewRing(opt *RingOptions) *Ring { if opt == nil { panic("redis: NewRing nil options") @@ -642,7 +639,8 @@ func (c *Ring) Process(ctx context.Context, cmd Cmder) error { return err } -// Options returns read-only Options that were used to create the client. +// Options returns read-only *RingOptions that were used to create the client. +// Any alteration of the returned *RingOptions may result in undefined behaviour. func (c *Ring) Options() *RingOptions { return c.opt } @@ -797,7 +795,7 @@ func (c *Ring) process(ctx context.Context, cmd Cmder) error { } lastErr = shard.Client.Process(ctx, cmd) - if lastErr == nil || !shouldRetry(lastErr, cmd.readTimeout() == nil) { + if lastErr == nil || !shouldRetry(lastErr, cmd.readTimeout() == nil) || cmd.NoRetry() { return lastErr } } diff --git a/vendor/github.com/redis/go-redis/v9/script.go b/vendor/github.com/redis/go-redis/v9/script.go index 626ab03bb56..92d508f9a80 100644 --- a/vendor/github.com/redis/go-redis/v9/script.go +++ b/vendor/github.com/redis/go-redis/v9/script.go @@ -4,7 +4,9 @@ import ( "context" "crypto/sha1" "encoding/hex" + "errors" "io" + "sync" ) type Scripter interface { @@ -23,28 +25,69 @@ var ( ) type Script struct { - src, hash string + src string + mu sync.RWMutex + hash string + serverSHA bool // if true: do not compute SHA-1 in Go; load digest from Redis (SCRIPT LOAD) } func NewScript(src string) *Script { h := sha1.New() _, _ = io.WriteString(h, src) + + return &Script{ + src: src, + hash: hex.EncodeToString(h.Sum(nil)), + serverSHA: false, + } +} + +// NewScriptServerSHA creates a Script that avoids computing SHA-1 in Go. +// The digest is obtained from Redis via SCRIPT LOAD (server-side hashing), +// then EVALSHA/EVALSHA_RO is used. +func NewScriptServerSHA(src string) *Script { return &Script{ - src: src, - hash: hex.EncodeToString(h.Sum(nil)), + src: src, + serverSHA: true, } } func (s *Script) Hash() string { + s.mu.RLock() + defer s.mu.RUnlock() return s.hash } func (s *Script) Load(ctx context.Context, c Scripter) *StringCmd { - return c.ScriptLoad(ctx, s.src) + cmd := c.ScriptLoad(ctx, s.src) + if err := cmd.Err(); err == nil { + s.mu.Lock() + s.hash = cmd.Val() + s.mu.Unlock() + } + return cmd } func (s *Script) Exists(ctx context.Context, c Scripter) *BoolSliceCmd { - return c.ScriptExists(ctx, s.hash) + s.mu.RLock() + hash := s.hash + serverSHA := s.serverSHA + s.mu.RUnlock() + if hash == "" && serverSHA { + // For server-side scripts, obtain digest from Redis first. + // If hash is empty, it means SCRIPT LOAD was not called yet, so we check existence of empty hash which will return false. + // This avoids unnecessary SCRIPT LOAD just to check existence. + if err := s.ensureHash(ctx, c); err != nil { + return c.ScriptExists(ctx, "") + } + s.mu.RLock() + hash = s.hash + s.mu.RUnlock() + } + if hash == "" { + return c.ScriptExists(ctx, "") + } + return c.ScriptExists(ctx, hash) } func (s *Script) Eval(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { @@ -55,19 +98,101 @@ func (s *Script) EvalRO(ctx context.Context, c Scripter, keys []string, args ... return c.EvalRO(ctx, s.src, keys, args...) } +// ensureHash ensures that s.hash is populated by using SCRIPT LOAD. +// It never calls SHA-1 in Go; Redis computes and returns the digest. +func (s *Script) ensureHash(ctx context.Context, c Scripter) error { + // Fast path: read lock, return if hash is already set. + s.mu.RLock() + if s.hash != "" { + s.mu.RUnlock() + return nil + } + s.mu.RUnlock() + + // Slow path: acquire write lock and load. + s.mu.Lock() + if s.hash != "" { + s.mu.Unlock() + return nil + } + cmd := c.ScriptLoad(ctx, s.src) + if err := cmd.Err(); err != nil { + s.mu.Unlock() + return err + } + s.hash = cmd.Val() + s.mu.Unlock() + return nil +} + func (s *Script) EvalSha(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { - return c.EvalSha(ctx, s.hash, keys, args...) + // Default behavior: use client-side SHA-1 computed in NewScript. + if !s.serverSHA { + s.mu.RLock() + hash := s.hash + s.mu.RUnlock() + return c.EvalSha(ctx, hash, keys, args...) + } + + // Server-side SHA via SCRIPT LOAD + EVALSHA. + if err := s.ensureHash(ctx, c); err != nil { + return s.Eval(ctx, c, keys, args...) + } + + s.mu.RLock() + hash := s.hash + s.mu.RUnlock() + + r := c.EvalSha(ctx, hash, keys, args...) + if HasErrorPrefix(r.Err(), "NOSCRIPT") { + // Script cache was flushed; reload and retry once. + if err := s.ensureHash(ctx, c); err != nil { + return s.Eval(ctx, c, keys, args...) + } + s.mu.RLock() + hash = s.hash + s.mu.RUnlock() + return c.EvalSha(ctx, hash, keys, args...) + } + + return r } func (s *Script) EvalShaRO(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { - return c.EvalShaRO(ctx, s.hash, keys, args...) + if !s.serverSHA { + s.mu.RLock() + hash := s.hash + s.mu.RUnlock() + return c.EvalShaRO(ctx, hash, keys, args...) + } + + if err := s.ensureHash(ctx, c); err != nil { + return s.EvalRO(ctx, c, keys, args...) + } + + s.mu.RLock() + hash := s.hash + s.mu.RUnlock() + + r := c.EvalShaRO(ctx, hash, keys, args...) + if HasErrorPrefix(r.Err(), "NOSCRIPT") { + if err := s.ensureHash(ctx, c); err != nil { + return s.EvalRO(ctx, c, keys, args...) + } + s.mu.RLock() + hash = s.hash + s.mu.RUnlock() + return c.EvalShaRO(ctx, hash, keys, args...) + } + + return r } // Run optimistically uses EVALSHA to run the script. If script does not exist // it is retried using EVAL. func (s *Script) Run(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { r := s.EvalSha(ctx, c, keys, args...) - if HasErrorPrefix(r.Err(), "NOSCRIPT") { + if errors.Is(r.Err(), ErrNoScript) { return s.Eval(ctx, c, keys, args...) } return r @@ -77,7 +202,7 @@ func (s *Script) Run(ctx context.Context, c Scripter, keys []string, args ...int // it is retried using EVAL_RO. func (s *Script) RunRO(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { r := s.EvalShaRO(ctx, c, keys, args...) - if HasErrorPrefix(r.Err(), "NOSCRIPT") { + if errors.Is(r.Err(), ErrNoScript) { return s.EvalRO(ctx, c, keys, args...) } return r diff --git a/vendor/github.com/redis/go-redis/v9/scripting_commands.go b/vendor/github.com/redis/go-redis/v9/scripting_commands.go index af9c3397bfe..3310b9d0ae0 100644 --- a/vendor/github.com/redis/go-redis/v9/scripting_commands.go +++ b/vendor/github.com/redis/go-redis/v9/scripting_commands.go @@ -60,6 +60,11 @@ func (c cmdable) eval(ctx context.Context, name, payload string, keys []string, cmd.SetFirstKeyPos(3) } _ = c(ctx, cmd) + if err := cmd.Err(); err != nil { + if HasErrorPrefix(err, "NOSCRIPT") { + cmd.SetErr(ErrNoScript) + } + } return cmd } diff --git a/vendor/github.com/redis/go-redis/v9/search_builders.go b/vendor/github.com/redis/go-redis/v9/search_builders.go index 91f0634041e..a6c6718c343 100644 --- a/vendor/github.com/redis/go-redis/v9/search_builders.go +++ b/vendor/github.com/redis/go-redis/v9/search_builders.go @@ -2,6 +2,7 @@ package redis import ( "context" + "fmt" ) // ---------------------- @@ -215,6 +216,7 @@ type AggregateBuilder struct { index string query string options *FTAggregateOptions + err error } // NewAggregateBuilder creates a new AggregateBuilder for FT.AGGREGATE commands. @@ -223,6 +225,14 @@ func (c *Client) NewAggregateBuilder(ctx context.Context, index, query string) * return &AggregateBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTAggregateOptions{LimitOffset: -1}} } +// setErr records the first error produced while building the pipeline. +// Subsequent errors are ignored; the first error is returned from Run. +func (b *AggregateBuilder) setErr(err error) { + if b.err == nil { + b.err = err + } +} + // Verbatim includes VERBATIM. func (b *AggregateBuilder) Verbatim() *AggregateBuilder { b.options.Verbatim = true; return b } @@ -241,15 +251,15 @@ func (b *AggregateBuilder) LoadAll() *AggregateBuilder { return b } -// Load adds LOAD [AS alias]... -// You can call it multiple times for multiple fields. +// Load adds a LOAD [AS alias] step. +// You can call it multiple times; each call becomes a separate LOAD clause +// at its position in the pipeline. func (b *AggregateBuilder) Load(field string, alias ...string) *AggregateBuilder { - // each Load entry becomes one element in options.Load - l := FTAggregateLoad{Field: field} + l := &FTAggregateLoad{Field: field} if len(alias) > 0 { l.As = alias[0] } - b.options.Load = append(b.options.Load, l) + b.options.Steps = append(b.options.Steps, FTAggregateStep{Load: l}) return b } @@ -259,62 +269,79 @@ func (b *AggregateBuilder) Timeout(ms int) *AggregateBuilder { return b } -// Apply adds APPLY [AS alias]. +// Apply adds an APPLY [AS alias] step. func (b *AggregateBuilder) Apply(field string, alias ...string) *AggregateBuilder { - a := FTAggregateApply{Field: field} + a := &FTAggregateApply{Field: field} if len(alias) > 0 { a.As = alias[0] } - b.options.Apply = append(b.options.Apply, a) + b.options.Steps = append(b.options.Steps, FTAggregateStep{Apply: a}) return b } -// GroupBy starts a new GROUPBY clause. +// GroupBy adds a new GROUPBY step. func (b *AggregateBuilder) GroupBy(fields ...interface{}) *AggregateBuilder { - b.options.GroupBy = append(b.options.GroupBy, FTAggregateGroupBy{ - Fields: fields, + b.options.Steps = append(b.options.Steps, FTAggregateStep{ + GroupBy: &FTAggregateGroupBy{Fields: fields}, }) return b } -// Reduce adds a REDUCE [<#args> ] clause to the *last* GROUPBY. +// Reduce adds a REDUCE [<#args> ] clause to the last step, +// which must be a GROUPBY. If it is not, Run will return an error. func (b *AggregateBuilder) Reduce(fn SearchAggregator, args ...interface{}) *AggregateBuilder { - if len(b.options.GroupBy) == 0 { - // no GROUPBY yet โ€” nothing to attach to + n := len(b.options.Steps) + if n == 0 || b.options.Steps[n-1].GroupBy == nil { + b.setErr(fmt.Errorf("FT.AGGREGATE: Reduce must follow a GroupBy step")) return b } - idx := len(b.options.GroupBy) - 1 - b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ - Reducer: fn, - Args: args, - }) + g := b.options.Steps[n-1].GroupBy + g.Reduce = append(g.Reduce, FTAggregateReducer{Reducer: fn, Args: args}) return b } -// ReduceAs does the same but also sets an alias: REDUCE โ€ฆ AS +// ReduceAs does the same but also sets an alias: REDUCE โ€ฆ AS . +// The last step must be a GROUPBY; otherwise Run will return an error. func (b *AggregateBuilder) ReduceAs(fn SearchAggregator, alias string, args ...interface{}) *AggregateBuilder { - if len(b.options.GroupBy) == 0 { + n := len(b.options.Steps) + if n == 0 || b.options.Steps[n-1].GroupBy == nil { + b.setErr(fmt.Errorf("FT.AGGREGATE: ReduceAs must follow a GroupBy step")) return b } - idx := len(b.options.GroupBy) - 1 - b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ - Reducer: fn, - Args: args, - As: alias, - }) + g := b.options.Steps[n-1].GroupBy + g.Reduce = append(g.Reduce, FTAggregateReducer{Reducer: fn, Args: args, As: alias}) return b } -// SortBy adds SORTBY ASC|DESC. +// SortBy adds SORTBY ASC|DESC. Consecutive SortBy calls (with no +// other step in between) are merged into a single SORTBY clause so fields +// act as tiebreakers. A SortBy call after a non-SortBy step starts a new +// SORTBY step. +// +// Note: this is a semantics change from earlier experimental versions of +// the builder, where SortBy always accumulated into a single SORTBY clause +// regardless of position in the pipeline. func (b *AggregateBuilder) SortBy(field string, asc bool) *AggregateBuilder { sb := FTAggregateSortBy{FieldName: field, Asc: asc, Desc: !asc} - b.options.SortBy = append(b.options.SortBy, sb) + if n := len(b.options.Steps); n > 0 && b.options.Steps[n-1].SortBy != nil { + b.options.Steps[n-1].SortBy.Fields = append(b.options.Steps[n-1].SortBy.Fields, sb) + return b + } + b.options.Steps = append(b.options.Steps, FTAggregateStep{ + SortBy: &FTAggregateSortByStep{Fields: []FTAggregateSortBy{sb}}, + }) return b } -// SortByMax sets MAX (only if SortBy was called). +// SortByMax sets MAX on the last SORTBY step. The last step must be a +// SORTBY; otherwise Run will return an error. func (b *AggregateBuilder) SortByMax(max int) *AggregateBuilder { - b.options.SortByMax = max + n := len(b.options.Steps) + if n == 0 || b.options.Steps[n-1].SortBy == nil { + b.setErr(fmt.Errorf("FT.AGGREGATE: SortByMax must follow a SortBy step")) + return b + } + b.options.Steps[n-1].SortBy.Max = max return b } @@ -352,8 +379,14 @@ func (b *AggregateBuilder) Dialect(version int) *AggregateBuilder { return b } -// Run executes FT.AGGREGATE and returns a typed result. +// Run executes FT.AGGREGATE and returns a typed result. If the builder +// recorded a validation error while constructing the pipeline (for example, +// calling SortByMax when the last step is not a SortBy), that error is +// returned without issuing the command. func (b *AggregateBuilder) Run() (*FTAggregateResult, error) { + if b.err != nil { + return nil, b.err + } cmd := b.c.FTAggregateWithArgs(b.ctx, b.index, b.query, b.options) return cmd.Result() } 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 0fef8ffccb3..bd7707da881 100644 --- a/vendor/github.com/redis/go-redis/v9/search_commands.go +++ b/vendor/github.com/redis/go-redis/v9/search_commands.go @@ -3,6 +3,8 @@ package redis import ( "context" "fmt" + "maps" + "slices" "strconv" "github.com/redis/go-redis/v9/internal" @@ -256,22 +258,42 @@ type FTAggregateWithCursor struct { MaxIdle int } +// FTAggregateSortByStep represents a SORTBY operation with optional MAX. +// Used inside FTAggregateStep to place SORTBY at an arbitrary position in +// the aggregation pipeline. +type FTAggregateSortByStep struct { + Fields []FTAggregateSortBy + Max int // 0 means no MAX +} + +// FTAggregateStep represents a single operation in the aggregation pipeline. +// LOAD, APPLY, SORTBY and GROUPBY can all appear multiple times in any order. +// Exactly one of the fields should be set per step. +type FTAggregateStep struct { + Load *FTAggregateLoad + Apply *FTAggregateApply + GroupBy *FTAggregateGroupBy + SortBy *FTAggregateSortByStep +} + type FTAggregateOptions struct { - Verbatim bool - LoadAll bool - Load []FTAggregateLoad - Timeout int - GroupBy []FTAggregateGroupBy - SortBy []FTAggregateSortBy - SortByMax int + Verbatim bool + LoadAll bool + Timeout int // Scorer is used to set scoring function, if not set passed, a default will be used. // The default scorer depends on the Redis version: // - `BM25` for Redis >= 8 // - `TFIDF` for Redis < 8 Scorer string // AddScores is available in Redis CE 8 - AddScores bool - Apply []FTAggregateApply + AddScores bool + + // Steps is the ordered sequence of aggregation pipeline operations. + // It can contain LOAD, APPLY, GROUPBY and SORTBY in any order, multiple times. + // Steps cannot be combined with the deprecated Load, Apply, GroupBy, SortBy + // and SortByMax fields: doing so returns an error. + Steps []FTAggregateStep + LimitOffset int Limit int Filter string @@ -280,6 +302,17 @@ type FTAggregateOptions struct { Params map[string]interface{} // Dialect 1,3 and 4 are deprecated since redis 8.0 DialectVersion int + + // Deprecated: Use Steps instead. + Load []FTAggregateLoad + // Deprecated: Use Steps instead. + GroupBy []FTAggregateGroupBy + // Deprecated: Use Steps instead. + SortBy []FTAggregateSortBy + // Deprecated: Use Steps instead. + SortByMax int + // Deprecated: Use Steps instead. + Apply []FTAggregateApply } type FTSearchFilter struct { @@ -615,9 +648,112 @@ func (c cmdable) FTAggregate(ctx context.Context, index string, query string) *M return cmd } +// validateFTAggregateOptions validates mutually exclusive combinations of +// FTAggregateOptions fields before any command arguments are constructed. +func validateFTAggregateOptions(options *FTAggregateOptions) error { + if len(options.Steps) > 0 { + if options.Load != nil || options.Apply != nil || options.GroupBy != nil || + options.SortBy != nil || options.SortByMax != 0 { + return fmt.Errorf("FT.AGGREGATE: Steps cannot be combined with the deprecated Load, Apply, GroupBy, SortBy and SortByMax fields") + } + if options.LoadAll { + for _, step := range options.Steps { + if step.Load != nil { + return fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") + } + } + } + } + if options.LoadAll && options.Load != nil { + return fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") + } + return nil +} + +// appendFTAggregateStep appends the Redis command arguments for a single +// aggregation pipeline step. Each step must set exactly one of Load, Apply, +// GroupBy or SortBy. +func appendFTAggregateStep(args []interface{}, step FTAggregateStep) ([]interface{}, error) { + set := 0 + if step.Load != nil { + set++ + } + if step.Apply != nil { + set++ + } + if step.GroupBy != nil { + set++ + } + if step.SortBy != nil { + set++ + } + if set != 1 { + return args, fmt.Errorf("FT.AGGREGATE: each step must set exactly one of Load, Apply, GroupBy, SortBy (got %d)", set) + } + + switch { + case step.Load != nil: + args = append(args, "LOAD") + countIdx := len(args) + args = append(args, 0) + count := 0 + args = append(args, step.Load.Field) + count++ + if step.Load.As != "" { + args = append(args, "AS", step.Load.As) + count += 2 + } + args[countIdx] = count + case step.Apply != nil: + args = append(args, "APPLY", step.Apply.Field) + if step.Apply.As != "" { + args = append(args, "AS", step.Apply.As) + } + case step.GroupBy != nil: + args = append(args, "GROUPBY", len(step.GroupBy.Fields)) + args = append(args, step.GroupBy.Fields...) + for _, reducer := range step.GroupBy.Reduce { + args = append(args, "REDUCE", reducer.Reducer.String()) + if reducer.Args != nil { + args = append(args, len(reducer.Args)) + args = append(args, reducer.Args...) + } else { + args = append(args, 0) + } + if reducer.As != "" { + args = append(args, "AS", reducer.As) + } + } + case step.SortBy != nil: + args = append(args, "SORTBY") + sortByOptions := []interface{}{} + for _, sortBy := range step.SortBy.Fields { + if sortBy.Asc && sortBy.Desc { + return args, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive") + } + sortByOptions = append(sortByOptions, sortBy.FieldName) + if sortBy.Asc { + sortByOptions = append(sortByOptions, "ASC") + } + if sortBy.Desc { + sortByOptions = append(sortByOptions, "DESC") + } + } + args = append(args, len(sortByOptions)) + args = append(args, sortByOptions...) + if step.SortBy.Max > 0 { + args = append(args, "MAX", step.SortBy.Max) + } + } + return args, nil +} + func FTAggregateQuery(query string, options *FTAggregateOptions) (AggregateQuery, error) { queryArgs := []interface{}{query} if options != nil { + if err := validateFTAggregateOptions(options); err != nil { + return nil, err + } if options.Verbatim { queryArgs = append(queryArgs, "VERBATIM") } @@ -630,13 +766,10 @@ func FTAggregateQuery(query string, options *FTAggregateOptions) (AggregateQuery queryArgs = append(queryArgs, "ADDSCORES") } - if options.LoadAll && options.Load != nil { - return nil, fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") - } if options.LoadAll { queryArgs = append(queryArgs, "LOAD", "*") } - if options.Load != nil { + if len(options.Steps) == 0 && options.Load != nil { queryArgs = append(queryArgs, "LOAD", len(options.Load)) index, count := len(queryArgs)-1, 0 for _, load := range options.Load { @@ -654,53 +787,63 @@ func FTAggregateQuery(query string, options *FTAggregateOptions) (AggregateQuery queryArgs = append(queryArgs, "TIMEOUT", options.Timeout) } - for _, apply := range options.Apply { - queryArgs = append(queryArgs, "APPLY", apply.Field) - if apply.As != "" { - queryArgs = append(queryArgs, "AS", apply.As) + if len(options.Steps) > 0 { + for _, step := range options.Steps { + var err error + queryArgs, err = appendFTAggregateStep(queryArgs, step) + if err != nil { + return nil, err + } + } + } else { + for _, apply := range options.Apply { + queryArgs = append(queryArgs, "APPLY", apply.Field) + if apply.As != "" { + queryArgs = append(queryArgs, "AS", apply.As) + } } - } - if options.GroupBy != nil { - for _, groupBy := range options.GroupBy { - queryArgs = append(queryArgs, "GROUPBY", len(groupBy.Fields)) - queryArgs = append(queryArgs, groupBy.Fields...) - - for _, reducer := range groupBy.Reduce { - queryArgs = append(queryArgs, "REDUCE") - queryArgs = append(queryArgs, reducer.Reducer.String()) - if reducer.Args != nil { - queryArgs = append(queryArgs, len(reducer.Args)) - queryArgs = append(queryArgs, reducer.Args...) - } else { - queryArgs = append(queryArgs, 0) - } - if reducer.As != "" { - queryArgs = append(queryArgs, "AS", reducer.As) + if options.GroupBy != nil { + for _, groupBy := range options.GroupBy { + queryArgs = append(queryArgs, "GROUPBY", len(groupBy.Fields)) + queryArgs = append(queryArgs, groupBy.Fields...) + + for _, reducer := range groupBy.Reduce { + queryArgs = append(queryArgs, "REDUCE") + queryArgs = append(queryArgs, reducer.Reducer.String()) + if reducer.Args != nil { + queryArgs = append(queryArgs, len(reducer.Args)) + queryArgs = append(queryArgs, reducer.Args...) + } else { + queryArgs = append(queryArgs, 0) + } + if reducer.As != "" { + queryArgs = append(queryArgs, "AS", reducer.As) + } } } } - } - if options.SortBy != nil { - queryArgs = append(queryArgs, "SORTBY") - sortByOptions := []interface{}{} - for _, sortBy := range options.SortBy { - sortByOptions = append(sortByOptions, sortBy.FieldName) - if sortBy.Asc && sortBy.Desc { - return nil, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive") - } - if sortBy.Asc { - sortByOptions = append(sortByOptions, "ASC") - } - if sortBy.Desc { - sortByOptions = append(sortByOptions, "DESC") + if options.SortBy != nil { + queryArgs = append(queryArgs, "SORTBY") + sortByOptions := []interface{}{} + for _, sortBy := range options.SortBy { + sortByOptions = append(sortByOptions, sortBy.FieldName) + if sortBy.Asc && sortBy.Desc { + return nil, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive") + } + if sortBy.Asc { + sortByOptions = append(sortByOptions, "ASC") + } + if sortBy.Desc { + sortByOptions = append(sortByOptions, "DESC") + } } + queryArgs = append(queryArgs, len(sortByOptions)) + queryArgs = append(queryArgs, sortByOptions...) + } + if options.SortByMax > 0 { + queryArgs = append(queryArgs, "MAX", options.SortByMax) } - queryArgs = append(queryArgs, len(sortByOptions)) - queryArgs = append(queryArgs, sortByOptions...) - } - if options.SortByMax > 0 { - queryArgs = append(queryArgs, "MAX", options.SortByMax) } if options.LimitOffset >= 0 && options.Limit > 0 { queryArgs = append(queryArgs, "LIMIT", options.LimitOffset, options.Limit) @@ -850,6 +993,11 @@ func (cmd *AggregateCmd) Clone() Cmder { func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query string, options *FTAggregateOptions) *AggregateCmd { args := []interface{}{"FT.AGGREGATE", index, query} if options != nil { + if err := validateFTAggregateOptions(options); err != nil { + cmd := NewAggregateCmd(ctx, args...) + cmd.SetErr(err) + return cmd + } if options.Verbatim { args = append(args, "VERBATIM") } @@ -859,15 +1007,10 @@ func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query st if options.AddScores { args = append(args, "ADDSCORES") } - if options.LoadAll && options.Load != nil { - cmd := NewAggregateCmd(ctx, args...) - cmd.SetErr(fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive")) - return cmd - } if options.LoadAll { args = append(args, "LOAD", "*") } - if options.Load != nil { + if len(options.Steps) == 0 && options.Load != nil { args = append(args, "LOAD", len(options.Load)) index, count := len(args)-1, 0 for _, load := range options.Load { @@ -883,54 +1026,66 @@ func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query st if options.Timeout > 0 { args = append(args, "TIMEOUT", options.Timeout) } - for _, apply := range options.Apply { - args = append(args, "APPLY", apply.Field) - if apply.As != "" { - args = append(args, "AS", apply.As) - } - } - if options.GroupBy != nil { - for _, groupBy := range options.GroupBy { - args = append(args, "GROUPBY", len(groupBy.Fields)) - args = append(args, groupBy.Fields...) - - for _, reducer := range groupBy.Reduce { - args = append(args, "REDUCE") - args = append(args, reducer.Reducer.String()) - if reducer.Args != nil { - args = append(args, len(reducer.Args)) - args = append(args, reducer.Args...) - } else { - args = append(args, 0) - } - if reducer.As != "" { - args = append(args, "AS", reducer.As) - } - } - } - } - if options.SortBy != nil { - args = append(args, "SORTBY") - sortByOptions := []interface{}{} - for _, sortBy := range options.SortBy { - sortByOptions = append(sortByOptions, sortBy.FieldName) - if sortBy.Asc && sortBy.Desc { + if len(options.Steps) > 0 { + for _, step := range options.Steps { + var err error + args, err = appendFTAggregateStep(args, step) + if err != nil { cmd := NewAggregateCmd(ctx, args...) - cmd.SetErr(fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive")) + cmd.SetErr(err) return cmd } - if sortBy.Asc { - sortByOptions = append(sortByOptions, "ASC") + } + } else { + for _, apply := range options.Apply { + args = append(args, "APPLY", apply.Field) + if apply.As != "" { + args = append(args, "AS", apply.As) } - if sortBy.Desc { - sortByOptions = append(sortByOptions, "DESC") + } + if options.GroupBy != nil { + for _, groupBy := range options.GroupBy { + args = append(args, "GROUPBY", len(groupBy.Fields)) + args = append(args, groupBy.Fields...) + + for _, reducer := range groupBy.Reduce { + args = append(args, "REDUCE") + args = append(args, reducer.Reducer.String()) + if reducer.Args != nil { + args = append(args, len(reducer.Args)) + args = append(args, reducer.Args...) + } else { + args = append(args, 0) + } + if reducer.As != "" { + args = append(args, "AS", reducer.As) + } + } } } - args = append(args, len(sortByOptions)) - args = append(args, sortByOptions...) - } - if options.SortByMax > 0 { - args = append(args, "MAX", options.SortByMax) + if options.SortBy != nil { + args = append(args, "SORTBY") + sortByOptions := []interface{}{} + for _, sortBy := range options.SortBy { + sortByOptions = append(sortByOptions, sortBy.FieldName) + if sortBy.Asc && sortBy.Desc { + cmd := NewAggregateCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive")) + return cmd + } + if sortBy.Asc { + sortByOptions = append(sortByOptions, "ASC") + } + if sortBy.Desc { + sortByOptions = append(sortByOptions, "DESC") + } + } + args = append(args, len(sortByOptions)) + args = append(args, sortByOptions...) + } + if options.SortByMax > 0 { + args = append(args, "MAX", options.SortByMax) + } } if options.LimitOffset >= 0 && options.Limit > 0 { args = append(args, "LIMIT", options.LimitOffset, options.Limit) @@ -1728,26 +1883,19 @@ func (cmd *FTInfoCmd) Clone() Cmder { } // Clone slices and maps if cmd.val.Attributes != nil { - val.Attributes = make([]FTAttribute, len(cmd.val.Attributes)) - copy(val.Attributes, cmd.val.Attributes) + val.Attributes = slices.Clone(cmd.val.Attributes) } if cmd.val.DialectStats != nil { - val.DialectStats = make(map[string]int, len(cmd.val.DialectStats)) - for k, v := range cmd.val.DialectStats { - val.DialectStats[k] = v - } + val.DialectStats = maps.Clone(cmd.val.DialectStats) } if cmd.val.FieldStatistics != nil { - val.FieldStatistics = make([]FieldStatistic, len(cmd.val.FieldStatistics)) - copy(val.FieldStatistics, cmd.val.FieldStatistics) + val.FieldStatistics = slices.Clone(cmd.val.FieldStatistics) } if cmd.val.IndexOptions != nil { - val.IndexOptions = make([]string, len(cmd.val.IndexOptions)) - copy(val.IndexOptions, cmd.val.IndexOptions) + val.IndexOptions = slices.Clone(cmd.val.IndexOptions) } if cmd.val.IndexDefinition.Prefixes != nil { - val.IndexDefinition.Prefixes = make([]string, len(cmd.val.IndexDefinition.Prefixes)) - copy(val.IndexDefinition.Prefixes, cmd.val.IndexDefinition.Prefixes) + val.IndexDefinition.Prefixes = slices.Clone(cmd.val.IndexDefinition.Prefixes) } return &FTInfoCmd{ baseCmd: cmd.cloneBaseCmd(), @@ -1918,8 +2066,7 @@ func (cmd *FTSpellCheckCmd) Clone() Cmder { Term: result.Term, } if result.Suggestions != nil { - val[i].Suggestions = make([]SpellCheckSuggestion, len(result.Suggestions)) - copy(val[i].Suggestions, result.Suggestions) + val[i].Suggestions = slices.Clone(result.Suggestions) } } } @@ -2115,34 +2262,25 @@ func (cmd *FTSearchCmd) Clone() Cmder { } // Clone slices and maps if cmd.options.Filters != nil { - options.Filters = make([]FTSearchFilter, len(cmd.options.Filters)) - copy(options.Filters, cmd.options.Filters) + options.Filters = slices.Clone(cmd.options.Filters) } if cmd.options.GeoFilter != nil { - options.GeoFilter = make([]FTSearchGeoFilter, len(cmd.options.GeoFilter)) - copy(options.GeoFilter, cmd.options.GeoFilter) + options.GeoFilter = slices.Clone(cmd.options.GeoFilter) } if cmd.options.InKeys != nil { - options.InKeys = make([]interface{}, len(cmd.options.InKeys)) - copy(options.InKeys, cmd.options.InKeys) + options.InKeys = slices.Clone(cmd.options.InKeys) } if cmd.options.InFields != nil { - options.InFields = make([]interface{}, len(cmd.options.InFields)) - copy(options.InFields, cmd.options.InFields) + options.InFields = slices.Clone(cmd.options.InFields) } if cmd.options.Return != nil { - options.Return = make([]FTSearchReturn, len(cmd.options.Return)) - copy(options.Return, cmd.options.Return) + options.Return = slices.Clone(cmd.options.Return) } if cmd.options.SortBy != nil { - options.SortBy = make([]FTSearchSortBy, len(cmd.options.SortBy)) - copy(options.SortBy, cmd.options.SortBy) + options.SortBy = slices.Clone(cmd.options.SortBy) } if cmd.options.Params != nil { - options.Params = make(map[string]interface{}, len(cmd.options.Params)) - for k, v := range cmd.options.Params { - options.Params[k] = v - } + options.Params = maps.Clone(cmd.options.Params) } } return &FTSearchCmd{ @@ -2368,8 +2506,7 @@ func (cmd *FTHybridCmd) Clone() Cmder { } } if cmd.val.Warnings != nil { - val.Warnings = make([]string, len(cmd.val.Warnings)) - copy(val.Warnings, cmd.val.Warnings) + val.Warnings = slices.Clone(cmd.val.Warnings) } var cursorVal *FTHybridCursorResult diff --git a/vendor/github.com/redis/go-redis/v9/sentinel.go b/vendor/github.com/redis/go-redis/v9/sentinel.go index 24646c1486e..785372f2647 100644 --- a/vendor/github.com/redis/go-redis/v9/sentinel.go +++ b/vendor/github.com/redis/go-redis/v9/sentinel.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/url" + "slices" "strconv" "strings" "sync" @@ -100,6 +101,10 @@ type FailoverOptions struct { // default: 100 milliseconds DialerRetryTimeout time.Duration + // DialerRetryBackoff controls the delay between dial retry attempts. + // See Options.DialerRetryBackoff for details. + DialerRetryBackoff func(attempt int) time.Duration + ReadTimeout time.Duration WriteTimeout time.Duration ContextTimeoutEnabled bool @@ -197,6 +202,7 @@ func (opt *FailoverOptions) clientOptions() *Options { DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -251,6 +257,7 @@ func (opt *FailoverOptions) sentinelOptions(addr string) *Options { DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -311,6 +318,7 @@ func (opt *FailoverOptions) clusterOptions() *ClusterOptions { DialTimeout: opt.DialTimeout, DialerRetries: opt.DialerRetries, DialerRetryTimeout: opt.DialerRetryTimeout, + DialerRetryBackoff: opt.DialerRetryBackoff, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -494,6 +502,7 @@ func setupFailoverConnParams(u *url.URL, o *FailoverOptions) (*FailoverOptions, // NewFailoverClient returns a Redis client that uses Redis Sentinel // for automatic failover. It's safe for concurrent use by multiple // goroutines. +// Passing nil FailoverOptions will cause a panic. func NewFailoverClient(failoverOpt *FailoverOptions) *Client { if failoverOpt == nil { panic("redis: NewFailoverClient nil options") @@ -524,7 +533,8 @@ func NewFailoverClient(failoverOpt *FailoverOptions) *Client { rdb := &Client{ baseClient: &baseClient{ - opt: opt, + opt: opt, + onClose: &onCloseHooks{}, }, } rdb.init() @@ -548,7 +558,7 @@ func NewFailoverClient(failoverOpt *FailoverOptions) *Client { panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err)) } - rdb.onClose = rdb.wrappedOnClose(failover.Close) + rdb.onClose.register(onCloseHookIDSentinelFailover, failover.Close) failover.mu.Lock() failover.onFailover = func(ctx context.Context, addr string) { @@ -603,6 +613,8 @@ type SentinelClient struct { *baseClient } +// NewSentinelClient returns a Redis Sentinel client. +// Passing nil Options will cause a panic. func NewSentinelClient(opt *Options) *SentinelClient { if opt == nil { panic("redis: NewSentinelClient nil options") @@ -610,7 +622,8 @@ func NewSentinelClient(opt *Options) *SentinelClient { opt.init() c := &SentinelClient{ baseClient: &baseClient{ - opt: opt, + opt: opt, + onClose: &onCloseHooks{}, }, } @@ -1128,7 +1141,7 @@ func (c *sentinelFailover) discoverSentinels(ctx context.Context) { } if ip != "" && port != "" { sentinelAddr := net.JoinHostPort(ip, port) - if !contains(c.sentinelAddrs, sentinelAddr) { + if !slices.Contains(c.sentinelAddrs, sentinelAddr) { internal.Logger.Printf(ctx, "sentinel: discovered new sentinel=%q for master=%q", sentinelAddr, c.opt.MasterName) c.sentinelAddrs = append(c.sentinelAddrs, sentinelAddr) @@ -1162,19 +1175,11 @@ func (c *sentinelFailover) listen(pubsub *PubSub) { } } -func contains(slice []string, str string) bool { - for _, s := range slice { - if s == str { - return true - } - } - return false -} - //------------------------------------------------------------------------------ // NewFailoverClusterClient returns a client that supports routing read-only commands // to a replica node. +// Passing nil FailoverOptions will cause a panic. func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient { if failoverOpt == nil { panic("redis: NewFailoverClusterClient nil options") 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 4a6c8f13c25..e21ff1aaf1c 100644 --- a/vendor/github.com/redis/go-redis/v9/sortedset_commands.go +++ b/vendor/github.com/redis/go-redis/v9/sortedset_commands.go @@ -373,6 +373,17 @@ type ZRangeArgs struct { // } // cmd: "ZRange example-key (3 8 ByScore" (3 < score <= 8). // + // When the Rev option is also provided, should be the higher score value and + // should be the lower score value (i.e. reversed order): + // ZRangeArgs{ + // Key: "example-key", + // Start: 8, + // Stop: "(3", + // ByScore: true, + // Rev: true, + // } + // cmd: "ZRange example-key 8 (3 ByScore Rev" (8 >= score > 3, in reverse order). + // // For the ByLex option, it is similar to the deprecated(6.2.0+) ZRangeByLex command. // You can set the and options as follows: // ZRangeArgs{ @@ -383,6 +394,17 @@ type ZRangeArgs struct { // } // cmd: "ZRange example-key [abc (def ByLex" // + // When the Rev option is also provided, should be the lexicographically higher + // value and should be the lower value: + // ZRangeArgs{ + // Key: "example-key", + // Start: "(def", + // Stop: "[abc", + // ByLex: true, + // Rev: true, + // } + // cmd: "ZRange example-key (def [abc ByLex Rev" + // // For normal cases (ByScore==false && ByLex==false), and should be set to the index range (int). // You can read the documentation for more information: https://redis.io/commands/zrange Start interface{} @@ -400,12 +422,7 @@ type ZRangeArgs struct { } func (z ZRangeArgs) appendArgs(args []interface{}) []interface{} { - // For Rev+ByScore/ByLex, we need to adjust the position of and . - if z.Rev && (z.ByScore || z.ByLex) { - args = append(args, z.Key, z.Stop, z.Start) - } else { - args = append(args, z.Key, z.Start, z.Stop) - } + args = append(args, z.Key, z.Start, z.Stop) if z.ByScore { args = append(args, "byscore") 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 f69d3d053c2..609a9541d12 100644 --- a/vendor/github.com/redis/go-redis/v9/string_commands.go +++ b/vendor/github.com/redis/go-redis/v9/string_commands.go @@ -429,8 +429,6 @@ func (c cmdable) SetEx(ctx context.Context, key string, value interface{}, expir // SetNX sets the value of a key only if the key does not exist. // -// Deprecated: Use Set with NX option instead as of Redis 2.6.12. -// // Zero expiration means the key has no expiration time. // KeepTTL is a Redis KEEPTTL option to keep existing TTL, it requires your redis-server version >= 6.0, // otherwise you will receive an error: (error) ERR syntax error. @@ -438,8 +436,7 @@ func (c cmdable) SetNX(ctx context.Context, key string, value interface{}, expir var cmd *BoolCmd switch expiration { case 0: - // Use old `SETNX` to support old Redis versions. - cmd = NewBoolCmd(ctx, "setnx", key, value) + cmd = NewBoolCmd(ctx, "set", key, value, "nx") case KeepTTL: cmd = NewBoolCmd(ctx, "set", key, value, "keepttl", "nx") default: diff --git a/vendor/github.com/redis/go-redis/v9/tx.go b/vendor/github.com/redis/go-redis/v9/tx.go index 40bc1d6618d..b433b402414 100644 --- a/vendor/github.com/redis/go-redis/v9/tx.go +++ b/vendor/github.com/redis/go-redis/v9/tx.go @@ -11,7 +11,7 @@ import ( const TxFailedErr = proto.RedisError("redis: transaction failed") // Tx implements Redis transactions as described in -// http://redis.io/topics/transactions. It's NOT safe for concurrent use +// https://redis.io/docs/latest/develop/using-commands/transactions. It's NOT safe for concurrent use // by multiple goroutines, because Exec resets list of watched keys. // // If you don't need WATCH, use Pipeline instead. @@ -24,10 +24,11 @@ type Tx struct { func (c *Client) newTx() *Tx { tx := Tx{ baseClient: baseClient{ - opt: c.opt.clone(), // Clone options to avoid sharing mutable state between transaction and parent client + opt: c.cloneOpt(), // Clone options under optLock to avoid race with initConn connPool: pool.NewStickyConnPool(c.connPool), hooksMixin: c.hooksMixin.clone(), pushProcessor: c.pushProcessor, // Copy push processor from parent client + onClose: &onCloseHooks{}, }, } tx.init() diff --git a/vendor/github.com/redis/go-redis/v9/universal.go b/vendor/github.com/redis/go-redis/v9/universal.go index 2531cb59411..d1347249d6e 100644 --- a/vendor/github.com/redis/go-redis/v9/universal.go +++ b/vendor/github.com/redis/go-redis/v9/universal.go @@ -372,6 +372,8 @@ var ( // 3. If the number of Addrs is two or more, or IsClusterMode option is specified, // a ClusterClient is returned. // 4. Otherwise, a single-node Client is returned. +// +// Passing nil UniversalOptions will cause a panic. func NewUniversalClient(opts *UniversalOptions) UniversalClient { if opts == nil { panic("redis: NewUniversalClient nil options") 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 8f99de07306..b91300b6b09 100644 --- a/vendor/github.com/redis/go-redis/v9/vectorset_commands.go +++ b/vendor/github.com/redis/go-redis/v9/vectorset_commands.go @@ -26,7 +26,10 @@ type VectorSetCmdable interface { VSimWithScores(ctx context.Context, key string, val Vector) *VectorScoreSliceCmd VSimWithArgs(ctx context.Context, key string, val Vector, args *VSimArgs) *StringSliceCmd VSimWithArgsWithScores(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorScoreSliceCmd + VSimWithArgsWithAttribs(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorAttribSliceCmd + VSimWithArgsWithScoresWithAttribs(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorScoreAttribSliceCmd VRange(ctx context.Context, key, start, end string, count int64) *StringSliceCmd + VIsMember(ctx context.Context, key, element string) *BoolCmd } type Vector interface { @@ -79,6 +82,17 @@ type VectorScore struct { Score float64 } +type VectorAttrib struct { + Name string + Attribs *string +} + +type VectorScoreAttrib struct { + Name string + Score float64 + Attribs *string +} + // `VADD key (FP32 | VALUES num) vector element` // note: the API is experimental and may be subject to change. func (c cmdable) VAdd(ctx context.Context, key, element string, val Vector) *BoolCmd { @@ -311,7 +325,7 @@ func (v VSimArgs) appendArgs(args []any) []any { args = append(args, "nothread") } if v.Epsilon > 0 { - args = append(args, "Epsilon", v.Epsilon) + args = append(args, "epsilon", v.Epsilon) } return args } @@ -347,6 +361,40 @@ func (c cmdable) VSimWithArgsWithScores(ctx context.Context, key string, val Vec return cmd } +// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHATTRIBS] [COUNT num] [EPSILON delta] +// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]` +// WITHATTRIBS is only available in Redis v8.2.0+ +// note: the API is experimental and may be subject to change. +func (c cmdable) VSimWithArgsWithAttribs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorAttribSliceCmd { + if simArgs == nil { + simArgs = &VSimArgs{} + } + args := []any{"vsim", key} + args = append(args, val.Value()...) + args = append(args, "withattribs") + args = simArgs.appendArgs(args) + cmd := NewVectorAttribSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHSCORES] [WITHATTRIBS] [COUNT num] [EPSILON delta] +// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]` +// WITHATTRIBS is only available in Redis v8.2.0+ +// note: the API is experimental and may be subject to change. +func (c cmdable) VSimWithArgsWithScoresWithAttribs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorScoreAttribSliceCmd { + if simArgs == nil { + simArgs = &VSimArgs{} + } + args := []any{"vsim", key} + args = append(args, val.Value()...) + args = append(args, "withscores", "withattribs") + args = simArgs.appendArgs(args) + cmd := NewVectorScoreAttribSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + // `VRANGE key start end count` // a negative count means to return all the elements in the vector set. // note: the API is experimental and may be subject to change. @@ -356,3 +404,12 @@ func (c cmdable) VRange(ctx context.Context, key, start, end string, count int64 _ = c(ctx, cmd) return cmd } + +// `VISMEMBER key element` +// Check if an element exists in a vector set. +// note: the API is experimental and may be subject to change. +func (c cmdable) VIsMember(ctx context.Context, key, element string) *BoolCmd { + cmd := NewBoolCmd(ctx, "vismember", key, element) + _ = 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 49f001e50b7..5005aa98448 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.18.0" + return "9.19.0" } diff --git a/vendor/modules.txt b/vendor/modules.txt index 4bb14cdd50a..627d3059c2b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -597,9 +597,6 @@ github.com/dennwc/varint # github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 ## explicit github.com/dgryski/go-metro -# github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f -## explicit -github.com/dgryski/go-rendezvous # github.com/dimchansky/utfbom v1.1.1 ## explicit github.com/dimchansky/utfbom @@ -1583,8 +1580,8 @@ github.com/prometheus/sigv4 # github.com/puzpuzpuz/xsync/v4 v4.4.0 ## explicit; go 1.24 github.com/puzpuzpuz/xsync/v4 -# github.com/redis/go-redis/v9 v9.18.0 -## explicit; go 1.21 +# github.com/redis/go-redis/v9 v9.19.0 +## explicit; go 1.24 github.com/redis/go-redis/v9 github.com/redis/go-redis/v9/auth github.com/redis/go-redis/v9/internal