chore(deps): bump go modules in backend and example

Backend (backend/go.mod):
- github.com/go-pkgz/auth/v2 v2.1.2 → v2.1.4
- github.com/klauspost/compress v1.18.5 → v1.18.6
- github.com/redis/go-redis/v9 v9.18.0 → v9.19.0
- github.com/slack-go/slack v0.21.1 → v0.23.1
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0
- golang.org/x/text v0.36.0 → v0.37.0

Example (backend/_example/memory_store/go.mod):
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0

Transitive cleanup: github.com/dgryski/go-rendezvous is no longer required
after redis/go-redis bump and gets pruned by `go mod tidy`.

`go mod tidy` + `go mod vendor` run on both modules. Both build with -race
and full test suites pass.
This commit is contained in:
Dmitry Verkhoturov
2026-05-20 20:09:47 -05:00
committed by Umputun
parent f3a7dea1f1
commit 45c17a913f
130 changed files with 14730 additions and 25980 deletions
+5 -5
View File
@@ -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
+25 -3
View File
@@ -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:
+91
View File
@@ -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
+137 -6
View File
@@ -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
TAG=v1.0.0 ./scripts/release.sh
git checkout master && git pull --ff-only
git checkout -b release/vX.Y.Z
```
2. Open a pull request and wait for the build to finish.
3. Merge the pull request and run `tag.sh` to create tags for packages:
Run the release script on that branch:
```shell
TAG=v1.0.0 ./scripts/tag.sh
TAG=vX.Y.Z ./scripts/release.sh
```
What the script does (and explicitly does **not** do):
- ✅ 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
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 `<module>/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.
+18
View File
@@ -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.
+421 -15
View File
@@ -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
+22
View File
@@ -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)
+39
View File
@@ -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)
}
}
+4 -4
View File
@@ -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:
+14
View File
@@ -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
}
+7
View File
@@ -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)
+1 -1
View File
@@ -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,
@@ -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
}
@@ -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))
}
}
+19
View File
@@ -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) {
+46 -4
View File
@@ -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
+9 -16
View File
@@ -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
}
+400 -81
View File
@@ -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,17 +946,26 @@ 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
}
// 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)
}
}
}
// Notify metrics: new connection is created and used
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, newcn, "", "used")
}
// newConn already emitted +1 used, so no transition needed here.
// Record wait time (use cached callback from above)
if waitTimeCallback != nil {
@@ -820,8 +973,11 @@ func (p *ConnPool) getConn(ctx context.Context) (cn *Conn, err error) {
}
// 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) {
p.connsMu.Lock()
defer p.connsMu.Unlock()
p.removeConn(cn)
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)
}
}
func (p *ConnPool) removeConn(cn *Conn) {
// 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()
return p.removeConn(cn)
}
// 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
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+25 -1
View File
@@ -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
})
+195 -5
View File
@@ -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)
}
+16 -4
View File
@@ -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_<sha>): @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.
+88 -43
View File
@@ -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",
// 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 exact match or suffix match
for _, pattern := range internalPatterns {
if hostname == pattern || strings.HasSuffix(hostname, pattern) {
return true
if len(ips) == 0 {
return false, nil
}
for _, ia := range ips {
if !isPrivateIP(ia.IP) {
return false, nil
}
}
// 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
}
// Default to external for fully qualified domain names
return false
return true, nil
}
+13 -6
View File
@@ -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,
+22 -22
View File
@@ -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)
+31
View File
@@ -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})
+1 -1
View File
@@ -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
+32 -27
View File
@@ -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".
+176 -33
View File
@@ -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{},
},
}
+11 -13
View File
@@ -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
}
}
+134 -9
View File
@@ -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)),
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,
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
+5
View File
@@ -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
}
+65 -32
View File
@@ -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 <n> <field> [AS alias]...
// You can call it multiple times for multiple fields.
// Load adds a LOAD <field> [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 <field> [AS alias].
// Apply adds an APPLY <field> [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 <fields...> clause.
// GroupBy adds a new GROUPBY <fields...> 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 <fn> [<#args> <args...>] clause to the *last* GROUPBY.
// Reduce adds a REDUCE <fn> [<#args> <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 <fn> … AS <alias>
// ReduceAs does the same but also sets an alias: REDUCE <fn> … AS <alias>.
// 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 <field> ASC|DESC.
// SortBy adds SORTBY <field> 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 <n> (only if SortBy was called).
// SortByMax sets MAX <n> 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()
}
+270 -133
View File
@@ -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...)
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)
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")
}
if sortBy.Desc {
sortByOptions = append(sortByOptions, "DESC")
}
} else {
for _, apply := range options.Apply {
args = append(args, "APPLY", apply.Field)
if apply.As != "" {
args = append(args, "AS", apply.As)
}
}
args = append(args, len(sortByOptions))
args = append(args, sortByOptions...)
}
if options.SortByMax > 0 {
args = append(args, "MAX", options.SortByMax)
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 {
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
+18 -13
View File
@@ -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")
+23 -6
View File
@@ -373,6 +373,17 @@ type ZRangeArgs struct {
// }
// cmd: "ZRange example-key (3 8 ByScore" (3 < score <= 8).
//
// When the Rev option is also provided, <Start> should be the higher score value and
// <Stop> 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 <Start> and <Stop> 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, <Start> should be the lexicographically higher
// value and <Stop> 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), <Start> and <Stop> 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 <Start> and <Stop>.
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")
+1 -4
View File
@@ -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:
+3 -2
View File
@@ -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()
+2
View File
@@ -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")
+58 -1
View File
@@ -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
}
+1 -1
View File
@@ -2,5 +2,5 @@ package redis
// Version is the current release version.
func Version() string {
return "9.18.0"
return "9.19.0"
}