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:
committed by
Umputun
parent
f3a7dea1f1
commit
45c17a913f
+5
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+54
@@ -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
|
||||
}
|
||||
+2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -2,5 +2,5 @@ package redis
|
||||
|
||||
// Version is the current release version.
|
||||
func Version() string {
|
||||
return "9.18.0"
|
||||
return "9.19.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user