fix(go.sum): update ResolveSpec dependency to v1.0.87
CI / build-and-test (push) Failing after 1s
Release / release (push) Failing after 19m26s

This commit is contained in:
Hein
2026-06-23 13:17:16 +02:00
parent 0227912325
commit 1adf50e3db
2436 changed files with 1078758 additions and 114 deletions
+19
View File
@@ -0,0 +1,19 @@
*.rdb
testdata/*
.idea/
.DS_Store
*.tar.gz
*.dic
redis8tests.sh
coverage.txt
**/coverage.txt
.vscode
tmp/*
*.test
extra/redisotel-native/metrics-collector-app/
# maintenanceNotifications upgrade documentation (temporary)
maintenanceNotifications/docs/
# Docker-generated files (TLS certificates, cluster data, etc.)
dockers/*/tls/
dockers/osscluster-tls/
+36
View File
@@ -0,0 +1,36 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
settings:
staticcheck:
checks:
- all
# Incorrect or missing package comment.
# https://staticcheck.dev/docs/checks/#ST1000
- -ST1000
# Omit embedded fields from selector expression.
# https://staticcheck.dev/docs/checks/#QF1008
- -QF1008
- -ST1003
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+4
View File
@@ -0,0 +1,4 @@
semi: false
singleQuote: true
proseWrap: always
printWidth: 100
+118
View File
@@ -0,0 +1,118 @@
# Contributing
## Introduction
We appreciate your interest in considering contributing to go-redis.
Community contributions mean a lot to us.
## Contributions we need
You may already know how you'd like to contribute, whether it's a fix for a bug you
encountered, or a new feature your team wants to use.
If you don't know where to start, consider improving
documentation, bug triaging, and writing tutorials are all examples of
helpful contributions that mean less work for you.
## Your First Contribution
Unsure where to begin contributing? You can start by looking through
[help-wanted
issues](https://github.com/redis/go-redis/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted).
Never contributed to open source before? Here are a couple of friendly
tutorials:
- <http://makeapullrequest.com/>
- <http://www.firsttimersonly.com/>
## Getting Started
Here's how to get started with your code contribution:
1. Create your own fork of go-redis
2. Do the changes in your fork
3. If you need a development environment, run `make docker.start`.
> Note: this clones and builds the docker containers specified in `docker-compose.yml`, to understand more about
> the infrastructure that will be started you can check the `docker-compose.yml`. You also have the possiblity
> to specify the redis image that will be pulled with the env variable `CLIENT_LIBS_TEST_IMAGE`.
> By default the docker image that will be pulled and started is `redislabs/client-libs-test:8.2.1-pre`.
> If you want to test with newer Redis version, using a newer version of `redislabs/client-libs-test` should work out of the box.
4. While developing, make sure the tests pass by running `make test` (if you have the docker containers running, `make test.ci` may be sufficient).
> Note: `make test` will try to start all containers, run the tests with `make test.ci` and then stop all containers.
5. If you like the change and think the project could use it, send a
pull request
To see what else is part of the automation, run `invoke -l`
## Testing
### Setting up Docker
To run the tests, you need to have Docker installed and running. If you are using a host OS that does not support
docker host networks out of the box (e.g. Windows, OSX), you need to set up a docker desktop and enable docker host networks.
### Running tests
Call `make test` to run all tests.
Continuous Integration uses these same wrappers to run all of these
tests against multiple versions of redis. Feel free to test your
changes against all the go versions supported, as declared by the
[build.yml](./.github/workflows/build.yml) file.
### Troubleshooting
If you get any errors when running `make test`, make sure
that you are using supported versions of Docker and go.
## How to Report a Bug
### Security Vulnerabilities
**NOTE**: If you find a security vulnerability, do NOT open an issue.
Email [Redis Open Source (<oss@redis.com>)](mailto:oss@redis.com) instead.
In order to determine whether you are dealing with a security issue, ask
yourself these two questions:
- Can I access something that's not mine, or something I shouldn't
have access to?
- Can I disable something for other people?
If the answer to either of those two questions are *yes*, then you're
probably dealing with a security issue. Note that even if you answer
*no* to both questions, you may still be dealing with a security
issue, so if you're unsure, just email [us](mailto:oss@redis.com).
### Everything Else
When filing an issue, make sure to answer these five questions:
1. What version of go-redis are you using?
2. What version of redis are you using?
3. What did you do?
4. What did you expect to see?
5. What did you see instead?
## Suggest a feature or enhancement
If you'd like to contribute a new feature, make sure you check our
issue list to see if someone has already proposed it. Work may already
be underway on the feature you want or we may have rejected a
feature like it already.
If you don't see anything, open a new issue that describes the feature
you would like and how it should work.
## Code review process
The core team regularly looks at pull requests. We will provide
feedback as soon as possible. After receiving our feedback, please respond
within two weeks. After that time, we may close your PR if it isn't
showing any activity.
## Support
Maintainers can provide limited support to contributors on discord: https://discord.gg/W4txy5AeKM
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2013 The github.com/redis/go-redis Authors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+122
View File
@@ -0,0 +1,122 @@
GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort)
REDIS_VERSION ?= 8.8
RE_CLUSTER ?= false
RCE_DOCKER ?= true
CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.8-m02
docker.start:
export RE_CLUSTER=$(RE_CLUSTER) && \
export RCE_DOCKER=$(RCE_DOCKER) && \
export REDIS_VERSION=$(REDIS_VERSION) && \
export CLIENT_LIBS_TEST_IMAGE=$(CLIENT_LIBS_TEST_IMAGE) && \
docker compose --profile all up -d --quiet-pull
docker.stop:
docker compose --profile all down
docker.e2e.start:
@echo "Starting Redis and cae-resp-proxy for E2E tests..."
docker compose --profile e2e up -d --quiet-pull
@echo "Waiting for services to be ready..."
@sleep 3
@echo "Services ready!"
docker.e2e.stop:
@echo "Stopping E2E services..."
docker compose --profile e2e down
test:
$(MAKE) docker.start
@if [ -z "$(REDIS_VERSION)" ]; then \
echo "REDIS_VERSION not set, running all tests"; \
$(MAKE) test.ci; \
else \
MAJOR_VERSION=$$(echo "$(REDIS_VERSION)" | cut -d. -f1); \
if [ "$$MAJOR_VERSION" -ge 8 ]; then \
echo "REDIS_VERSION $(REDIS_VERSION) >= 8, running all tests"; \
$(MAKE) test.ci; \
else \
echo "REDIS_VERSION $(REDIS_VERSION) < 8, skipping vector_sets tests"; \
$(MAKE) test.ci.skip-vectorsets; \
fi; \
fi
$(MAKE) docker.stop
test.ci:
set -e; for dir in $(GO_MOD_DIRS); do \
echo "go test in $${dir}"; \
(cd "$${dir}" && \
export RE_CLUSTER=$(RE_CLUSTER) && \
export RCE_DOCKER=$(RCE_DOCKER) && \
export REDIS_VERSION=$(REDIS_VERSION) && \
go mod tidy && \
go vet && \
go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race -skip Example); \
done
cd internal/customvet && go build .
go vet -vettool ./internal/customvet/customvet
test.ci.skip-vectorsets:
set -e; for dir in $(GO_MOD_DIRS); do \
echo "go test in $${dir} (skipping vector sets)"; \
(cd "$${dir}" && \
export RE_CLUSTER=$(RE_CLUSTER) && \
export RCE_DOCKER=$(RCE_DOCKER) && \
export REDIS_VERSION=$(REDIS_VERSION) && \
go mod tidy && \
go vet && \
go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race \
-run '^(?!.*(?:VectorSet|vectorset|ExampleClient_vectorset)).*$$' -skip Example); \
done
cd internal/customvet && go build .
go vet -vettool ./internal/customvet/customvet
bench:
export RE_CLUSTER=$(RE_CLUSTER) && \
export RCE_DOCKER=$(RCE_DOCKER) && \
export REDIS_VERSION=$(REDIS_VERSION) && \
go test ./... -test.run=NONE -test.bench=. -test.benchmem -skip Example
test.e2e:
@echo "Running E2E tests with auto-start proxy..."
$(MAKE) docker.e2e.start
@echo "Running tests..."
@E2E_SCENARIO_TESTS=true go test -v ./maintnotifications/e2e/ -timeout 30m || ($(MAKE) docker.e2e.stop && exit 1)
$(MAKE) docker.e2e.stop
@echo "E2E tests completed!"
test.e2e.docker:
@echo "Running Docker-compatible E2E tests..."
$(MAKE) docker.e2e.start
@echo "Running unified injector tests..."
@E2E_SCENARIO_TESTS=true go test -v -run "TestUnifiedInjector|TestCreateTestFaultInjectorLogic|TestFaultInjectorClientCreation" ./maintnotifications/e2e/ -timeout 10m || ($(MAKE) docker.e2e.stop && exit 1)
$(MAKE) docker.e2e.stop
@echo "Docker E2E tests completed!"
test.e2e.logic:
@echo "Running E2E logic tests (no proxy required)..."
@E2E_SCENARIO_TESTS=true \
REDIS_ENDPOINTS_CONFIG_PATH=/tmp/test_endpoints_verify.json \
FAULT_INJECTION_API_URL=http://localhost:8080 \
go test -v -run "TestCreateTestFaultInjectorLogic|TestFaultInjectorClientCreation" ./maintnotifications/e2e/
@echo "Logic tests completed!"
.PHONY: all test test.ci test.ci.skip-vectorsets bench fmt test.e2e test.e2e.logic docker.e2e.start docker.e2e.stop
build:
export RE_CLUSTER=$(RE_CLUSTER) && \
export RCE_DOCKER=$(RCE_DOCKER) && \
export REDIS_VERSION=$(REDIS_VERSION) && \
go build .
fmt:
gofumpt -w ./
goimports -w -local github.com/redis/go-redis ./
go_mod_tidy:
set -e; for dir in $(GO_MOD_DIRS); do \
echo "go mod tidy in $${dir}"; \
(cd "$${dir}" && \
go get -u ./... && \
go mod tidy); \
done
+618
View File
@@ -0,0 +1,618 @@
# Redis client for Go
[![build workflow](https://github.com/redis/go-redis/actions/workflows/build.yml/badge.svg)](https://github.com/redis/go-redis/actions)
[![PkgGoDev](https://pkg.go.dev/badge/github.com/redis/go-redis/v9)](https://pkg.go.dev/github.com/redis/go-redis/v9?tab=doc)
[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.io/docs/latest/develop/clients/go/)
[![Go Report Card](https://goreportcard.com/badge/github.com/redis/go-redis/v9)](https://goreportcard.com/report/github.com/redis/go-redis/v9)
[![codecov](https://codecov.io/github/redis/go-redis/graph/badge.svg?token=tsrCZKuSSw)](https://codecov.io/github/redis/go-redis)
[![Discord](https://img.shields.io/discord/697882427875393627.svg?style=social&logo=discord)](https://discord.gg/W4txy5AeKM)
[![Twitch](https://img.shields.io/twitch/status/redisinc?style=social)](https://www.twitch.tv/redisinc)
[![YouTube](https://img.shields.io/youtube/channel/views/UCD78lHSwYqMlyetR0_P4Vig?style=social)](https://www.youtube.com/redisinc)
[![Twitter](https://img.shields.io/twitter/follow/redisinc?style=social)](https://twitter.com/redisinc)
[![Stack Exchange questions](https://img.shields.io/stackexchange/stackoverflow/t/go-redis?style=social&logo=stackoverflow&label=Stackoverflow)](https://stackoverflow.com/questions/tagged/go-redis)
> go-redis is the official Redis client library for the Go programming language. It offers a straightforward interface for interacting with Redis servers.
## Supported versions
In `go-redis` we are aiming to support the last three releases of Redis. Currently, this means we do support:
- [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0
- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2
- [Redis 8.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.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.
## How do I Redis?
[Learn for free at Redis University](https://university.redis.com/)
[Build faster with the Redis Launchpad](https://launchpad.redis.com/)
[Try the Redis Cloud](https://redis.com/try-free/)
[Dive in developer tutorials](https://developer.redis.com/)
[Join the Redis community](https://redis.com/community/)
[Work at Redis](https://redis.com/company/careers/jobs/)
## Resources
- [Discussions](https://github.com/redis/go-redis/discussions)
- [Chat](https://discord.gg/W4txy5AeKM)
- [Reference](https://pkg.go.dev/github.com/redis/go-redis/v9)
- [Examples](https://pkg.go.dev/github.com/redis/go-redis/v9#pkg-examples)
## old documentation
- [English](https://redis.uptrace.dev)
- [简体中文](https://redis.uptrace.dev/zh/)
## Ecosystem
- [Entra ID (Azure AD)](https://github.com/redis/go-redis-entraid)
- [Distributed Locks](https://github.com/bsm/redislock)
- [Redis Cache](https://github.com/go-redis/cache)
- [Rate limiting](https://github.com/go-redis/redis_rate)
## Features
- Redis commands except QUIT and SYNC.
- Automatic connection pooling.
- [StreamingCredentialsProvider (e.g. entra id, oauth)](#1-streaming-credentials-provider-highest-priority) (experimental)
- [Pub/Sub](https://redis.uptrace.dev/guide/go-redis-pubsub.html).
- [Pipelines and transactions](https://redis.uptrace.dev/guide/go-redis-pipelines.html).
- [Scripting](https://redis.uptrace.dev/guide/lua-scripting.html).
- [Redis Sentinel](https://redis.uptrace.dev/guide/go-redis-sentinel.html).
- [Redis Cluster](https://redis.uptrace.dev/guide/go-redis-cluster.html).
- [Redis Performance Monitoring](https://redis.uptrace.dev/guide/redis-performance-monitoring.html).
- [Redis Probabilistic [RedisStack]](https://redis.io/docs/data-types/probabilistic/)
- [Customizable read and write buffers size.](#custom-buffer-sizes)
## Installation
go-redis supports 2 last Go versions and requires a Go version with
[modules](https://github.com/golang/go/wiki/Modules) support. So make sure to initialize a Go
module:
```shell
go mod init github.com/my/repo
```
Then install go-redis/**v9**:
```shell
go get github.com/redis/go-redis/v9
```
## Quickstart
```go
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
func ExampleClient() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
defer rdb.Close()
err := rdb.Set(ctx, "key", "value", 0).Err()
if err != nil {
panic(err)
}
val, err := rdb.Get(ctx, "key").Result()
if err != nil {
panic(err)
}
fmt.Println("key", val)
val2, err := rdb.Get(ctx, "key2").Result()
if err == redis.Nil {
fmt.Println("key2 does not exist")
} else if err != nil {
panic(err)
} else {
fmt.Println("key2", val2)
}
// Output: key value
// key2 does not exist
}
```
### 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:
#### 1. Streaming Credentials Provider (Highest Priority) - Experimental feature
The streaming credentials provider allows for dynamic credential updates during the connection lifetime. This is particularly useful for managed identity services and token-based authentication.
```go
type StreamingCredentialsProvider interface {
Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error)
}
type CredentialsListener interface {
OnNext(credentials Credentials) // Called when credentials are updated
OnError(err error) // Called when an error occurs
}
type Credentials interface {
BasicAuth() (username string, password string)
RawCredentials() string
}
```
Example usage:
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
StreamingCredentialsProvider: &MyCredentialsProvider{},
})
```
**Note:** The streaming credentials provider can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) to enable Entra ID (formerly Azure AD) authentication. This allows for seamless integration with Azure's managed identity services and token-based authentication.
Example with Entra ID:
```go
import (
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis-entraid"
)
// Create an Entra ID credentials provider
provider := entraid.NewDefaultAzureIdentityProvider()
// Configure Redis client with Entra ID authentication
rdb := redis.NewClient(&redis.Options{
Addr: "your-redis-server.redis.cache.windows.net:6380",
StreamingCredentialsProvider: provider,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
})
```
#### 2. Context-based Credentials Provider
The context-based provider allows credentials to be determined at the time of each operation, using the context.
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
CredentialsProviderContext: func(ctx context.Context) (string, string, error) {
// Return username, password, and any error
return "user", "pass", nil
},
})
```
#### 3. Regular Credentials Provider
A simple function-based provider that returns static credentials.
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
CredentialsProvider: func() (string, string) {
// Return username and password
return "user", "pass"
},
})
```
#### 4. Username/Password Fields (Lowest Priority)
The most basic way to provide credentials is through the `Username` and `Password` fields in the options.
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Username: "user",
Password: "pass",
})
```
#### Priority Order
The client will use credentials in the following priority order:
1. Streaming Credentials Provider (if set)
2. Context-based Credentials Provider (if set)
3. Regular Credentials Provider (if set)
4. Username/Password fields (if set)
If none of these are set, the client will attempt to connect without authentication.
### Protocol Version
The client supports both RESP2 and RESP3 protocols. You can specify the protocol version in the options:
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
Protocol: 3, // specify 2 for RESP 2 or 3 for RESP 3
})
```
### Connecting via a redis url
go-redis also supports connecting via the
[redis uri specification](https://github.com/redis/redis-specifications/tree/master/uri/redis.txt).
The example below demonstrates how the connection can easily be configured using a string, adhering
to this specification.
```go
import (
"github.com/redis/go-redis/v9"
)
func ExampleClient() *redis.Client {
url := "redis://user:password@localhost:6379/0?protocol=3"
opts, err := redis.ParseURL(url)
if err != nil {
panic(err)
}
return redis.NewClient(opts)
}
```
### Instrument with OpenTelemetry
```go
import (
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/extra/redisotel/v9"
"errors"
)
func main() {
...
rdb := redis.NewClient(&redis.Options{...})
if err := errors.Join(redisotel.InstrumentTracing(rdb), redisotel.InstrumentMetrics(rdb)); err != nil {
log.Fatal(err)
}
```
### Buffer Size Configuration
go-redis uses 32KiB read and write buffers by default for optimal performance. For high-throughput applications or large pipelines, you can customize buffer sizes:
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
ReadBufferSize: 1024 * 1024, // 1MiB read buffer
WriteBufferSize: 1024 * 1024, // 1MiB write buffer
})
```
### Advanced Configuration
go-redis supports extending the client identification phase to allow projects to send their own custom client identification.
#### Default Client Identification
By default, go-redis automatically sends the client library name and version during the connection process. This feature is available in redis-server as of version 7.2. As a result, the command is "fire and forget", meaning it should fail silently, in the case that the redis server does not support this feature.
#### Disabling Identity Verification
When connection identity verification is not required or needs to be explicitly disabled, a `DisableIdentity` configuration option exists.
Initially there was a typo and the option was named `DisableIndentity` instead of `DisableIdentity`. The misspelled option is marked as Deprecated and will be removed in V10 of this library.
Although both options will work at the moment, the correct option is `DisableIdentity`. The deprecated option will be removed in V10 of this library, so please use the correct option name to avoid any issues.
To disable verification, set the `DisableIdentity` option to `true` in the Redis client options:
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
DisableIdentity: true, // Disable set-info on connect
})
```
#### Unstable RESP3 Structures for RediSearch Commands
When integrating Redis with application functionalities using RESP3, it's important to note that some response structures aren't final yet. This is especially true for more complex structures like search and query results. We recommend using RESP2 when using the search and query capabilities, but we plan to stabilize the RESP3-based API-s in the coming versions. You can find more guidance in the upcoming release notes.
To enable unstable RESP3, set the option in your client configuration:
```go
redis.NewClient(&redis.Options{
UnstableResp3: true,
})
```
**Note:** When UnstableResp3 mode is enabled, it's necessary to use RawResult() and RawVal() to retrieve a raw data.
Since, raw response is the only option for unstable search commands Val() and Result() calls wouldn't have any affect on them:
```go
res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawResult()
val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawVal()
```
#### Redis-Search Default Dialect
In the Redis-Search module, **the default dialect is 2**. If needed, you can explicitly specify a different dialect using the appropriate configuration in your queries.
**Important**: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by passing the desired dialect in the arguments of the command you want to execute.
For example:
```
res2, err := rdb.FTSearchWithArgs(ctx,
"idx:bicycle",
"@pickup_zone:[CONTAINS $bike]",
&redis.FTSearchOptions{
Params: map[string]interface{}{
"bike": "POINT(-0.1278 51.5074)",
},
DialectVersion: 3,
},
).Result()
```
You can find further details in the [query dialect documentation](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/).
#### Custom buffer sizes
Prior to v9.12, the buffer size was the default go value of 4096 bytes. Starting from v9.12,
go-redis uses 32KiB read and write buffers by default for optimal performance.
For high-throughput applications or large pipelines, you can customize buffer sizes:
```go
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
ReadBufferSize: 1024 * 1024, // 1MiB read buffer
WriteBufferSize: 1024 * 1024, // 1MiB write buffer
})
```
**Important**: If you experience any issues with the default buffer sizes, please try setting them to the go default of 4096 bytes.
## Contributing
We welcome contributions to the go-redis library! If you have a bug fix, feature request, or improvement, please open an issue or pull request on GitHub.
We appreciate your help in making go-redis better for everyone.
If you are interested in contributing to the go-redis library, please check out our [contributing guidelines](CONTRIBUTING.md) for more information on how to get started.
## Look and feel
Some corner cases:
```go
// SET key value EX 10 NX
set, err := rdb.SetNX(ctx, "key", "value", 10*time.Second).Result()
// SET key value keepttl NX
set, err := rdb.SetNX(ctx, "key", "value", redis.KeepTTL).Result()
// SORT list LIMIT 0 2 ASC
vals, err := rdb.Sort(ctx, "list", &redis.Sort{Offset: 0, Count: 2, Order: "ASC"}).Result()
// ZRANGEBYSCORE zset -inf +inf WITHSCORES LIMIT 0 2
vals, err := rdb.ZRangeByScoreWithScores(ctx, "zset", &redis.ZRangeBy{
Min: "-inf",
Max: "+inf",
Offset: 0,
Count: 2,
}).Result()
// ZINTERSTORE out 2 zset1 zset2 WEIGHTS 2 3 AGGREGATE SUM
vals, err := rdb.ZInterStore(ctx, "out", &redis.ZStore{
Keys: []string{"zset1", "zset2"},
Weights: []int64{2, 3}
}).Result()
// EVAL "return {KEYS[1],ARGV[1]}" 1 "key" "hello"
vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello").Result()
// custom command
res, err := rdb.Do(ctx, "set", "key", "value").Result()
```
## Typed Errors
go-redis provides typed error checking functions for common Redis errors:
```go
// Cluster and replication errors
redis.IsLoadingError(err) // Redis is loading the dataset
redis.IsReadOnlyError(err) // Write to read-only replica
redis.IsClusterDownError(err) // Cluster is down
redis.IsTryAgainError(err) // Command should be retried
redis.IsMasterDownError(err) // Master is down
redis.IsMovedError(err) // Returns (address, true) if key moved
redis.IsAskError(err) // Returns (address, true) if key being migrated
// Connection and resource errors
redis.IsMaxClientsError(err) // Maximum clients reached
redis.IsAuthError(err) // Authentication failed (NOAUTH, WRONGPASS, unauthenticated)
redis.IsPermissionError(err) // Permission denied (NOPERM)
redis.IsOOMError(err) // Out of memory (OOM)
// Transaction errors
redis.IsExecAbortError(err) // Transaction aborted (EXECABORT)
```
### Error Wrapping in Hooks
When wrapping errors in hooks, use custom error types with `Unwrap()` method (preferred) or `fmt.Errorf` with `%w`. Always call `cmd.SetErr()` to preserve error type information:
```go
// Custom error type (preferred)
type AppError struct {
Code string
RequestID string
Err error
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Hook implementation
func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
err := next(ctx, cmd)
if err != nil {
// Wrap with custom error type
wrappedErr := &AppError{
Code: "REDIS_ERROR",
RequestID: getRequestID(ctx),
Err: err,
}
cmd.SetErr(wrappedErr)
return wrappedErr // Return wrapped error to preserve it
}
return nil
}
}
// Typed error detection works through wrappers
if redis.IsLoadingError(err) {
// Retry logic
}
// Extract custom error if needed
var appErr *AppError
if errors.As(err, &appErr) {
log.Printf("Request: %s", appErr.RequestID)
}
```
Alternatively, use `fmt.Errorf` with `%w`:
```go
wrappedErr := fmt.Errorf("context: %w", err)
cmd.SetErr(wrappedErr)
```
### Pipeline Hook Example
For pipeline operations, use `ProcessPipelineHook`:
```go
type PipelineLoggingHook struct{}
func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook {
return next
}
func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return next
}
func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
start := time.Now()
// Execute the pipeline
err := next(ctx, cmds)
duration := time.Since(start)
log.Printf("Pipeline executed %d commands in %v", len(cmds), duration)
// Process individual command errors
// Note: Individual command errors are already set on each cmd by the pipeline execution
for _, cmd := range cmds {
if cmdErr := cmd.Err(); cmdErr != nil {
// Check for specific error types using typed error functions
if redis.IsAuthError(cmdErr) {
log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr)
} else if redis.IsPermissionError(cmdErr) {
log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr)
}
// Optionally wrap individual command errors to add context
// The wrapped error preserves type information through errors.As()
wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr)
cmd.SetErr(wrappedErr)
}
}
// Return the pipeline-level error (connection errors, etc.)
// You can wrap it if needed, or return it as-is
return err
}
}
// Register the hook
rdb.AddHook(PipelineLoggingHook{})
// Use pipeline - errors are still properly typed
pipe := rdb.Pipeline()
pipe.Set(ctx, "key1", "value1", 0)
pipe.Get(ctx, "key2")
_, err := pipe.Exec(ctx)
```
## Run the test
Recommended to use Docker, just need to run:
```shell
make test
```
## See also
- [Golang ORM](https://bun.uptrace.dev) for PostgreSQL, MySQL, MSSQL, and SQLite
- [Golang PostgreSQL](https://bun.uptrace.dev/postgres/)
- [Golang HTTP router](https://bunrouter.uptrace.dev/)
- [Golang ClickHouse ORM](https://github.com/uptrace/go-clickhouse)
## Contributors
> The go-redis project was originally initiated by :star: [**uptrace/uptrace**](https://github.com/uptrace/uptrace).
> Uptrace is an open-source APM tool that supports distributed tracing, metrics, and logs. You can
> use it to monitor applications and set up automatic alerts to receive notifications via email,
> Slack, Telegram, and others.
>
> See [OpenTelemetry](https://github.com/redis/go-redis/tree/master/example/otel) example which
> demonstrates how you can use Uptrace to monitor go-redis.
Thanks to all the people who already contributed!
<a href="https://github.com/redis/go-redis/graphs/contributors">
<img src="https://contributors-img.web.app/image?repo=redis/go-redis" />
</a>
+950
View File
@@ -0,0 +1,950 @@
# 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
### Redis 8.6 Support
Added support for Redis 8.6, including new commands and features for streams idempotent production and HOTKEYS.
### Smart Client Handoff (Maintenance Notifications) for Cluster
This release introduces comprehensive support for Redis Cluster maintenance notifications via SMIGRATING/SMIGRATED push notifications. The client now automatically handles slot migrations by:
- **Relaxing timeouts during migration** (SMIGRATING) to prevent false failures
- **Triggering lazy cluster state reloads** upon completion (SMIGRATED)
- Enabling seamless operations during Redis Enterprise maintenance windows
([#3643](https://github.com/redis/go-redis/pull/3643)) by [@ndyakov](https://github.com/ndyakov)
### OpenTelemetry Native Metrics Support
Added comprehensive OpenTelemetry metrics support following the [OpenTelemetry Database Client Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/database/database-metrics/). The implementation uses a Bridge Pattern to keep the core library dependency-free while providing optional metrics instrumentation through the new `extra/redisotel-native` package.
**Metric groups include:**
- Command metrics: Operation duration with retry tracking
- Connection basic: Connection count and creation time
- Resiliency: Errors, handoffs, timeout relaxation
- Connection advanced: Wait time and use time
- Pubsub metrics: Published and received messages
- Stream metrics: Processing duration and maintenance notifications
([#3637](https://github.com/redis/go-redis/pull/3637)) by [@ofekshenawa](https://github.com/ofekshenawa)
## ✨ New Features
- **HOTKEYS Commands**: Added support for Redis HOTKEYS feature for identifying hot keys based on CPU consumption and network utilization ([#3695](https://github.com/redis/go-redis/pull/3695)) by [@ofekshenawa](https://github.com/ofekshenawa)
- **Streams Idempotent Production**: Added support for Redis 8.6+ Streams Idempotent Production with `ProducerID`, `IdempotentID`, `IdempotentAuto` in `XAddArgs` and new `XCFGSET` command ([#3693](https://github.com/redis/go-redis/pull/3693)) by [@ofekshenawa](https://github.com/ofekshenawa)
- **NaN Values for TimeSeries**: Added support for NaN (Not a Number) values in Redis time series commands ([#3687](https://github.com/redis/go-redis/pull/3687)) by [@ofekshenawa](https://github.com/ofekshenawa)
- **DialerRetries Options**: Added `DialerRetries` and `DialerRetryTimeout` to `ClusterOptions`, `RingOptions`, and `FailoverOptions` ([#3686](https://github.com/redis/go-redis/pull/3686)) by [@naveenchander30](https://github.com/naveenchander30)
- **ConnMaxLifetimeJitter**: Added jitter configuration to distribute connection expiration times and prevent thundering herd ([#3666](https://github.com/redis/go-redis/pull/3666)) by [@cyningsun](https://github.com/cyningsun)
- **Digest Helper Functions**: Added `DigestString` and `DigestBytes` helper functions for client-side xxh3 hashing compatible with Redis DIGEST command ([#3679](https://github.com/redis/go-redis/pull/3679)) by [@ofekshenawa](https://github.com/ofekshenawa)
- **SMIGRATED New Format**: Updated SMIGRATED parser to support new format and remember original host:port ([#3697](https://github.com/redis/go-redis/pull/3697)) by [@ndyakov](https://github.com/ndyakov)
- **Cluster State Reload Interval**: Added cluster state reload interval option for maintenance notifications ([#3663](https://github.com/redis/go-redis/pull/3663)) by [@ndyakov](https://github.com/ndyakov)
## 🐛 Bug Fixes
- **PubSub nil pointer dereference**: Fixed nil pointer dereference in PubSub after `WithTimeout()` - `pubSubPool` is now properly cloned ([#3710](https://github.com/redis/go-redis/pull/3710)) by [@Copilot](https://github.com/apps/copilot-swe-agent)
- **MaintNotificationsConfig nil check**: Guard against nil `MaintNotificationsConfig` in `initConn` ([#3707](https://github.com/redis/go-redis/pull/3707)) by [@veeceey](https://github.com/veeceey)
- **wantConnQueue zombie elements**: Fixed zombie `wantConn` elements accumulation in `wantConnQueue` ([#3680](https://github.com/redis/go-redis/pull/3680)) by [@cyningsun](https://github.com/cyningsun)
- **XADD/XTRIM approx flag**: Fixed XADD and XTRIM to use `=` when approx is false ([#3684](https://github.com/redis/go-redis/pull/3684)) by [@ndyakov](https://github.com/ndyakov)
- **Sentinel timeout retry**: When connection to a sentinel times out, attempt to connect to other sentinels ([#3654](https://github.com/redis/go-redis/pull/3654)) by [@cxljs](https://github.com/cxljs)
## ⚡ Performance
- **Fuzz test optimization**: Eliminated repeated string conversions, used functional approach for cleaner operation selection ([#3692](https://github.com/redis/go-redis/pull/3692)) by [@feiguoL](https://github.com/feiguoL)
- **Pre-allocate capacity**: Pre-allocate slice capacity to prevent multiple capacity expansions ([#3689](https://github.com/redis/go-redis/pull/3689)) by [@feelshu](https://github.com/feelshu)
## 🧪 Testing
- **Comprehensive TLS tests**: Added comprehensive TLS tests and example for standalone, cluster, and certificate authentication ([#3681](https://github.com/redis/go-redis/pull/3681)) by [@ndyakov](https://github.com/ndyakov)
- **Redis 8.6**: Updated CI to use Redis 8.6-pre ([#3685](https://github.com/redis/go-redis/pull/3685)) by [@ndyakov](https://github.com/ndyakov)
## 🧰 Maintenance
- **Deprecation warnings**: Added deprecation warnings for commands based on Redis documentation ([#3673](https://github.com/redis/go-redis/pull/3673)) by [@ndyakov](https://github.com/ndyakov)
- **Use errors.Join()**: Replaced custom error join function with standard library `errors.Join()` ([#3653](https://github.com/redis/go-redis/pull/3653)) by [@cxljs](https://github.com/cxljs)
- **Use Go 1.21 min/max**: Use Go 1.21's built-in min/max functions ([#3656](https://github.com/redis/go-redis/pull/3656)) by [@cxljs](https://github.com/cxljs)
- **Proper formatting**: Code formatting improvements ([#3670](https://github.com/redis/go-redis/pull/3670)) by [@12ya](https://github.com/12ya)
- **Set commands documentation**: Added comprehensive documentation to all set command methods ([#3642](https://github.com/redis/go-redis/pull/3642)) by [@iamamirsalehi](https://github.com/iamamirsalehi)
- **MaxActiveConns docs**: Added default value documentation for `MaxActiveConns` ([#3674](https://github.com/redis/go-redis/pull/3674)) by [@codykaup](https://github.com/codykaup)
- **README example update**: Updated README example ([#3657](https://github.com/redis/go-redis/pull/3657)) by [@cxljs](https://github.com/cxljs)
- **Cluster maintnotif example**: Added example application for cluster maintenance notifications ([#3651](https://github.com/redis/go-redis/pull/3651)) by [@ndyakov](https://github.com/ndyakov)
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@12ya](https://github.com/12ya), [@Copilot](https://github.com/apps/copilot-swe-agent), [@codykaup](https://github.com/codykaup), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@feelshu](https://github.com/feelshu), [@feiguoL](https://github.com/feiguoL), [@iamamirsalehi](https://github.com/iamamirsalehi), [@naveenchander30](https://github.com/naveenchander30), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@veeceey](https://github.com/veeceey)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.0...v9.18.0
# 9.18.0-beta.2 (2025-12-09)
## 🚀 Highlights
### Go Version Update
This release updates the minimum required Go version to 1.21. This is part of a gradual migration strategy where the minimum supported Go version will be three versions behind the latest release. With each new Go version release, we will bump the minimum version by one, ensuring compatibility while staying current with the Go ecosystem.
### Stability Improvements
This release includes several important stability fixes:
- Fixed a critical panic in the handoff worker manager that could occur when handling nil errors
- Improved test reliability for Smart Client Handoff functionality
- Fixed logging format issues that could cause runtime errors
## ✨ New Features
- OpenTelemetry metrics improvements for nil response handling ([#3638](https://github.com/redis/go-redis/pull/3638)) by [@fengve](https://github.com/fengve)
## 🐛 Bug Fixes
- Fixed panic on nil error in handoffWorkerManager closeConnFromRequest ([#3633](https://github.com/redis/go-redis/pull/3633)) by [@ccoVeille](https://github.com/ccoVeille)
- Fixed bad sprintf syntax in logging ([#3632](https://github.com/redis/go-redis/pull/3632)) by [@ccoVeille](https://github.com/ccoVeille)
## 🧰 Maintenance
- Updated minimum Go version to 1.21 ([#3640](https://github.com/redis/go-redis/pull/3640)) by [@ndyakov](https://github.com/ndyakov)
- Use Go 1.20 idiomatic string<->byte conversion ([#3435](https://github.com/redis/go-redis/pull/3435)) by [@justinhwang](https://github.com/justinhwang)
- Reduce flakiness of Smart Client Handoff test ([#3641](https://github.com/redis/go-redis/pull/3641)) by [@kiryazovi-redis](https://github.com/kiryazovi-redis)
- Revert PR #3634 (Observability metrics phase1) ([#3635](https://github.com/redis/go-redis/pull/3635)) by [@ofekshenawa](https://github.com/ofekshenawa)
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@justinhwang](https://github.com/justinhwang), [@ndyakov](https://github.com/ndyakov), [@kiryazovi-redis](https://github.com/kiryazovi-redis), [@fengve](https://github.com/fengve), [@ccoVeille](https://github.com/ccoVeille), [@ofekshenawa](https://github.com/ofekshenawa)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.18.0-beta.1...v9.18.0-beta.2
# 9.18.0-beta.1 (2025-12-01)
## 🚀 Highlights
### Request and Response Policy Based Routing in Cluster Mode
This beta release introduces comprehensive support for Redis COMMAND-based request and response policy routing for cluster clients. This feature enables intelligent command routing and response aggregation based on Redis command metadata.
**Key Features:**
- **Command Policy Loader**: Automatically parses and caches COMMAND metadata with routing/aggregation hints
- **Enhanced Routing Engine**: Supports all request policies including:
- `default(keyless)` - Commands without keys
- `default(hashslot)` - Commands with hash slot routing
- `all_shards` - Commands that need to run on all shards
- `all_nodes` - Commands that need to run on all nodes
- `multi_shard` - Commands that span multiple shards
- `special` - Commands with custom routing logic
- **Response Aggregator**: Intelligently combines multi-shard replies based on response policies:
- `all_succeeded` - All shards must succeed
- `one_succeeded` - At least one shard must succeed
- `agg_sum` - Aggregate numeric responses
- `special` - Custom aggregation logic (e.g., FT.CURSOR)
- **Raw Command Support**: Policies are enforced on `Client.Do(ctx, args...)`
This feature is particularly useful for Redis Stack commands like RediSearch that need to operate across multiple shards in a cluster.
### Connection Pool Improvements
Fixed a critical defect in the connection pool's turn management mechanism that could lead to connection leaks under certain conditions. The fix ensures proper 1:1 correspondence between turns and connections.
## ✨ New Features
- Request and Response Policy Based Routing in Cluster Mode ([#3422](https://github.com/redis/go-redis/pull/3422)) by [@ofekshenawa](https://github.com/ofekshenawa)
## 🐛 Bug Fixes
- Fixed connection pool turn management to prevent connection leaks ([#3626](https://github.com/redis/go-redis/pull/3626)) by [@cyningsun](https://github.com/cyningsun)
## 🧰 Maintenance
- chore(deps): bump rojopolis/spellcheck-github-actions from 0.54.0 to 0.55.0 ([#3627](https://github.com/redis/go-redis/pull/3627))
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@cyningsun](https://github.com/cyningsun), [@ofekshenawa](https://github.com/ofekshenawa), [@ndyakov](https://github.com/ndyakov)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.1...v9.18.0-beta.1
# 9.17.1 (2025-11-25)
## 🐛 Bug Fixes
- add wait to keyless commands list ([#3615](https://github.com/redis/go-redis/pull/3615)) by [@marcoferrer](https://github.com/marcoferrer)
- fix(time): remove cached time optimization ([#3611](https://github.com/redis/go-redis/pull/3611)) by [@ndyakov](https://github.com/ndyakov)
## 🧰 Maintenance
- chore(deps): bump golangci/golangci-lint-action from 9.0.0 to 9.1.0 ([#3609](https://github.com/redis/go-redis/pull/3609))
- chore(deps): bump actions/checkout from 5 to 6 ([#3610](https://github.com/redis/go-redis/pull/3610))
- chore(script): fix help call in tag.sh ([#3606](https://github.com/redis/go-redis/pull/3606)) by [@ndyakov](https://github.com/ndyakov)
## Contributors
We'd like to thank all the contributors who worked on this release!
[@marcoferrer](https://github.com/marcoferrer) and [@ndyakov](https://github.com/ndyakov)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.0...v9.17.1
# 9.17.0 (2025-11-19)
## 🚀 Highlights
### Redis 8.4 Support
Added support for Redis 8.4, including new commands and features ([#3572](https://github.com/redis/go-redis/pull/3572))
### Typed Errors
Introduced typed errors for better error handling using `errors.As` instead of string checks. Errors can now be wrapped and set to commands in hooks without breaking library functionality ([#3602](https://github.com/redis/go-redis/pull/3602))
### New Commands
- **CAS/CAD Commands**: Added support for Compare-And-Set/Compare-And-Delete operations with conditional matching (`IFEQ`, `IFNE`, `IFDEQ`, `IFDNE`) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595))
- **MSETEX**: Atomically set multiple key-value pairs with expiration options and conditional modes ([#3580](https://github.com/redis/go-redis/pull/3580))
- **XReadGroup CLAIM**: Consume both incoming and idle pending entries from streams in a single call ([#3578](https://github.com/redis/go-redis/pull/3578))
- **ACL Commands**: Added `ACLGenPass`, `ACLUsers`, and `ACLWhoAmI` ([#3576](https://github.com/redis/go-redis/pull/3576))
- **SLOWLOG Commands**: Added `SLOWLOG LEN` and `SLOWLOG RESET` ([#3585](https://github.com/redis/go-redis/pull/3585))
- **LATENCY Commands**: Added `LATENCY LATEST` and `LATENCY RESET` ([#3584](https://github.com/redis/go-redis/pull/3584))
### Search & Vector Improvements
- **Hybrid Search**: Added **EXPERIMENTAL** support for the new `FT.HYBRID` command ([#3573](https://github.com/redis/go-redis/pull/3573))
- **Vector Range**: Added `VRANGE` command for vector sets ([#3543](https://github.com/redis/go-redis/pull/3543))
- **FT.INFO Enhancements**: Added vector-specific attributes in FT.INFO response ([#3596](https://github.com/redis/go-redis/pull/3596))
### Connection Pool Improvements
- **Improved Connection Success Rate**: Implemented FIFO queue-based fairness and context pattern for connection creation to prevent premature cancellation under high concurrency ([#3518](https://github.com/redis/go-redis/pull/3518))
- **Connection State Machine**: Resolved race conditions and improved pool performance with proper state tracking ([#3559](https://github.com/redis/go-redis/pull/3559))
- **Pool Performance**: Significant performance improvements with faster semaphores, lockless hook manager, and reduced allocations (47-67% faster Get/Put operations) ([#3565](https://github.com/redis/go-redis/pull/3565))
### Metrics & Observability
- **Canceled Metric Attribute**: Added 'canceled' metrics attribute to distinguish context cancellation errors from other errors ([#3566](https://github.com/redis/go-redis/pull/3566))
## ✨ New Features
- Typed errors with wrapping support ([#3602](https://github.com/redis/go-redis/pull/3602)) by [@ndyakov](https://github.com/ndyakov)
- CAS/CAD commands (marked as experimental) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) by [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis)
- MSETEX command support ([#3580](https://github.com/redis/go-redis/pull/3580)) by [@ofekshenawa](https://github.com/ofekshenawa)
- XReadGroup CLAIM argument ([#3578](https://github.com/redis/go-redis/pull/3578)) by [@ofekshenawa](https://github.com/ofekshenawa)
- ACL commands: GenPass, Users, WhoAmI ([#3576](https://github.com/redis/go-redis/pull/3576)) by [@destinyoooo](https://github.com/destinyoooo)
- SLOWLOG commands: LEN, RESET ([#3585](https://github.com/redis/go-redis/pull/3585)) by [@destinyoooo](https://github.com/destinyoooo)
- LATENCY commands: LATEST, RESET ([#3584](https://github.com/redis/go-redis/pull/3584)) by [@destinyoooo](https://github.com/destinyoooo)
- Hybrid search command (FT.HYBRID) ([#3573](https://github.com/redis/go-redis/pull/3573)) by [@htemelski-redis](https://github.com/htemelski-redis)
- Vector range command (VRANGE) ([#3543](https://github.com/redis/go-redis/pull/3543)) by [@cxljs](https://github.com/cxljs)
- Vector-specific attributes in FT.INFO ([#3596](https://github.com/redis/go-redis/pull/3596)) by [@ndyakov](https://github.com/ndyakov)
- Improved connection pool success rate with FIFO queue ([#3518](https://github.com/redis/go-redis/pull/3518)) by [@cyningsun](https://github.com/cyningsun)
- Canceled metrics attribute for context errors ([#3566](https://github.com/redis/go-redis/pull/3566)) by [@pvragov](https://github.com/pvragov)
## 🐛 Bug Fixes
- Fixed Failover Client MaintNotificationsConfig ([#3600](https://github.com/redis/go-redis/pull/3600)) by [@ajax16384](https://github.com/ajax16384)
- Fixed ACLGenPass function to use the bit parameter ([#3597](https://github.com/redis/go-redis/pull/3597)) by [@destinyoooo](https://github.com/destinyoooo)
- Return error instead of panic from commands ([#3568](https://github.com/redis/go-redis/pull/3568)) by [@dragneelfps](https://github.com/dragneelfps)
- Safety harness in `joinErrors` to prevent panic ([#3577](https://github.com/redis/go-redis/pull/3577)) by [@manisharma](https://github.com/manisharma)
## ⚡ Performance
- Connection state machine with race condition fixes ([#3559](https://github.com/redis/go-redis/pull/3559)) by [@ndyakov](https://github.com/ndyakov)
- Pool performance improvements: 47-67% faster Get/Put, 33% less memory, 50% fewer allocations ([#3565](https://github.com/redis/go-redis/pull/3565)) by [@ndyakov](https://github.com/ndyakov)
## 🧪 Testing & Infrastructure
- Updated to Redis 8.4.0 image ([#3603](https://github.com/redis/go-redis/pull/3603)) by [@ndyakov](https://github.com/ndyakov)
- Added Redis 8.4-RC1-pre to CI ([#3572](https://github.com/redis/go-redis/pull/3572)) by [@ndyakov](https://github.com/ndyakov)
- Refactored tests for idiomatic Go ([#3561](https://github.com/redis/go-redis/pull/3561), [#3562](https://github.com/redis/go-redis/pull/3562), [#3563](https://github.com/redis/go-redis/pull/3563)) by [@12ya](https://github.com/12ya)
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@12ya](https://github.com/12ya), [@ajax16384](https://github.com/ajax16384), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@destinyoooo](https://github.com/destinyoooo), [@dragneelfps](https://github.com/dragneelfps), [@htemelski-redis](https://github.com/htemelski-redis), [@manisharma](https://github.com/manisharma), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@pvragov](https://github.com/pvragov)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.16.0...v9.17.0
# 9.16.0 (2025-10-23)
## 🚀 Highlights
### Maintenance Notifications Support
This release introduces comprehensive support for Redis maintenance notifications, enabling applications to handle server maintenance events gracefully. The new `maintnotifications` package provides:
- **RESP3 Push Notifications**: Full support for Redis RESP3 protocol push notifications
- **Connection Handoff**: Automatic connection migration during server maintenance with configurable retry policies and circuit breakers
- **Graceful Degradation**: Configurable timeout relaxation during maintenance windows to prevent false failures
- **Event-Driven Architecture**: Background workers with on-demand scaling for efficient handoff processing
- **Production-Ready**: Comprehensive E2E testing framework and monitoring capabilities
For detailed usage examples and configuration options, see the [maintenance notifications documentation](maintnotifications/README.md).
## ✨ New Features
- **Trace Filtering**: Add support for filtering traces for specific commands, including pipeline operations and dial operations ([#3519](https://github.com/redis/go-redis/pull/3519), [#3550](https://github.com/redis/go-redis/pull/3550))
- New `TraceCmdFilter` option to selectively trace commands
- Reduces overhead by excluding high-frequency or low-value commands from traces
## 🐛 Bug Fixes
- **Pipeline Error Handling**: Fix issue where pipeline repeatedly sets the same error ([#3525](https://github.com/redis/go-redis/pull/3525))
- **Connection Pool**: Ensure re-authentication does not interfere with connection handoff operations ([#3547](https://github.com/redis/go-redis/pull/3547))
## 🔧 Improvements
- **Hash Commands**: Update hash command implementations ([#3523](https://github.com/redis/go-redis/pull/3523))
- **OpenTelemetry**: Use `metric.WithAttributeSet` to avoid unnecessary attribute copying in redisotel ([#3552](https://github.com/redis/go-redis/pull/3552))
## 📚 Documentation
- **Cluster Client**: Add explanation for why `MaxRetries` is disabled for `ClusterClient` ([#3551](https://github.com/redis/go-redis/pull/3551))
## 🧪 Testing & Infrastructure
- **E2E Testing**: Upgrade E2E testing framework with improved reliability and coverage ([#3541](https://github.com/redis/go-redis/pull/3541))
- **Release Process**: Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530))
## 📦 Dependencies
- Bump `rojopolis/spellcheck-github-actions` from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520))
- Bump `github/codeql-action` from 3 to 4 ([#3544](https://github.com/redis/go-redis/pull/3544))
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@Sovietaced](https://github.com/Sovietaced), [@Udhayarajan](https://github.com/Udhayarajan), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@Pika-Gopher](https://github.com/Pika-Gopher), [@cxljs](https://github.com/cxljs), [@huiyifyj](https://github.com/huiyifyj), [@omid-h70](https://github.com/omid-h70)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.14.0...v9.16.0
# 9.15.0 was accidentally released. Please use version 9.16.0 instead.
# 9.15.0-beta.3 (2025-09-26)
## Highlights
This beta release includes a pre-production version of processing push notifications and hitless upgrades.
# Changes
- chore: Update hash_commands.go ([#3523](https://github.com/redis/go-redis/pull/3523))
## 🚀 New Features
- feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418))
## 🐛 Bug Fixes
- fix: pipeline repeatedly sets the error ([#3525](https://github.com/redis/go-redis/pull/3525))
## 🧰 Maintenance
- chore(deps): bump rojopolis/spellcheck-github-actions from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520))
- feat(e2e-testing): maintnotifications e2e and refactor ([#3526](https://github.com/redis/go-redis/pull/3526))
- feat(tag.sh): Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@cxljs](https://github.com/cxljs), [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), and [@omid-h70](https://github.com/omid-h70)
# 9.15.0-beta.1 (2025-09-10)
## Highlights
This beta release includes a pre-production version of processing push notifications and hitless upgrades.
### Hitless Upgrades
Hitless upgrades is a major new feature that allows for zero-downtime upgrades in Redis clusters.
You can find more information in the [Hitless Upgrades documentation](https://github.com/redis/go-redis/tree/master/hitless).
# Changes
## 🚀 New Features
- [CAE-1088] & [CAE-1072] feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@ofekshenawa](https://github.com/ofekshenawa)
# 9.14.0 (2025-09-10)
## Highlights
- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510))
# Changes
## 🚀 New Features
- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510))
## 🐛 Bug Fixes
- fix: SetErr on Cmd if the command cannot be queued correctly in multi/exec ([#3509](https://github.com/redis/go-redis/pull/3509))
## 🧰 Maintenance
- Updates release drafter config to exclude dependabot ([#3511](https://github.com/redis/go-redis/pull/3511))
- chore(deps): bump actions/setup-go from 5 to 6 ([#3504](https://github.com/redis/go-redis/pull/3504))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@elena-kolevska](https://github.com/elena-kolevksa), [@htemelski-redis](https://github.com/htemelski-redis) and [@ndyakov](https://github.com/ndyakov)
# 9.13.0 (2025-09-03)
## Highlights
- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496))
- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470))
- Fixes on Read and Write buffer sizes and UniversalOptions
## Changes
- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496))
- fix(test): fix a timing issue in pubsub test ([#3498](https://github.com/redis/go-redis/pull/3498))
- Allow users to enable read-write splitting in failover mode. ([#3482](https://github.com/redis/go-redis/pull/3482))
- Set the read/write buffer size of the sentinel client to 4KiB ([#3476](https://github.com/redis/go-redis/pull/3476))
## 🚀 New Features
- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499))
- Support subscriptions against cluster slave nodes ([#3480](https://github.com/redis/go-redis/pull/3480))
- Add wait metrics to otel ([#3493](https://github.com/redis/go-redis/pull/3493))
- Clean failing timeout implementation ([#3472](https://github.com/redis/go-redis/pull/3472))
## 🐛 Bug Fixes
- Do not assume that all non-IP hosts are loopbacks ([#3085](https://github.com/redis/go-redis/pull/3085))
- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470))
## 🧰 Maintenance
- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499))
- fix(make test): Add default env in makefile ([#3491](https://github.com/redis/go-redis/pull/3491))
- Update the introduction to running tests in README.md ([#3495](https://github.com/redis/go-redis/pull/3495))
- test: Add comprehensive edge case tests for IncrByFloat command ([#3477](https://github.com/redis/go-redis/pull/3477))
- Set the default read/write buffer size of Redis connection to 32KiB ([#3483](https://github.com/redis/go-redis/pull/3483))
- Bumps test image to 8.2.1-pre ([#3478](https://github.com/redis/go-redis/pull/3478))
- fix UniversalOptions miss ReadBufferSize and WriteBufferSize options ([#3485](https://github.com/redis/go-redis/pull/3485))
- chore(deps): bump actions/checkout from 4 to 5 ([#3484](https://github.com/redis/go-redis/pull/3484))
- Removes dry run for stale issues policy ([#3471](https://github.com/redis/go-redis/pull/3471))
- Update otel metrics URL ([#3474](https://github.com/redis/go-redis/pull/3474))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@LINKIWI](https://github.com/LINKIWI), [@cxljs](https://github.com/cxljs), [@cybersmeashish](https://github.com/cybersmeashish), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@mwhooker](https://github.com/mwhooker), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@suever](https://github.com/suever)
# 9.12.1 (2025-08-11)
## 🚀 Highlights
In the last version (9.12.0) the client introduced bigger write and read buffer sized. The default value we set was 512KiB.
However, users reported that this is too big for most use cases and can lead to high memory usage.
In this version the default value is changed to 256KiB. The `README.md` was updated to reflect the
correct default value and include a note that the default value can be changed.
## 🐛 Bug Fixes
- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468))
## 🧰 Maintenance
- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468))
- chore: update & fix otel example ([#3466](https://github.com/redis/go-redis/pull/3466))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@ndyakov](https://github.com/ndyakov) and [@vmihailenco](https://github.com/vmihailenco)
# 9.12.0 (2025-08-05)
## 🚀 Highlights
- This release includes support for [Redis 8.2](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.2-release-notes/).
- Introduces an experimental Query Builders for `FTSearch`, `FTAggregate` and other search commands.
- Adds support for `EPSILON` option in `FT.VSIM`.
- Includes bug fixes and improvements contributed by the community related to ring and [redisotel](https://github.com/redis/go-redis/tree/master/extra/redisotel).
## Changes
- Improve stale issue workflow ([#3458](https://github.com/redis/go-redis/pull/3458))
- chore(ci): Add 8.2 rc2 pre build for CI ([#3459](https://github.com/redis/go-redis/pull/3459))
- Added new stream commands ([#3450](https://github.com/redis/go-redis/pull/3450))
- feat: Add "skip_verify" to Sentinel ([#3428](https://github.com/redis/go-redis/pull/3428))
- fix: `errors.Join` requires Go 1.20 or later ([#3442](https://github.com/redis/go-redis/pull/3442))
- DOC-4344 document quickstart examples ([#3426](https://github.com/redis/go-redis/pull/3426))
- feat(bitop): add support for the new bitop operations ([#3409](https://github.com/redis/go-redis/pull/3409))
## 🚀 New Features
- feat: recover addIdleConn may occur panic ([#2445](https://github.com/redis/go-redis/pull/2445))
- feat(ring): specify custom health check func via HeartbeatFn option ([#2940](https://github.com/redis/go-redis/pull/2940))
- Add Query Builder for RediSearch commands ([#3436](https://github.com/redis/go-redis/pull/3436))
- add configurable buffer sizes for Redis connections ([#3453](https://github.com/redis/go-redis/pull/3453))
- Add VAMANA vector type to RediSearch ([#3449](https://github.com/redis/go-redis/pull/3449))
- VSIM add `EPSILON` option ([#3454](https://github.com/redis/go-redis/pull/3454))
- Add closing support to otel metrics instrumentation ([#3444](https://github.com/redis/go-redis/pull/3444))
## 🐛 Bug Fixes
- fix(redisotel): fix buggy append in reportPoolStats ([#3122](https://github.com/redis/go-redis/pull/3122))
- fix(search): return results even if doc is empty ([#3457](https://github.com/redis/go-redis/pull/3457))
- [ISSUE-3402]: Ring.Pipelined return dial timeout error ([#3403](https://github.com/redis/go-redis/pull/3403))
## 🧰 Maintenance
- Merges stale issues jobs into one job with two steps ([#3463](https://github.com/redis/go-redis/pull/3463))
- improve code readability ([#3446](https://github.com/redis/go-redis/pull/3446))
- chore(release): 9.12.0-beta.1 ([#3460](https://github.com/redis/go-redis/pull/3460))
- DOC-5472 time series doc examples ([#3443](https://github.com/redis/go-redis/pull/3443))
- Add VAMANA compression algorithm tests ([#3461](https://github.com/redis/go-redis/pull/3461))
- bumped redis 8.2 version used in the CI/CD ([#3451](https://github.com/redis/go-redis/pull/3451))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@andy-stark-redis](https://github.com/andy-stark-redis), [@cxljs](https://github.com/cxljs), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@jouir](https://github.com/jouir), [@monkey92t](https://github.com/monkey92t), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@rokn](https://github.com/rokn), [@smnvdev](https://github.com/smnvdev), [@strobil](https://github.com/strobil) and [@wzy9607](https://github.com/wzy9607)
## New Contributors
* [@htemelski-redis](https://github.com/htemelski-redis) made their first contribution in [#3409](https://github.com/redis/go-redis/pull/3409)
* [@smnvdev](https://github.com/smnvdev) made their first contribution in [#3403](https://github.com/redis/go-redis/pull/3403)
* [@rokn](https://github.com/rokn) made their first contribution in [#3444](https://github.com/redis/go-redis/pull/3444)
# 9.11.0 (2025-06-24)
## 🚀 Highlights
Fixes TxPipeline to work correctly in cluster scenarios, allowing execution of commands
only in the same slot.
# Changes
## 🚀 New Features
- Set cluster slot for `scan` commands, rather than random ([#2623](https://github.com/redis/go-redis/pull/2623))
- Add CredentialsProvider field to UniversalOptions ([#2927](https://github.com/redis/go-redis/pull/2927))
- feat(redisotel): add WithCallerEnabled option ([#3415](https://github.com/redis/go-redis/pull/3415))
## 🐛 Bug Fixes
- fix(txpipeline): keyless commands should take the slot of the keyed ([#3411](https://github.com/redis/go-redis/pull/3411))
- fix(loading): cache the loaded flag for slave nodes ([#3410](https://github.com/redis/go-redis/pull/3410))
- fix(txpipeline): should return error on multi/exec on multiple slots ([#3408](https://github.com/redis/go-redis/pull/3408))
- fix: check if the shard exists to avoid returning nil ([#3396](https://github.com/redis/go-redis/pull/3396))
## 🧰 Maintenance
- feat: optimize connection pool waitTurn ([#3412](https://github.com/redis/go-redis/pull/3412))
- chore(ci): update CI redis builds ([#3407](https://github.com/redis/go-redis/pull/3407))
- chore: remove a redundant method from `Ring`, `Client` and `ClusterClient` ([#3401](https://github.com/redis/go-redis/pull/3401))
- test: refactor TestBasicCredentials using table-driven tests ([#3406](https://github.com/redis/go-redis/pull/3406))
- perf: reduce unnecessary memory allocation operations ([#3399](https://github.com/redis/go-redis/pull/3399))
- fix: insert entry during iterating over a map ([#3398](https://github.com/redis/go-redis/pull/3398))
- DOC-5229 probabilistic data type examples ([#3413](https://github.com/redis/go-redis/pull/3413))
- chore(deps): bump rojopolis/spellcheck-github-actions from 0.49.0 to 0.51.0 ([#3414](https://github.com/redis/go-redis/pull/3414))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@andy-stark-redis](https://github.com/andy-stark-redis), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@cxljs](https://github.com/cxljs), [@dcherubini](https://github.com/dcherubini), [@dependabot[bot]](https://github.com/apps/dependabot), [@iamamirsalehi](https://github.com/iamamirsalehi), [@ndyakov](https://github.com/ndyakov), [@pete-woods](https://github.com/pete-woods), [@twz915](https://github.com/twz915) and [dependabot[bot]](https://github.com/apps/dependabot)
# 9.10.0 (2025-06-06)
## 🚀 Highlights
`go-redis` now supports [vector sets](https://redis.io/docs/latest/develop/data-types/vector-sets/). This data type is marked
as "in preview" in Redis and its support in `go-redis` is marked as experimental. You can find examples in the documentation and
in the `doctests` folder.
# Changes
## 🚀 New Features
- feat: support vectorset ([#3375](https://github.com/redis/go-redis/pull/3375))
## 🧰 Maintenance
- Add the missing NewFloatSliceResult for testing ([#3393](https://github.com/redis/go-redis/pull/3393))
- DOC-5078 vector set examples ([#3394](https://github.com/redis/go-redis/pull/3394))
## Contributors
We'd like to thank all the contributors who worked on this release!
[@AndBobsYourUncle](https://github.com/AndBobsYourUncle), [@andy-stark-redis](https://github.com/andy-stark-redis), [@fukua95](https://github.com/fukua95) and [@ndyakov](https://github.com/ndyakov)
# 9.9.0 (2025-05-27)
## 🚀 Highlights
- **Token-based Authentication**: Added `StreamingCredentialsProvider` for dynamic credential updates (experimental)
- Can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) for Azure AD authentication
- **Connection Statistics**: Added connection waiting statistics for better monitoring
- **Failover Improvements**: Added `ParseFailoverURL` for easier failover configuration
- **Ring Client Enhancements**: Added shard access methods for better Pub/Sub management
## ✨ New Features
- Added `StreamingCredentialsProvider` for token-based authentication ([#3320](https://github.com/redis/go-redis/pull/3320))
- Supports dynamic credential updates
- Includes connection close hooks
- Note: Currently marked as experimental
- Added `ParseFailoverURL` for parsing failover URLs ([#3362](https://github.com/redis/go-redis/pull/3362))
- Added connection waiting statistics ([#2804](https://github.com/redis/go-redis/pull/2804))
- Added new utility functions:
- `ParseFloat` and `MustParseFloat` in public utils package ([#3371](https://github.com/redis/go-redis/pull/3371))
- Unit tests for `Atoi`, `ParseInt`, `ParseUint`, and `ParseFloat` ([#3377](https://github.com/redis/go-redis/pull/3377))
- Added Ring client shard access methods:
- `GetShardClients()` to retrieve all active shard clients
- `GetShardClientForKey(key string)` to get the shard client for a specific key ([#3388](https://github.com/redis/go-redis/pull/3388))
## 🐛 Bug Fixes
- Fixed routing reads to loading slave nodes ([#3370](https://github.com/redis/go-redis/pull/3370))
- Added support for nil lag in XINFO GROUPS ([#3369](https://github.com/redis/go-redis/pull/3369))
- Fixed pool acquisition timeout issues ([#3381](https://github.com/redis/go-redis/pull/3381))
- Optimized unnecessary copy operations ([#3376](https://github.com/redis/go-redis/pull/3376))
## 📚 Documentation
- Updated documentation for XINFO GROUPS with nil lag support ([#3369](https://github.com/redis/go-redis/pull/3369))
- Added package-level comments for new features
## ⚡ Performance and Reliability
- Optimized `ReplaceSpaces` function ([#3383](https://github.com/redis/go-redis/pull/3383))
- Set default value for `Options.Protocol` in `init()` ([#3387](https://github.com/redis/go-redis/pull/3387))
- Exported pool errors for public consumption ([#3380](https://github.com/redis/go-redis/pull/3380))
## 🔧 Dependencies and Infrastructure
- Updated Redis CI to version 8.0.1 ([#3372](https://github.com/redis/go-redis/pull/3372))
- Updated spellcheck GitHub Actions ([#3389](https://github.com/redis/go-redis/pull/3389))
- Removed unused parameters ([#3382](https://github.com/redis/go-redis/pull/3382), [#3384](https://github.com/redis/go-redis/pull/3384))
## 🧪 Testing
- Added unit tests for pool acquisition timeout ([#3381](https://github.com/redis/go-redis/pull/3381))
- Added unit tests for utility functions ([#3377](https://github.com/redis/go-redis/pull/3377))
## 👥 Contributors
We would like to thank all the contributors who made this release possible:
[@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@LINKIWI](https://github.com/LINKIWI), [@iamamirsalehi](https://github.com/iamamirsalehi), [@fukua95](https://github.com/fukua95), [@lzakharov](https://github.com/lzakharov), [@DengY11](https://github.com/DengY11)
## 📝 Changelog
For a complete list of changes, see the [full changelog](https://github.com/redis/go-redis/compare/v9.8.0...v9.9.0).
# 9.8.0 (2025-04-30)
## 🚀 Highlights
- **Redis 8 Support**: Full compatibility with Redis 8.0, including testing and CI integration
- **Enhanced Hash Operations**: Added support for new hash commands (`HGETDEL`, `HGETEX`, `HSETEX`) and `HSTRLEN` command
- **Search Improvements**: Enabled Search DIALECT 2 by default and added `CountOnly` argument for `FT.Search`
## ✨ New Features
- Added support for new hash commands: `HGETDEL`, `HGETEX`, `HSETEX` ([#3305](https://github.com/redis/go-redis/pull/3305))
- Added `HSTRLEN` command for hash operations ([#2843](https://github.com/redis/go-redis/pull/2843))
- Added `Do` method for raw query by single connection from `pool.Conn()` ([#3182](https://github.com/redis/go-redis/pull/3182))
- Prevent false-positive marshaling by treating zero time.Time as empty in isEmptyValue ([#3273](https://github.com/redis/go-redis/pull/3273))
- Added FailoverClusterClient support for Universal client ([#2794](https://github.com/redis/go-redis/pull/2794))
- Added support for cluster mode with `IsClusterMode` config parameter ([#3255](https://github.com/redis/go-redis/pull/3255))
- Added client name support in `HELLO` RESP handshake ([#3294](https://github.com/redis/go-redis/pull/3294))
- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213))
- Added read-only option for failover configurations ([#3281](https://github.com/redis/go-redis/pull/3281))
- Added `CountOnly` argument for `FT.Search` to use `LIMIT 0 0` ([#3338](https://github.com/redis/go-redis/pull/3338))
- Added `DB` option support in `NewFailoverClusterClient` ([#3342](https://github.com/redis/go-redis/pull/3342))
- Added `nil` check for the options when creating a client ([#3363](https://github.com/redis/go-redis/pull/3363))
## 🐛 Bug Fixes
- Fixed `PubSub` concurrency safety issues ([#3360](https://github.com/redis/go-redis/pull/3360))
- Fixed panic caused when argument is `nil` ([#3353](https://github.com/redis/go-redis/pull/3353))
- Improved error handling when fetching master node from sentinels ([#3349](https://github.com/redis/go-redis/pull/3349))
- Fixed connection pool timeout issues and increased retries ([#3298](https://github.com/redis/go-redis/pull/3298))
- Fixed context cancellation error leading to connection spikes on Primary instances ([#3190](https://github.com/redis/go-redis/pull/3190))
- Fixed RedisCluster client to consider `MASTERDOWN` a retriable error ([#3164](https://github.com/redis/go-redis/pull/3164))
- Fixed tracing to show complete commands instead of truncated versions ([#3290](https://github.com/redis/go-redis/pull/3290))
- Fixed OpenTelemetry instrumentation to prevent multiple span reporting ([#3168](https://github.com/redis/go-redis/pull/3168))
- Fixed `FT.Search` Limit argument and added `CountOnly` argument for limit 0 0 ([#3338](https://github.com/redis/go-redis/pull/3338))
- Fixed missing command in interface ([#3344](https://github.com/redis/go-redis/pull/3344))
- Fixed slot calculation for `COUNTKEYSINSLOT` command ([#3327](https://github.com/redis/go-redis/pull/3327))
- Updated PubSub implementation with correct context ([#3329](https://github.com/redis/go-redis/pull/3329))
## 📚 Documentation
- Added hash search examples ([#3357](https://github.com/redis/go-redis/pull/3357))
- Fixed documentation comments ([#3351](https://github.com/redis/go-redis/pull/3351))
- Added `CountOnly` search example ([#3345](https://github.com/redis/go-redis/pull/3345))
- Added examples for list commands: `LLEN`, `LPOP`, `LPUSH`, `LRANGE`, `RPOP`, `RPUSH` ([#3234](https://github.com/redis/go-redis/pull/3234))
- Added `SADD` and `SMEMBERS` command examples ([#3242](https://github.com/redis/go-redis/pull/3242))
- Updated `README.md` to use Redis Discord guild ([#3331](https://github.com/redis/go-redis/pull/3331))
- Updated `HExpire` command documentation ([#3355](https://github.com/redis/go-redis/pull/3355))
- Featured OpenTelemetry instrumentation more prominently ([#3316](https://github.com/redis/go-redis/pull/3316))
- Updated `README.md` with additional information ([#310ce55](https://github.com/redis/go-redis/commit/310ce55))
## ⚡ Performance and Reliability
- Bound connection pool background dials to configured dial timeout ([#3089](https://github.com/redis/go-redis/pull/3089))
- Ensured context isn't exhausted via concurrent query ([#3334](https://github.com/redis/go-redis/pull/3334))
## 🔧 Dependencies and Infrastructure
- Updated testing image to Redis 8.0-RC2 ([#3361](https://github.com/redis/go-redis/pull/3361))
- Enabled CI for Redis CE 8.0 ([#3274](https://github.com/redis/go-redis/pull/3274))
- Updated various dependencies:
- Bumped golangci/golangci-lint-action from 6.5.0 to 7.0.0 ([#3354](https://github.com/redis/go-redis/pull/3354))
- Bumped rojopolis/spellcheck-github-actions ([#3336](https://github.com/redis/go-redis/pull/3336))
- Bumped golang.org/x/net in example/otel ([#3308](https://github.com/redis/go-redis/pull/3308))
- Migrated golangci-lint configuration to v2 format ([#3354](https://github.com/redis/go-redis/pull/3354))
## ⚠️ Breaking Changes
- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213))
- Dropped RedisGears (Triggers and Functions) support ([#3321](https://github.com/redis/go-redis/pull/3321))
- Dropped FT.PROFILE command that was never enabled ([#3323](https://github.com/redis/go-redis/pull/3323))
## 🔒 Security
- Fixed network error handling on SETINFO (CVE-2025-29923) ([#3295](https://github.com/redis/go-redis/pull/3295))
## 🧪 Testing
- Added integration tests for Redis 8 behavior changes in Redis Search ([#3337](https://github.com/redis/go-redis/pull/3337))
- Added vector types INT8 and UINT8 tests ([#3299](https://github.com/redis/go-redis/pull/3299))
- Added test codes for search_commands.go ([#3285](https://github.com/redis/go-redis/pull/3285))
- Fixed example test sorting ([#3292](https://github.com/redis/go-redis/pull/3292))
## 👥 Contributors
We would like to thank all the contributors who made this release possible:
[@alexander-menshchikov](https://github.com/alexander-menshchikov), [@EXPEbdodla](https://github.com/EXPEbdodla), [@afti](https://github.com/afti), [@dmaier-redislabs](https://github.com/dmaier-redislabs), [@four_leaf_clover](https://github.com/four_leaf_clover), [@alohaglenn](https://github.com/alohaglenn), [@gh73962](https://github.com/gh73962), [@justinmir](https://github.com/justinmir), [@LINKIWI](https://github.com/LINKIWI), [@liushuangbill](https://github.com/liushuangbill), [@golang88](https://github.com/golang88), [@gnpaone](https://github.com/gnpaone), [@ndyakov](https://github.com/ndyakov), [@nikolaydubina](https://github.com/nikolaydubina), [@oleglacto](https://github.com/oleglacto), [@andy-stark-redis](https://github.com/andy-stark-redis), [@rodneyosodo](https://github.com/rodneyosodo), [@dependabot](https://github.com/dependabot), [@rfyiamcool](https://github.com/rfyiamcool), [@frankxjkuang](https://github.com/frankxjkuang), [@fukua95](https://github.com/fukua95), [@soleymani-milad](https://github.com/soleymani-milad), [@ofekshenawa](https://github.com/ofekshenawa), [@khasanovbi](https://github.com/khasanovbi)
# Old Changelog
## Unreleased
### Changed
* `go-redis` won't skip span creation if the parent spans is not recording. ([#2980](https://github.com/redis/go-redis/issues/2980))
Users can use the OpenTelemetry sampler to control the sampling behavior.
For instance, you can use the `ParentBased(NeverSample())` sampler from `go.opentelemetry.io/otel/sdk/trace` to keep
a similar behavior (drop orphan spans) of `go-redis` as before.
## [9.0.5](https://github.com/redis/go-redis/compare/v9.0.4...v9.0.5) (2023-05-29)
### Features
* Add ACL LOG ([#2536](https://github.com/redis/go-redis/issues/2536)) ([31ba855](https://github.com/redis/go-redis/commit/31ba855ddebc38fbcc69a75d9d4fb769417cf602))
* add field protocol to setupClusterQueryParams ([#2600](https://github.com/redis/go-redis/issues/2600)) ([840c25c](https://github.com/redis/go-redis/commit/840c25cb6f320501886a82a5e75f47b491e46fbe))
* add protocol option ([#2598](https://github.com/redis/go-redis/issues/2598)) ([3917988](https://github.com/redis/go-redis/commit/391798880cfb915c4660f6c3ba63e0c1a459e2af))
## [9.0.4](https://github.com/redis/go-redis/compare/v9.0.3...v9.0.4) (2023-05-01)
### Bug Fixes
* reader float parser ([#2513](https://github.com/redis/go-redis/issues/2513)) ([46f2450](https://github.com/redis/go-redis/commit/46f245075e6e3a8bd8471f9ca67ea95fd675e241))
### Features
* add client info command ([#2483](https://github.com/redis/go-redis/issues/2483)) ([b8c7317](https://github.com/redis/go-redis/commit/b8c7317cc6af444603731f7017c602347c0ba61e))
* no longer verify HELLO error messages ([#2515](https://github.com/redis/go-redis/issues/2515)) ([7b4f217](https://github.com/redis/go-redis/commit/7b4f2179cb5dba3d3c6b0c6f10db52b837c912c8))
* read the structure to increase the judgment of the omitempty op… ([#2529](https://github.com/redis/go-redis/issues/2529)) ([37c057b](https://github.com/redis/go-redis/commit/37c057b8e597c5e8a0e372337f6a8ad27f6030af))
## [9.0.3](https://github.com/redis/go-redis/compare/v9.0.2...v9.0.3) (2023-04-02)
### New Features
- feat(scan): scan time.Time sets the default decoding (#2413)
- Add support for CLUSTER LINKS command (#2504)
- Add support for acl dryrun command (#2502)
- Add support for COMMAND GETKEYS & COMMAND GETKEYSANDFLAGS (#2500)
- Add support for LCS Command (#2480)
- Add support for BZMPOP (#2456)
- Adding support for ZMPOP command (#2408)
- Add support for LMPOP (#2440)
- feat: remove pool unused fields (#2438)
- Expiretime and PExpireTime (#2426)
- Implement `FUNCTION` group of commands (#2475)
- feat(zadd): add ZAddLT and ZAddGT (#2429)
- Add: Support for COMMAND LIST command (#2491)
- Add support for BLMPOP (#2442)
- feat: check pipeline.Do to prevent confusion with Exec (#2517)
- Function stats, function kill, fcall and fcall_ro (#2486)
- feat: Add support for CLUSTER SHARDS command (#2507)
- feat(cmd): support for adding byte,bit parameters to the bitpos command (#2498)
### Fixed
- fix: eval api cmd.SetFirstKeyPos (#2501)
- fix: limit the number of connections created (#2441)
- fixed #2462 v9 continue support dragonfly, it's Hello command return "NOAUTH Authentication required" error (#2479)
- Fix for internal/hscan/structmap.go:89:23: undefined: reflect.Pointer (#2458)
- fix: group lag can be null (#2448)
### Maintenance
- Updating to the latest version of redis (#2508)
- Allowing for running tests on a port other than the fixed 6380 (#2466)
- redis 7.0.8 in tests (#2450)
- docs: Update redisotel example for v9 (#2425)
- chore: update go mod, Upgrade golang.org/x/net version to 0.7.0 (#2476)
- chore: add Chinese translation (#2436)
- chore(deps): bump github.com/bsm/gomega from 1.20.0 to 1.26.0 (#2421)
- chore(deps): bump github.com/bsm/ginkgo/v2 from 2.5.0 to 2.7.0 (#2420)
- chore(deps): bump actions/setup-go from 3 to 4 (#2495)
- docs: add instructions for the HSet api (#2503)
- docs: add reading lag field comment (#2451)
- test: update go mod before testing(go mod tidy) (#2423)
- docs: fix comment typo (#2505)
- test: remove testify (#2463)
- refactor: change ListElementCmd to KeyValuesCmd. (#2443)
- fix(appendArg): appendArg case special type (#2489)
## [9.0.2](https://github.com/redis/go-redis/compare/v9.0.1...v9.0.2) (2023-02-01)
### Features
* upgrade OpenTelemetry, use the new metrics API. ([#2410](https://github.com/redis/go-redis/issues/2410)) ([e29e42c](https://github.com/redis/go-redis/commit/e29e42cde2755ab910d04185025dc43ce6f59c65))
## v9 2023-01-30
### Breaking
- Changed Pipelines to not be thread-safe any more.
### Added
- Added support for [RESP3](https://github.com/antirez/RESP3/blob/master/spec.md) protocol. It was
contributed by @monkey92t who has done the majority of work in this release.
- Added `ContextTimeoutEnabled` option that controls whether the client respects context timeouts
and deadlines. See
[Redis Timeouts](https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts) for details.
- Added `ParseClusterURL` to parse URLs into `ClusterOptions`, for example,
`redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791`.
- Added metrics instrumentation using `redisotel.IstrumentMetrics`. See
[documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html)
- Added `redis.HasErrorPrefix` to help working with errors.
### Changed
- Removed asynchronous cancellation based on the context timeout. It was racy in v8 and is
completely gone in v9.
- Reworked hook interface and added `DialHook`.
- Replaced `redisotel.NewTracingHook` with `redisotel.InstrumentTracing`. See
[example](example/otel) and
[documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html).
- Replaced `*redis.Z` with `redis.Z` since it is small enough to be passed as value without making
an allocation.
- Renamed the option `MaxConnAge` to `ConnMaxLifetime`.
- Renamed the option `IdleTimeout` to `ConnMaxIdleTime`.
- Removed connection reaper in favor of `MaxIdleConns`.
- Removed `WithContext` since `context.Context` can be passed directly as an arg.
- Removed `Pipeline.Close` since there is no real need to explicitly manage pipeline resources and
it can be safely reused via `sync.Pool` etc. `Pipeline.Discard` is still available if you want to
reset commands for some reason.
### Fixed
- Improved and fixed pipeline retries.
- As usually, added support for more commands and fixed some bugs.
+146
View File
@@ -0,0 +1,146 @@
# Releasing
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 35 **Highlights** — the most user-facing, impactful changes.
4. Remove dependabot bumps and doc-only typo fixes from the lists.
5. Verify every PR has a contributor attribution and link.
6. Open a PR with just the release-notes change if you want review before
bumping versions, otherwise include it in the release PR below.
## 2. Bump versions and open the release PR
Create a release branch from `master`:
```shell
git checkout master && git pull --ff-only
git checkout -b release/vX.Y.Z
```
Run the release script on that branch:
```shell
TAG=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.
+116
View File
@@ -0,0 +1,116 @@
package redis
import "context"
type ACLCmdable interface {
ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd
ACLLog(ctx context.Context, count int64) *ACLLogCmd
ACLLogReset(ctx context.Context) *StatusCmd
ACLGenPass(ctx context.Context, bit int) *StringCmd
ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd
ACLDelUser(ctx context.Context, username string) *IntCmd
ACLUsers(ctx context.Context) *StringSliceCmd
ACLWhoAmI(ctx context.Context) *StringCmd
ACLList(ctx context.Context) *StringSliceCmd
ACLCat(ctx context.Context) *StringSliceCmd
ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd
}
type ACLCatArgs struct {
Category string
}
func (c cmdable) ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd {
args := make([]interface{}, 0, 3+len(command))
args = append(args, "acl", "dryrun", username)
args = append(args, command...)
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLLog(ctx context.Context, count int64) *ACLLogCmd {
args := make([]interface{}, 0, 3)
args = append(args, "acl", "log")
if count > 0 {
args = append(args, count)
}
cmd := NewACLLogCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLLogReset(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "acl", "log", "reset")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLDelUser(ctx context.Context, username string) *IntCmd {
cmd := NewIntCmd(ctx, "acl", "deluser", username)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd {
args := make([]interface{}, 3+len(rules))
args[0] = "acl"
args[1] = "setuser"
args[2] = username
for i, rule := range rules {
args[i+3] = rule
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLGenPass(ctx context.Context, bit int) *StringCmd {
args := make([]interface{}, 0, 3)
args = append(args, "acl", "genpass")
if bit > 0 {
args = append(args, bit)
}
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLUsers(ctx context.Context) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "acl", "users")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLWhoAmI(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "acl", "whoami")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLList(ctx context.Context) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "acl", "list")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLCat(ctx context.Context) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "acl", "cat")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd {
// if there is a category passed, build new cmd, if there isn't - use the ACLCat method
if options != nil && options.Category != "" {
cmd := NewStringSliceCmd(ctx, "acl", "cat", options.Category)
_ = c(ctx, cmd)
return cmd
}
return c.ACLCat(ctx)
}
+118
View File
@@ -0,0 +1,118 @@
package redis
import (
"context"
"errors"
"net"
"time"
"github.com/redis/go-redis/v9/internal/interfaces"
"github.com/redis/go-redis/v9/push"
)
// ErrInvalidCommand is returned when an invalid command is passed to ExecuteCommand.
var ErrInvalidCommand = errors.New("invalid command type")
// ErrInvalidPool is returned when the pool type is not supported.
var ErrInvalidPool = errors.New("invalid pool type")
// newClientAdapter creates a new client adapter for regular Redis clients.
func newClientAdapter(client *baseClient) interfaces.ClientInterface {
return &clientAdapter{client: client}
}
// clientAdapter adapts a Redis client to implement interfaces.ClientInterface.
type clientAdapter struct {
client *baseClient
}
// GetOptions returns the client options.
func (ca *clientAdapter) GetOptions() interfaces.OptionsInterface {
return &optionsAdapter{options: ca.client.opt}
}
// GetPushProcessor returns the client's push notification processor.
func (ca *clientAdapter) GetPushProcessor() interfaces.NotificationProcessor {
return &pushProcessorAdapter{processor: ca.client.pushProcessor}
}
// optionsAdapter adapts Redis options to implement interfaces.OptionsInterface.
type optionsAdapter struct {
options *Options
}
// GetReadTimeout returns the read timeout.
func (oa *optionsAdapter) GetReadTimeout() time.Duration {
return oa.options.ReadTimeout
}
// GetWriteTimeout returns the write timeout.
func (oa *optionsAdapter) GetWriteTimeout() time.Duration {
return oa.options.WriteTimeout
}
// GetNetwork returns the network type.
func (oa *optionsAdapter) GetNetwork() string {
return oa.options.Network
}
// GetAddr returns the connection address.
func (oa *optionsAdapter) GetAddr() string {
return oa.options.Addr
}
// GetNodeAddress returns the address of the Redis node as reported by the server.
// For cluster clients, this is the endpoint from CLUSTER SLOTS before any transformation.
// For standalone clients, this defaults to Addr.
func (oa *optionsAdapter) GetNodeAddress() string {
return oa.options.NodeAddress
}
// IsTLSEnabled returns true if TLS is enabled.
func (oa *optionsAdapter) IsTLSEnabled() bool {
return oa.options.TLSConfig != nil
}
// GetProtocol returns the protocol version.
func (oa *optionsAdapter) GetProtocol() int {
return oa.options.Protocol
}
// GetPoolSize returns the connection pool size.
func (oa *optionsAdapter) GetPoolSize() int {
return oa.options.PoolSize
}
// NewDialer returns a new dialer function for the connection.
func (oa *optionsAdapter) NewDialer() func(context.Context) (net.Conn, error) {
baseDialer := oa.options.NewDialer()
return func(ctx context.Context) (net.Conn, error) {
// Extract network and address from the options
network := oa.options.Network
addr := oa.options.Addr
return baseDialer(ctx, network, addr)
}
}
// pushProcessorAdapter adapts a push.NotificationProcessor to implement interfaces.NotificationProcessor.
type pushProcessorAdapter struct {
processor push.NotificationProcessor
}
// RegisterHandler registers a handler for a specific push notification name.
func (ppa *pushProcessorAdapter) RegisterHandler(pushNotificationName string, handler interface{}, protected bool) error {
if pushHandler, ok := handler.(push.NotificationHandler); ok {
return ppa.processor.RegisterHandler(pushNotificationName, pushHandler, protected)
}
return errors.New("handler must implement push.NotificationHandler")
}
// UnregisterHandler removes a handler for a specific push notification name.
func (ppa *pushProcessorAdapter) UnregisterHandler(pushNotificationName string) error {
return ppa.processor.UnregisterHandler(pushNotificationName)
}
// GetHandler returns the handler for a specific push notification name.
func (ppa *pushProcessorAdapter) GetHandler(pushNotificationName string) interface{} {
return ppa.processor.GetHandler(pushNotificationName)
}
+79
View File
@@ -0,0 +1,79 @@
// Package auth package provides authentication-related interfaces and types.
// It also includes a basic implementation of credentials using username and password.
package auth
// StreamingCredentialsProvider is an interface that defines the methods for a streaming credentials provider.
// It is used to provide credentials for authentication.
// The CredentialsListener is used to receive updates when the credentials change.
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.
// It is used to receive updates when the credentials change.
// The OnNext method is called when the credentials change.
// The OnError method is called when an error occurs while requesting the credentials.
type CredentialsListener interface {
OnNext(credentials Credentials)
OnError(err error)
}
// Credentials is an interface that defines the methods for credentials.
// It is used to provide the credentials for authentication.
type Credentials interface {
// BasicAuth returns the username and password for basic authentication.
BasicAuth() (username string, password string)
// RawCredentials returns the raw credentials as a string.
// This can be used to extract the username and password from the raw credentials or
// additional information if present in the token.
RawCredentials() string
}
type basicAuth struct {
username string
password string
}
// RawCredentials returns the raw credentials as a string.
func (b *basicAuth) RawCredentials() string {
return b.username + ":" + b.password
}
// BasicAuth returns the username and password for basic authentication.
func (b *basicAuth) BasicAuth() (username string, password string) {
return b.username, b.password
}
// NewBasicCredentials creates a new Credentials object from the given username and password.
func NewBasicCredentials(username, password string) Credentials {
return &basicAuth{
username: username,
password: password,
}
}
@@ -0,0 +1,47 @@
package auth
// ReAuthCredentialsListener is a struct that implements the CredentialsListener interface.
// It is used to re-authenticate the credentials when they are updated.
// It contains:
// - reAuth: a function that takes the new credentials and returns an error if any.
// - onErr: a function that takes an error and handles it.
type ReAuthCredentialsListener struct {
reAuth func(credentials Credentials) error
onErr func(err error)
}
// OnNext is called when the credentials are updated.
// It calls the reAuth function with the new credentials.
// If the reAuth function returns an error, it calls the onErr function with the error.
func (c *ReAuthCredentialsListener) OnNext(credentials Credentials) {
if c.reAuth == nil {
return
}
err := c.reAuth(credentials)
if err != nil {
c.OnError(err)
}
}
// OnError is called when an error occurs.
// It can be called from both the credentials provider and the reAuth function.
func (c *ReAuthCredentialsListener) OnError(err error) {
if c.onErr == nil {
return
}
c.onErr(err)
}
// NewReAuthCredentialsListener creates a new ReAuthCredentialsListener.
// Implements the auth.CredentialsListener interface.
func NewReAuthCredentialsListener(reAuth func(credentials Credentials) error, onErr func(err error)) *ReAuthCredentialsListener {
return &ReAuthCredentialsListener{
reAuth: reAuth,
onErr: onErr,
}
}
// Ensure ReAuthCredentialsListener implements the CredentialsListener interface.
var _ CredentialsListener = (*ReAuthCredentialsListener)(nil)
+197
View File
@@ -0,0 +1,197 @@
package redis
import (
"context"
"errors"
)
type BitMapCmdable interface {
GetBit(ctx context.Context, key string, offset int64) *IntCmd
SetBit(ctx context.Context, key string, offset int64, value int) *IntCmd
BitCount(ctx context.Context, key string, bitCount *BitCount) *IntCmd
BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd
BitOpNot(ctx context.Context, destKey string, key string) *IntCmd
BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd
BitPosSpan(ctx context.Context, key string, bit int8, start, end int64, span string) *IntCmd
BitField(ctx context.Context, key string, values ...interface{}) *IntSliceCmd
BitFieldRO(ctx context.Context, key string, values ...interface{}) *IntSliceCmd
}
func (c cmdable) GetBit(ctx context.Context, key string, offset int64) *IntCmd {
cmd := NewIntCmd(ctx, "getbit", key, offset)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) SetBit(ctx context.Context, key string, offset int64, value int) *IntCmd {
cmd := NewIntCmd(
ctx,
"setbit",
key,
offset,
value,
)
_ = c(ctx, cmd)
return cmd
}
type BitCount struct {
Start, End int64
Unit string // BYTE(default) | BIT
}
const BitCountIndexByte string = "BYTE"
const BitCountIndexBit string = "BIT"
func (c cmdable) BitCount(ctx context.Context, key string, bitCount *BitCount) *IntCmd {
args := make([]any, 2, 5)
args[0] = "bitcount"
args[1] = key
if bitCount != nil {
args = append(args, bitCount.Start, bitCount.End)
if bitCount.Unit != "" {
if bitCount.Unit != BitCountIndexByte && bitCount.Unit != BitCountIndexBit {
cmd := NewIntCmd(ctx)
cmd.SetErr(errors.New("redis: invalid bitcount index"))
return cmd
}
args = append(args, bitCount.Unit)
}
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) bitOp(ctx context.Context, op, destKey string, keys ...string) *IntCmd {
args := make([]interface{}, 3+len(keys))
args[0] = "bitop"
args[1] = op
args[2] = destKey
for i, key := range keys {
args[3+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BitOpAnd creates a new bitmap in which users are members of all given bitmaps
func (c cmdable) BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "and", destKey, keys...)
}
// BitOpOr creates a new bitmap in which users are member of at least one given bitmap
func (c cmdable) BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "or", destKey, keys...)
}
// BitOpXor creates a new bitmap in which users are the result of XORing all given bitmaps
func (c cmdable) BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "xor", destKey, keys...)
}
// BitOpNot creates a new bitmap in which users are not members of a given bitmap
func (c cmdable) BitOpNot(ctx context.Context, destKey string, key string) *IntCmd {
return c.bitOp(ctx, "not", destKey, key)
}
// BitOpDiff creates a new bitmap in which users are members of bitmap X but not of any of bitmaps Y1, Y2, …
// Introduced with Redis 8.2
func (c cmdable) BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "diff", destKey, keys...)
}
// BitOpDiff1 creates a new bitmap in which users are members of one or more of bitmaps Y1, Y2, … but not members of bitmap X
// Introduced with Redis 8.2
func (c cmdable) BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "diff1", destKey, keys...)
}
// BitOpAndOr creates a new bitmap in which users are members of bitmap X and also members of one or more of bitmaps Y1, Y2, …
// Introduced with Redis 8.2
func (c cmdable) BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "andor", destKey, keys...)
}
// BitOpOne creates a new bitmap in which users are members of exactly one of the given bitmaps
// Introduced with Redis 8.2
func (c cmdable) BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd {
return c.bitOp(ctx, "one", destKey, keys...)
}
// BitPos is an API before Redis version 7.0, cmd: bitpos key bit start end
// if you need the `byte | bit` parameter, please use `BitPosSpan`.
func (c cmdable) BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd {
args := make([]interface{}, 3+len(pos))
args[0] = "bitpos"
args[1] = key
args[2] = bit
switch len(pos) {
case 0:
case 1:
args[3] = pos[0]
case 2:
args[3] = pos[0]
args[4] = pos[1]
default:
cmd := NewIntCmd(ctx)
cmd.SetErr(errors.New("too many arguments"))
return cmd
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BitPosSpan supports the `byte | bit` parameters in redis version 7.0,
// the bitpos command defaults to using byte type for the `start-end` range,
// which means it counts in bytes from start to end. you can set the value
// of "span" to determine the type of `start-end`.
// span = "bit", cmd: bitpos key bit start end bit
// span = "byte", cmd: bitpos key bit start end byte
func (c cmdable) BitPosSpan(ctx context.Context, key string, bit int8, start, end int64, span string) *IntCmd {
cmd := NewIntCmd(ctx, "bitpos", key, bit, start, end, span)
_ = c(ctx, cmd)
return cmd
}
// BitField accepts multiple values:
// - BitField("set", "i1", "offset1", "value1","cmd2", "type2", "offset2", "value2")
// - BitField([]string{"cmd1", "type1", "offset1", "value1","cmd2", "type2", "offset2", "value2"})
// - BitField([]interface{}{"cmd1", "type1", "offset1", "value1","cmd2", "type2", "offset2", "value2"})
func (c cmdable) BitField(ctx context.Context, key string, values ...interface{}) *IntSliceCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "bitfield"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BitFieldRO - Read-only variant of the BITFIELD command.
// It is like the original BITFIELD but only accepts GET subcommand and can safely be used in read-only replicas.
// - BitFieldRO(ctx, key, "<Encoding0>", "<Offset0>", "<Encoding1>","<Offset1>")
func (c cmdable) BitFieldRO(ctx context.Context, key string, values ...interface{}) *IntSliceCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "BITFIELD_RO"
args[1] = key
if len(values)%2 != 0 {
c := NewIntSliceCmd(ctx)
c.SetErr(errors.New("BitFieldRO: invalid number of arguments, must be even"))
return c
}
for i := 0; i < len(values); i += 2 {
args = append(args, "GET", values[i], values[i+1])
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
+205
View File
@@ -0,0 +1,205 @@
package redis
import "context"
type ClusterCmdable interface {
ClusterMyShardID(ctx context.Context) *StringCmd
ClusterMyID(ctx context.Context) *StringCmd
ClusterSlots(ctx context.Context) *ClusterSlotsCmd
ClusterShards(ctx context.Context) *ClusterShardsCmd
ClusterLinks(ctx context.Context) *ClusterLinksCmd
ClusterNodes(ctx context.Context) *StringCmd
ClusterMeet(ctx context.Context, host, port string) *StatusCmd
ClusterForget(ctx context.Context, nodeID string) *StatusCmd
ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd
ClusterResetSoft(ctx context.Context) *StatusCmd
ClusterResetHard(ctx context.Context) *StatusCmd
ClusterInfo(ctx context.Context) *StringCmd
ClusterKeySlot(ctx context.Context, key string) *IntCmd
ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd
ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd
ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd
ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd
ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd
ClusterSaveConfig(ctx context.Context) *StatusCmd
ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd
ClusterFailover(ctx context.Context) *StatusCmd
ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd
ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd
ReadOnly(ctx context.Context) *StatusCmd
ReadWrite(ctx context.Context) *StatusCmd
}
func (c cmdable) ClusterMyShardID(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "cluster", "myshardid")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterMyID(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "cluster", "myid")
_ = c(ctx, cmd)
return cmd
}
// ClusterSlots returns the mapping of cluster slots to nodes.
//
// Deprecated: Use ClusterShards instead as of Redis 7.0.0.
func (c cmdable) ClusterSlots(ctx context.Context) *ClusterSlotsCmd {
cmd := NewClusterSlotsCmd(ctx, "cluster", "slots")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterShards(ctx context.Context) *ClusterShardsCmd {
cmd := NewClusterShardsCmd(ctx, "cluster", "shards")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterLinks(ctx context.Context) *ClusterLinksCmd {
cmd := NewClusterLinksCmd(ctx, "cluster", "links")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterNodes(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "cluster", "nodes")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterMeet(ctx context.Context, host, port string) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "meet", host, port)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterForget(ctx context.Context, nodeID string) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "forget", nodeID)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "replicate", nodeID)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterResetSoft(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "reset", "soft")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterResetHard(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "reset", "hard")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterInfo(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "cluster", "info")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterKeySlot(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "cluster", "keyslot", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", slot, count)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd {
cmd := NewIntCmd(ctx, "cluster", "count-failure-reports", nodeID)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd {
cmd := NewIntCmd(ctx, "cluster", "countkeysinslot", slot)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd {
args := make([]interface{}, 2+len(slots))
args[0] = "cluster"
args[1] = "delslots"
for i, slot := range slots {
args[2+i] = slot
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd {
size := max - min + 1
slots := make([]int, size)
for i := 0; i < size; i++ {
slots[i] = min + i
}
return c.ClusterDelSlots(ctx, slots...)
}
func (c cmdable) ClusterSaveConfig(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "saveconfig")
_ = c(ctx, cmd)
return cmd
}
// ClusterSlaves lists the replica nodes of a master node.
//
// Deprecated: Use ClusterReplicas instead as of Redis 5.0.0.
func (c cmdable) ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "cluster", "slaves", nodeID)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterFailover(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "cluster", "failover")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd {
args := make([]interface{}, 2+len(slots))
args[0] = "cluster"
args[1] = "addslots"
for i, num := range slots {
args[2+i] = num
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd {
size := max - min + 1
slots := make([]int, size)
for i := 0; i < size; i++ {
slots[i] = min + i
}
return c.ClusterAddSlots(ctx, slots...)
}
func (c cmdable) ReadOnly(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "readonly")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ReadWrite(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "readwrite")
_ = c(ctx, cmd)
return cmd
}
+8433
View File
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
package redis
import (
"context"
"strings"
"github.com/redis/go-redis/v9/internal/routing"
)
type (
module = string
commandName = string
)
var defaultPolicies = map[module]map[commandName]*routing.CommandPolicy{
"ft": {
"create": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"search": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"aggregate": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"dictadd": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"dictdump": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"dictdel": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"suglen": {
Request: routing.ReqDefault,
Response: routing.RespDefaultHashSlot,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"cursor": {
Request: routing.ReqSpecial,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"sugadd": {
Request: routing.ReqDefault,
Response: routing.RespDefaultHashSlot,
},
"sugget": {
Request: routing.ReqDefault,
Response: routing.RespDefaultHashSlot,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"sugdel": {
Request: routing.ReqDefault,
Response: routing.RespDefaultHashSlot,
},
"spellcheck": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"explain": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"explaincli": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"aliasadd": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"aliasupdate": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"aliasdel": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"info": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"tagvals": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"syndump": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"synupdate": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"profile": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"alter": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"dropindex": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
"drop": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
},
},
}
type CommandInfoResolveFunc func(ctx context.Context, cmd Cmder) *routing.CommandPolicy
type commandInfoResolver struct {
resolveFunc CommandInfoResolveFunc
fallBackResolver *commandInfoResolver
}
func NewCommandInfoResolver(resolveFunc CommandInfoResolveFunc) *commandInfoResolver {
return &commandInfoResolver{
resolveFunc: resolveFunc,
}
}
func NewDefaultCommandPolicyResolver() *commandInfoResolver {
return NewCommandInfoResolver(func(ctx context.Context, cmd Cmder) *routing.CommandPolicy {
module := "core"
command := cmd.Name()
cmdParts := strings.Split(command, ".")
if len(cmdParts) == 2 {
module = cmdParts[0]
command = cmdParts[1]
}
if policy, ok := defaultPolicies[module][command]; ok {
return policy
}
return nil
})
}
func (r *commandInfoResolver) GetCommandPolicy(ctx context.Context, cmd Cmder) *routing.CommandPolicy {
if r.resolveFunc == nil {
return nil
}
policy := r.resolveFunc(ctx, cmd)
if policy != nil {
return policy
}
if r.fallBackResolver != nil {
return r.fallBackResolver.GetCommandPolicy(ctx, cmd)
}
return nil
}
func (r *commandInfoResolver) SetFallbackResolver(fallbackResolver *commandInfoResolver) {
r.fallBackResolver = fallbackResolver
}
+819
View File
@@ -0,0 +1,819 @@
package redis
import (
"context"
"encoding"
"errors"
"fmt"
"io"
"net"
"reflect"
"runtime"
"strings"
"time"
"github.com/redis/go-redis/v9/internal"
)
// 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.
// For example:
//
// rdb.Set(ctx, key, value, redis.KeepTTL)
const KeepTTL = -1
func usePrecise(dur time.Duration) bool {
return dur < time.Second || dur%time.Second != 0
}
func formatMs(ctx context.Context, dur time.Duration) int64 {
if dur > 0 && dur < time.Millisecond {
internal.Logger.Printf(
ctx,
"specified duration is %s, but minimal supported value is %s - truncating to 1ms",
dur, time.Millisecond,
)
return 1
}
return int64(dur / time.Millisecond)
}
func formatSec(ctx context.Context, dur time.Duration) int64 {
if dur > 0 && dur < time.Second {
internal.Logger.Printf(
ctx,
"specified duration is %s, but minimal supported value is %s - truncating to 1s",
dur, time.Second,
)
return 1
}
return int64(dur / time.Second)
}
func appendArgs(dst, src []interface{}) []interface{} {
if len(src) == 1 {
return appendArg(dst, src[0])
}
if cap(dst) < len(dst)+len(src) {
newDst := make([]interface{}, len(dst), len(dst)+len(src))
copy(newDst, dst)
dst = newDst
}
dst = append(dst, src...)
return dst
}
func appendArg(dst []interface{}, arg interface{}) []interface{} {
switch arg := arg.(type) {
case []string:
for _, s := range arg {
dst = append(dst, s)
}
return dst
case []interface{}:
dst = append(dst, arg...)
return dst
case map[string]interface{}:
for k, v := range arg {
dst = append(dst, k, v)
}
return dst
case map[string]string:
for k, v := range arg {
dst = append(dst, k, v)
}
return dst
case time.Time, time.Duration, encoding.BinaryMarshaler, net.IP:
return append(dst, arg)
case nil:
return dst
default:
// scan struct field
v := reflect.ValueOf(arg)
if v.Type().Kind() == reflect.Ptr {
if v.IsNil() {
// error: arg is not a valid object
return dst
}
v = v.Elem()
}
if v.Type().Kind() == reflect.Struct {
return appendStructField(dst, v)
}
return append(dst, arg)
}
}
// appendStructField appends the field and value held by the structure v to dst, and returns the appended dst.
func appendStructField(dst []interface{}, v reflect.Value) []interface{} {
typ := v.Type()
for i := 0; i < typ.NumField(); i++ {
tag := typ.Field(i).Tag.Get("redis")
if tag == "" || tag == "-" {
continue
}
name, opt, _ := strings.Cut(tag, ",")
if name == "" {
continue
}
field := v.Field(i)
// miss field
if omitEmpty(opt) && isEmptyValue(field) {
continue
}
if field.CanInterface() {
dst = append(dst, name, field.Interface())
}
}
return dst
}
func omitEmpty(opt string) bool {
for opt != "" {
var name string
name, opt, _ = strings.Cut(opt, ",")
if name == "omitempty" {
return true
}
}
return false
}
func isEmptyValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
return v.Len() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Pointer:
return v.IsNil()
case reflect.Struct:
if v.Type() == reflect.TypeOf(time.Time{}) {
return v.IsZero()
}
// Only supports the struct time.Time,
// subsequent iterations will follow the func Scan support decoder.
}
return false
}
type Cmdable interface {
Pipeline() Pipeliner
Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error)
TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error)
TxPipeline() Pipeliner
Command(ctx context.Context) *CommandsInfoCmd
CommandList(ctx context.Context, filter *FilterBy) *StringSliceCmd
CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd
CommandGetKeysAndFlags(ctx context.Context, commands ...interface{}) *KeyFlagsCmd
ClientGetName(ctx context.Context) *StringCmd
Echo(ctx context.Context, message interface{}) *StringCmd
Ping(ctx context.Context) *StatusCmd
Quit(ctx context.Context) *StatusCmd
Unlink(ctx context.Context, keys ...string) *IntCmd
BgRewriteAOF(ctx context.Context) *StatusCmd
BgSave(ctx context.Context) *StatusCmd
ClientKill(ctx context.Context, ipPort string) *StatusCmd
ClientKillByFilter(ctx context.Context, keys ...string) *IntCmd
ClientList(ctx context.Context) *StringCmd
ClientInfo(ctx context.Context) *ClientInfoCmd
ClientPause(ctx context.Context, dur time.Duration) *BoolCmd
ClientUnpause(ctx context.Context) *BoolCmd
ClientID(ctx context.Context) *IntCmd
ClientUnblock(ctx context.Context, id int64) *IntCmd
ClientUnblockWithError(ctx context.Context, id int64) *IntCmd
ClientMaintNotifications(ctx context.Context, enabled bool, endpointType string) *StatusCmd
ConfigGet(ctx context.Context, parameter string) *MapStringStringCmd
ConfigResetStat(ctx context.Context) *StatusCmd
ConfigSet(ctx context.Context, parameter, value string) *StatusCmd
ConfigRewrite(ctx context.Context) *StatusCmd
DBSize(ctx context.Context) *IntCmd
FlushAll(ctx context.Context) *StatusCmd
FlushAllAsync(ctx context.Context) *StatusCmd
FlushDB(ctx context.Context) *StatusCmd
FlushDBAsync(ctx context.Context) *StatusCmd
Info(ctx context.Context, section ...string) *StringCmd
LastSave(ctx context.Context) *IntCmd
Save(ctx context.Context) *StatusCmd
Shutdown(ctx context.Context) *StatusCmd
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
Time(ctx context.Context) *TimeCmd
DebugObject(ctx context.Context, key string) *StringCmd
MemoryUsage(ctx context.Context, key string, samples ...int) *IntCmd
Latency(ctx context.Context) *LatencyCmd
LatencyReset(ctx context.Context, events ...interface{}) *StatusCmd
ModuleLoadex(ctx context.Context, conf *ModuleLoadexConfig) *StringCmd
ACLCmdable
BitMapCmdable
ClusterCmdable
GenericCmdable
GeoCmdable
HashCmdable
HyperLogLogCmdable
ListCmdable
ProbabilisticCmdable
PubSubCmdable
ScriptingFunctionsCmdable
SearchCmdable
SetCmdable
SortedSetCmdable
StringCmdable
StreamCmdable
TimeseriesCmdable
JSONCmdable
VectorSetCmdable
}
type StatefulCmdable interface {
Cmdable
Auth(ctx context.Context, password string) *StatusCmd
AuthACL(ctx context.Context, username, password string) *StatusCmd
Select(ctx context.Context, index int) *StatusCmd
SwapDB(ctx context.Context, index1, index2 int) *StatusCmd
ClientSetName(ctx context.Context, name string) *BoolCmd
ClientSetInfo(ctx context.Context, info LibraryInfo) *StatusCmd
Hello(ctx context.Context, ver int, username, password, clientName string) *MapStringInterfaceCmd
}
var (
_ Cmdable = (*Client)(nil)
_ Cmdable = (*Tx)(nil)
_ Cmdable = (*Ring)(nil)
_ Cmdable = (*ClusterClient)(nil)
_ Cmdable = (*Pipeline)(nil)
)
type cmdable func(ctx context.Context, cmd Cmder) error
type statefulCmdable func(ctx context.Context, cmd Cmder) error
//------------------------------------------------------------------------------
func (c statefulCmdable) Auth(ctx context.Context, password string) *StatusCmd {
cmd := NewStatusCmd(ctx, "auth", password)
_ = c(ctx, cmd)
return cmd
}
// AuthACL Perform an AUTH command, using the given user and pass.
// Should be used to authenticate the current connection with one of the connections defined in the ACL list
// when connecting to a Redis 6.0 instance, or greater, that is using the Redis ACL system.
func (c statefulCmdable) AuthACL(ctx context.Context, username, password string) *StatusCmd {
cmd := NewStatusCmd(ctx, "auth", username, password)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Wait(ctx context.Context, numSlaves int, timeout time.Duration) *IntCmd {
cmd := NewIntCmd(ctx, "wait", numSlaves, int(timeout/time.Millisecond))
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) WaitAOF(ctx context.Context, numLocal, numSlaves int, timeout time.Duration) *IntCmd {
cmd := NewIntCmd(ctx, "waitAOF", numLocal, numSlaves, int(timeout/time.Millisecond))
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c statefulCmdable) Select(ctx context.Context, index int) *StatusCmd {
cmd := NewStatusCmd(ctx, "select", index)
_ = c(ctx, cmd)
return cmd
}
func (c statefulCmdable) SwapDB(ctx context.Context, index1, index2 int) *StatusCmd {
cmd := NewStatusCmd(ctx, "swapdb", index1, index2)
_ = c(ctx, cmd)
return cmd
}
// ClientSetName assigns a name to the connection.
func (c statefulCmdable) ClientSetName(ctx context.Context, name string) *BoolCmd {
cmd := NewBoolCmd(ctx, "client", "setname", name)
_ = c(ctx, cmd)
return cmd
}
// ClientSetInfo sends a CLIENT SETINFO command with the provided info.
func (c statefulCmdable) ClientSetInfo(ctx context.Context, info LibraryInfo) *StatusCmd {
err := info.Validate()
if err != nil {
panic(err.Error())
}
var cmd *StatusCmd
if info.LibName != nil {
libName := fmt.Sprintf("go-redis(%s,%s)", *info.LibName, internal.ReplaceSpaces(runtime.Version()))
cmd = NewStatusCmd(ctx, "client", "setinfo", "LIB-NAME", libName)
} else {
cmd = NewStatusCmd(ctx, "client", "setinfo", "LIB-VER", *info.LibVer)
}
_ = c(ctx, cmd)
return cmd
}
// Validate checks if only one field in the struct is non-nil.
func (info LibraryInfo) Validate() error {
if info.LibName != nil && info.LibVer != nil {
return errors.New("both LibName and LibVer cannot be set at the same time")
}
if info.LibName == nil && info.LibVer == nil {
return errors.New("at least one of LibName and LibVer should be set")
}
return nil
}
// Hello sets the resp protocol used.
func (c statefulCmdable) Hello(ctx context.Context,
ver int, username, password, clientName string,
) *MapStringInterfaceCmd {
args := make([]interface{}, 0, 7)
args = append(args, "hello", ver)
if password != "" {
if username != "" {
args = append(args, "auth", username, password)
} else {
args = append(args, "auth", "default", password)
}
}
if clientName != "" {
args = append(args, "setname", clientName)
}
cmd := NewMapStringInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
//------------------------------------------------------------------------------
func (c cmdable) Command(ctx context.Context) *CommandsInfoCmd {
cmd := NewCommandsInfoCmd(ctx, "command")
_ = c(ctx, cmd)
return cmd
}
// FilterBy is used for the `CommandList` command parameter.
type FilterBy struct {
Module string
ACLCat string
Pattern string
}
func (c cmdable) CommandList(ctx context.Context, filter *FilterBy) *StringSliceCmd {
args := make([]interface{}, 0, 5)
args = append(args, "command", "list")
if filter != nil {
if filter.Module != "" {
args = append(args, "filterby", "module", filter.Module)
} else if filter.ACLCat != "" {
args = append(args, "filterby", "aclcat", filter.ACLCat)
} else if filter.Pattern != "" {
args = append(args, "filterby", "pattern", filter.Pattern)
}
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd {
args := make([]interface{}, 2+len(commands))
args[0] = "command"
args[1] = "getkeys"
copy(args[2:], commands)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) CommandGetKeysAndFlags(ctx context.Context, commands ...interface{}) *KeyFlagsCmd {
args := make([]interface{}, 2+len(commands))
args[0] = "command"
args[1] = "getkeysandflags"
copy(args[2:], commands)
cmd := NewKeyFlagsCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ClientGetName returns the name of the connection.
func (c cmdable) ClientGetName(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "client", "getname")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Echo(ctx context.Context, message interface{}) *StringCmd {
cmd := NewStringCmd(ctx, "echo", message)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Ping(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "ping")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Do(ctx context.Context, args ...interface{}) *Cmd {
cmd := NewCmd(ctx, args...)
_ = c(ctx, 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.
func (c cmdable) Quit(_ context.Context) *StatusCmd {
panic("not implemented")
}
//------------------------------------------------------------------------------
func (c cmdable) BgRewriteAOF(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "bgrewriteaof")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) BgSave(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "bgsave")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientKill(ctx context.Context, ipPort string) *StatusCmd {
cmd := NewStatusCmd(ctx, "client", "kill", ipPort)
_ = c(ctx, cmd)
return cmd
}
// ClientKillByFilter is new style syntax, while the ClientKill is old
//
// CLIENT KILL <option> [value] ... <option> [value]
func (c cmdable) ClientKillByFilter(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "client"
args[1] = "kill"
for i, key := range keys {
args[2+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientList(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "client", "list")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientPause(ctx context.Context, dur time.Duration) *BoolCmd {
cmd := NewBoolCmd(ctx, "client", "pause", formatMs(ctx, dur))
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientUnpause(ctx context.Context) *BoolCmd {
cmd := NewBoolCmd(ctx, "client", "unpause")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientID(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "client", "id")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientUnblock(ctx context.Context, id int64) *IntCmd {
cmd := NewIntCmd(ctx, "client", "unblock", id)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientUnblockWithError(ctx context.Context, id int64) *IntCmd {
cmd := NewIntCmd(ctx, "client", "unblock", id, "error")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ClientInfo(ctx context.Context) *ClientInfoCmd {
cmd := NewClientInfoCmd(ctx, "client", "info")
_ = c(ctx, cmd)
return cmd
}
// ClientMaintNotifications enables or disables maintenance notifications for maintenance upgrades.
// When enabled, the client will receive push notifications about Redis maintenance events.
func (c cmdable) ClientMaintNotifications(ctx context.Context, enabled bool, endpointType string) *StatusCmd {
args := []interface{}{"client", "maint_notifications"}
if enabled {
if endpointType == "" {
endpointType = "none"
}
args = append(args, "on", "moving-endpoint-type", endpointType)
} else {
args = append(args, "off")
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ------------------------------------------------------------------------------------------------
func (c cmdable) ConfigGet(ctx context.Context, parameter string) *MapStringStringCmd {
cmd := NewMapStringStringCmd(ctx, "config", "get", parameter)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ConfigResetStat(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "config", "resetstat")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ConfigSet(ctx context.Context, parameter, value string) *StatusCmd {
cmd := NewStatusCmd(ctx, "config", "set", parameter, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ConfigRewrite(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "config", "rewrite")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) DBSize(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "dbsize")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FlushAll(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "flushall")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FlushAllAsync(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "flushall", "async")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FlushDB(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "flushdb")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FlushDBAsync(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "flushdb", "async")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Info(ctx context.Context, sections ...string) *StringCmd {
args := make([]interface{}, 1+len(sections))
args[0] = "info"
for i, section := range sections {
args[i+1] = section
}
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) InfoMap(ctx context.Context, sections ...string) *InfoCmd {
args := make([]interface{}, 1+len(sections))
args[0] = "info"
for i, section := range sections {
args[i+1] = section
}
cmd := NewInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LastSave(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "lastsave")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Save(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "save")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) shutdown(ctx context.Context, modifier string) *StatusCmd {
var args []interface{}
if modifier == "" {
args = []interface{}{"shutdown"}
} else {
args = []interface{}{"shutdown", modifier}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
if err := cmd.Err(); err != nil {
if err == io.EOF {
// Server quit as expected.
cmd.err = nil
}
} else {
// Server did not quit. String reply contains the reason.
cmd.err = errors.New(cmd.val)
cmd.val = ""
}
return cmd
}
func (c cmdable) Shutdown(ctx context.Context) *StatusCmd {
return c.shutdown(ctx, "")
}
func (c cmdable) ShutdownSave(ctx context.Context) *StatusCmd {
return c.shutdown(ctx, "save")
}
func (c cmdable) ShutdownNoSave(ctx context.Context) *StatusCmd {
return c.shutdown(ctx, "nosave")
}
// SlaveOf sets a Redis server as a replica of another, or promotes it to being a master.
//
// Deprecated: Use ReplicaOf instead as of Redis 5.0.0.
func (c cmdable) SlaveOf(ctx context.Context, host, port string) *StatusCmd {
cmd := NewStatusCmd(ctx, "slaveof", host, port)
_ = c(ctx, cmd)
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)
return cmd
}
func (c cmdable) SlowLogLen(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "slowlog", "len")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) SlowLogReset(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "slowlog", "reset")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Latency(ctx context.Context) *LatencyCmd {
cmd := NewLatencyCmd(ctx, "latency", "latest")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LatencyReset(ctx context.Context, events ...interface{}) *StatusCmd {
args := make([]interface{}, 2+len(events))
args[0] = "latency"
args[1] = "reset"
copy(args[2:], events)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Sync(_ context.Context) {
panic("not implemented")
}
func (c cmdable) Time(ctx context.Context) *TimeCmd {
cmd := NewTimeCmd(ctx, "time")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) DebugObject(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "debug", "object", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) MemoryUsage(ctx context.Context, key string, samples ...int) *IntCmd {
args := []interface{}{"memory", "usage", key}
if len(samples) > 0 {
if len(samples) != 1 {
cmd := NewIntCmd(ctx)
cmd.SetErr(errors.New("MemoryUsage expects single sample count"))
return cmd
}
args = append(args, "SAMPLES", samples[0])
}
cmd := NewIntCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
//------------------------------------------------------------------------------
// ModuleLoadexConfig struct is used to specify the arguments for the MODULE LOADEX command of redis.
// `MODULE LOADEX path [CONFIG name value [CONFIG name value ...]] [ARGS args [args ...]]`
type ModuleLoadexConfig struct {
Path string
Conf map[string]interface{}
Args []interface{}
}
func (c *ModuleLoadexConfig) toArgs() []interface{} {
args := make([]interface{}, 3, 3+len(c.Conf)*3+len(c.Args)*2)
args[0] = "MODULE"
args[1] = "LOADEX"
args[2] = c.Path
for k, v := range c.Conf {
args = append(args, "CONFIG", k, v)
}
for _, arg := range c.Args {
args = append(args, "ARGS", arg)
}
return args
}
// ModuleLoadex Redis `MODULE LOADEX path [CONFIG name value [CONFIG name value ...]] [ARGS args [args ...]]` command.
func (c cmdable) ModuleLoadex(ctx context.Context, conf *ModuleLoadexConfig) *StringCmd {
cmd := NewStringCmd(ctx, conf.toArgs()...)
_ = c(ctx, cmd)
return cmd
}
/*
Monitor - represents a Redis MONITOR command, allowing the user to capture
and process all commands sent to a Redis server. This mimics the behavior of
MONITOR in the redis-cli.
Notes:
- Using MONITOR blocks the connection to the server for itself. It needs a dedicated connection
- The user should create a channel of type string
- This runs concurrently in the background. Trigger via the Start and Stop functions
See further: Redis MONITOR command: https://redis.io/commands/monitor
*/
func (c cmdable) Monitor(ctx context.Context, ch chan string) *MonitorCmd {
cmd := newMonitorCmd(ctx, ch)
_ = c(ctx, cmd)
return cmd
}
+39
View File
@@ -0,0 +1,39 @@
package redis
import (
"time"
"github.com/redis/go-redis/v9/internal"
)
// DialRetryBackoffConstant returns a dial retry backoff function that always returns d.
// attempt is 0-based: attempt=0 is the delay after the 1st failed dial.
func DialRetryBackoffConstant(d time.Duration) func(attempt int) time.Duration {
if d < 0 {
d = 0
}
return func(int) time.Duration { return d }
}
// DialRetryBackoffExponential returns a dial retry backoff function that uses exponential
// backoff with jitter and a cap, using internal.RetryBackoff.
//
// attempt is 0-based: attempt=0 is the delay after the 1st failed dial.
func DialRetryBackoffExponential(minBackoff, maxBackoff time.Duration) func(attempt int) time.Duration {
if minBackoff < 0 {
minBackoff = 0
}
if maxBackoff < 0 {
maxBackoff = 0
}
if minBackoff > maxBackoff {
minBackoff = maxBackoff
}
return func(attempt int) time.Duration {
// internal.RetryBackoff expects retry >= 0.
if attempt < 0 {
attempt = 0
}
return internal.RetryBackoff(attempt, minBackoff, maxBackoff)
}
}
+4
View File
@@ -0,0 +1,4 @@
/*
Package redis implements a Redis client.
*/
package redis
+176
View File
@@ -0,0 +1,176 @@
---
x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-m02}
services:
redis:
image: *default-image
platform: linux/amd64
container_name: redis-standalone
environment:
- TLS_ENABLED=yes
- TLS_CLIENT_CNS=testcertuser
- TLS_AUTH_CLIENTS_USER=CN
- REDIS_CLUSTER=no
- PORT=6379
- TLS_PORT=6666
command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""}
ports:
- 6379:6379
- 6666:6666 # TLS port
volumes:
- "./dockers/standalone:/redis/work"
profiles:
- standalone
- sentinel
- all-stack
- all
- e2e
osscluster:
image: *default-image
platform: linux/amd64
container_name: redis-osscluster
environment:
- NODES=6
- PORT=16600
command: "--cluster-enabled yes"
ports:
- "16600-16605:16600-16605"
volumes:
- "./dockers/osscluster:/redis/work"
profiles:
- cluster
- all-stack
- all
cae-resp-proxy:
image: redislabs/client-resp-proxy:latest
container_name: cae-resp-proxy
environment:
- TARGET_HOST=redis
- TARGET_PORT=6379
- LISTEN_PORT=17000,17001,17002,17003 # 4 proxy nodes: initially show 3, swap in 4th during SMIGRATED
- LISTEN_HOST=0.0.0.0
- API_PORT=3000
- DEFAULT_INTERCEPTORS=cluster,hitless
ports:
- "17000:17000" # Proxy node 1 (host:container)
- "17001:17001" # Proxy node 2 (host:container)
- "17002:17002" # Proxy node 3 (host:container)
- "17003:17003" # Proxy node 4 (host:container) - hidden initially, swapped in during SMIGRATED
- "18100:3000" # HTTP API port (host:container)
depends_on:
- redis
profiles:
- e2e
- all
proxy-fault-injector:
build:
context: .
dockerfile: maintnotifications/e2e/cmd/proxy-fi-server/Dockerfile
container_name: proxy-fault-injector
ports:
- "15000:5000" # Fault injector API port (host:container)
depends_on:
- cae-resp-proxy
environment:
- PROXY_API_URL=http://cae-resp-proxy:3000
profiles:
- e2e
- all
osscluster-tls:
image: *default-image
platform: linux/amd64
container_name: redis-osscluster-tls
environment:
- NODES=6
- PORT=6430
- TLS_PORT=5430
- TLS_ENABLED=yes
- TLS_CLIENT_CNS=testcertuser
- TLS_AUTH_CLIENTS_USER=CN
- REDIS_CLUSTER=yes
- REPLICAS=1
command: "--tls-auth-clients optional --cluster-announce-ip 127.0.0.1"
ports:
- "6430-6435:6430-6435" # Regular ports
- "5430-5435:5430-5435" # TLS ports (set via TLS_PORT env var)
- "16430-16435:16430-16435" # Cluster bus ports (PORT + 10000)
volumes:
- "./dockers/osscluster-tls:/redis/work"
profiles:
- cluster-tls
- all
sentinel-cluster:
image: *default-image
platform: linux/amd64
container_name: redis-sentinel-cluster
network_mode: "host"
environment:
- NODES=3
- TLS_ENABLED=yes
- TLS_CLIENT_CNS=testcertuser
- TLS_AUTH_CLIENTS_USER=CN
- REDIS_CLUSTER=no
- PORT=9121
command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""}
#ports:
# - "9121-9123:9121-9123"
volumes:
- "./dockers/sentinel-cluster:/redis/work"
profiles:
- sentinel
- all-stack
- all
sentinel:
image: *default-image
platform: linux/amd64
container_name: redis-sentinel
depends_on:
- sentinel-cluster
environment:
- NODES=3
- REDIS_CLUSTER=no
- PORT=26379
command: ${REDIS_EXTRA_ARGS:---sentinel}
network_mode: "host"
#ports:
# - 26379:26379
# - 26380:26380
# - 26381:26381
volumes:
- "./dockers/sentinel.conf:/redis/config-default/redis.conf"
- "./dockers/sentinel:/redis/work"
profiles:
- sentinel
- all-stack
- all
ring-cluster:
image: *default-image
platform: linux/amd64
container_name: redis-ring-cluster
environment:
- NODES=3
- TLS_ENABLED=yes
- TLS_CLIENT_CNS=testcertuser
- TLS_AUTH_CLIENTS_USER=CN
- REDIS_CLUSTER=no
- 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"
volumes:
- "./dockers/ring:/redis/work"
profiles:
- ring
- cluster
- all-stack
- all
+377
View File
@@ -0,0 +1,377 @@
package redis
import (
"context"
"errors"
"io"
"net"
"strings"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
)
// ErrClosed performs any operation on the closed client will return this error.
var ErrClosed = pool.ErrClosed
// ErrPoolExhausted is returned from a pool connection method
// when the maximum number of database connections in the pool has been reached.
var ErrPoolExhausted = pool.ErrPoolExhausted
// ErrPoolTimeout timed out waiting to get a connection from the connection pool.
var ErrPoolTimeout = pool.ErrPoolTimeout
// ErrCrossSlot is returned when keys are used in the same Redis command and
// the keys are not in the same hash slot. This error is returned by Redis
// Cluster and will be returned by the client when TxPipeline or TxPipelined
// 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
if !errors.As(err, &rErr) {
return false
}
msg := rErr.Error()
msg = strings.TrimPrefix(msg, "ERR ") // KVRocks adds such prefix
return strings.HasPrefix(msg, prefix)
}
type Error interface {
error
// RedisError is a no-op function but
// serves to distinguish types that are Redis
// errors from ordinary errors: a type is a
// Redis error if it has a RedisError method.
RedisError()
}
var _ Error = proto.RedisError("")
func isContextError(err error) bool {
// Check for wrapped context errors using errors.Is
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// isTimeoutError checks if an error is a timeout error, even if wrapped.
// Returns (isTimeout, shouldRetryOnTimeout) where:
// - isTimeout: true if the error is any kind of timeout error
// - shouldRetryOnTimeout: true if Timeout() method returns true
func isTimeoutError(err error) (isTimeout bool, hasTimeoutFlag bool) {
// Check for timeoutError interface (works with wrapped errors)
var te timeoutError
if errors.As(err, &te) {
return true, te.Timeout()
}
// Check for net.Error specifically (common case for network timeouts)
var netErr net.Error
if errors.As(err, &netErr) {
return true, netErr.Timeout()
}
return false, false
}
func shouldRetry(err error, retryTimeout bool) bool {
if err == nil {
return false
}
// Check for EOF errors (works with wrapped errors)
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Check for pool timeout (works with wrapped errors)
if errors.Is(err, pool.ErrPoolTimeout) {
// connection pool timeout, increase retries. #3289
return true
}
// 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
}
// Check for typed Redis errors using errors.As (works with wrapped errors)
if proto.IsMaxClientsError(err) {
return true
}
if proto.IsLoadingError(err) {
return true
}
if proto.IsReadOnlyError(err) {
return true
}
if proto.IsMasterDownError(err) {
return true
}
if proto.IsClusterDownError(err) {
return true
}
if proto.IsTryAgainError(err) {
return true
}
if proto.IsNoReplicasError(err) {
return true
}
// Fallback to string checking for backward compatibility with plain errors
s := err.Error()
if strings.HasPrefix(s, "ERR max number of clients reached") {
return true
}
if strings.HasPrefix(s, "LOADING ") {
return true
}
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
}
if strings.HasPrefix(s, "TRYAGAIN ") {
return true
}
if strings.HasPrefix(s, "MASTERDOWN ") {
return true
}
if strings.HasPrefix(s, "NOREPLICAS ") {
return true
}
return false
}
func isRedisError(err error) bool {
// Check if error implements the Error interface (works with wrapped errors)
var redisErr Error
if errors.As(err, &redisErr) {
return true
}
// Also check for proto.RedisError specifically
var protoRedisErr proto.RedisError
return errors.As(err, &protoRedisErr)
}
func isBadConn(err error, allowTimeout bool, addr string) bool {
if err == nil {
return false
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
// Check for pool timeout errors (works with wrapped errors)
if errors.Is(err, pool.ErrConnUnusableTimeout) {
return true
}
if isRedisError(err) {
switch {
case isReadOnlyError(err):
// Close connections in read only state in case domain addr is used
// and domain resolves to a different Redis Server. See #790.
return true
case isMovedSameConnAddr(err, addr):
// Close connections when we are asked to move to the same addr
// of the connection. Force a DNS resolution when all connections
// of the pool are recycled
return true
default:
return false
}
}
if allowTimeout {
// Check for network timeout errors (works with wrapped errors)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return false
}
}
return true
}
func isMovedError(err error) (moved bool, ask bool, addr string) {
// Check for typed MovedError
if movedErr, ok := proto.IsMovedError(err); ok {
addr = movedErr.Addr()
addr = internal.GetAddr(addr)
return true, false, addr
}
// Check for typed AskError
if askErr, ok := proto.IsAskError(err); ok {
addr = askErr.Addr()
addr = internal.GetAddr(addr)
return false, true, addr
}
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "MOVED ") {
// Parse: MOVED 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return true, false, addr
}
}
if strings.HasPrefix(s, "ASK ") {
// Parse: ASK 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return false, true, addr
}
}
return false, false, ""
}
func isLoadingError(err error) bool {
return proto.IsLoadingError(err)
}
func isReadOnlyError(err error) bool {
return proto.IsReadOnlyError(err)
}
func isMovedSameConnAddr(err error, addr string) bool {
if movedErr, ok := proto.IsMovedError(err); ok {
return strings.HasSuffix(movedErr.Addr(), addr)
}
return false
}
//------------------------------------------------------------------------------
// Typed error checking functions for public use.
// These functions work correctly even when errors are wrapped in hooks.
// IsLoadingError checks if an error is a Redis LOADING error, even if wrapped.
// LOADING errors occur when Redis is loading the dataset in memory.
func IsLoadingError(err error) bool {
return proto.IsLoadingError(err)
}
// IsReadOnlyError checks if an error is a Redis READONLY error, even if wrapped.
// READONLY errors occur when trying to write to a read-only replica.
func IsReadOnlyError(err error) bool {
return proto.IsReadOnlyError(err)
}
// IsClusterDownError checks if an error is a Redis CLUSTERDOWN error, even if wrapped.
// CLUSTERDOWN errors occur when the cluster is down.
func IsClusterDownError(err error) bool {
return proto.IsClusterDownError(err)
}
// IsTryAgainError checks if an error is a Redis TRYAGAIN error, even if wrapped.
// TRYAGAIN errors occur when a command cannot be processed and should be retried.
func IsTryAgainError(err error) bool {
return proto.IsTryAgainError(err)
}
// IsMasterDownError checks if an error is a Redis MASTERDOWN error, even if wrapped.
// MASTERDOWN errors occur when the master is down.
func IsMasterDownError(err error) bool {
return proto.IsMasterDownError(err)
}
// IsMaxClientsError checks if an error is a Redis max clients error, even if wrapped.
// This error occurs when the maximum number of clients has been reached.
func IsMaxClientsError(err error) bool {
return proto.IsMaxClientsError(err)
}
// IsMovedError checks if an error is a Redis MOVED error, even if wrapped.
// MOVED errors occur in cluster mode when a key has been moved to a different node.
// Returns the address of the node where the key has been moved and a boolean indicating if it's a MOVED error.
func IsMovedError(err error) (addr string, ok bool) {
if movedErr, isMovedErr := proto.IsMovedError(err); isMovedErr {
return movedErr.Addr(), true
}
return "", false
}
// IsAskError checks if an error is a Redis ASK error, even if wrapped.
// ASK errors occur in cluster mode when a key is being migrated and the client should ask another node.
// Returns the address of the node to ask and a boolean indicating if it's an ASK error.
func IsAskError(err error) (addr string, ok bool) {
if askErr, isAskErr := proto.IsAskError(err); isAskErr {
return askErr.Addr(), true
}
return "", false
}
// IsAuthError checks if an error is a Redis authentication error, even if wrapped.
// Authentication errors occur when:
// - NOAUTH: Redis requires authentication but none was provided
// - WRONGPASS: Redis authentication failed due to incorrect password
// - unauthenticated: Error returned when password changed
func IsAuthError(err error) bool {
return proto.IsAuthError(err)
}
// IsPermissionError checks if an error is a Redis permission error, even if wrapped.
// Permission errors (NOPERM) occur when a user does not have permission to execute a command.
func IsPermissionError(err error) bool {
return proto.IsPermissionError(err)
}
// IsExecAbortError checks if an error is a Redis EXECABORT error, even if wrapped.
// EXECABORT errors occur when a transaction is aborted.
func IsExecAbortError(err error) bool {
return proto.IsExecAbortError(err)
}
// IsOOMError checks if an error is a Redis OOM (Out Of Memory) error, even if wrapped.
// OOM errors occur when Redis is out of memory.
func IsOOMError(err error) bool {
return proto.IsOOMError(err)
}
// IsNoReplicasError checks if an error is a Redis NOREPLICAS error, even if wrapped.
// NOREPLICAS errors occur when not enough replicas acknowledge a write operation.
// This typically happens with WAIT/WAITAOF commands or CLUSTER SETSLOT with synchronous
// replication when the required number of replicas cannot confirm the write within the timeout.
func IsNoReplicasError(err error) bool {
return proto.IsNoReplicasError(err)
}
//------------------------------------------------------------------------------
type timeoutError interface {
Timeout() bool
}
+392
View File
@@ -0,0 +1,392 @@
package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9/internal/hashtag"
)
type GenericCmdable interface {
Del(ctx context.Context, keys ...string) *IntCmd
Dump(ctx context.Context, key string) *StringCmd
Exists(ctx context.Context, keys ...string) *IntCmd
Expire(ctx context.Context, key string, expiration time.Duration) *BoolCmd
ExpireAt(ctx context.Context, key string, tm time.Time) *BoolCmd
ExpireTime(ctx context.Context, key string) *DurationCmd
ExpireNX(ctx context.Context, key string, expiration time.Duration) *BoolCmd
ExpireXX(ctx context.Context, key string, expiration time.Duration) *BoolCmd
ExpireGT(ctx context.Context, key string, expiration time.Duration) *BoolCmd
ExpireLT(ctx context.Context, key string, expiration time.Duration) *BoolCmd
Keys(ctx context.Context, pattern string) *StringSliceCmd
Migrate(ctx context.Context, host, port, key string, db int, timeout time.Duration) *StatusCmd
Move(ctx context.Context, key string, db int) *BoolCmd
ObjectFreq(ctx context.Context, key string) *IntCmd
ObjectRefCount(ctx context.Context, key string) *IntCmd
ObjectEncoding(ctx context.Context, key string) *StringCmd
ObjectIdleTime(ctx context.Context, key string) *DurationCmd
Persist(ctx context.Context, key string) *BoolCmd
PExpire(ctx context.Context, key string, expiration time.Duration) *BoolCmd
PExpireAt(ctx context.Context, key string, tm time.Time) *BoolCmd
PExpireTime(ctx context.Context, key string) *DurationCmd
PTTL(ctx context.Context, key string) *DurationCmd
RandomKey(ctx context.Context) *StringCmd
Rename(ctx context.Context, key, newkey string) *StatusCmd
RenameNX(ctx context.Context, key, newkey string) *BoolCmd
Restore(ctx context.Context, key string, ttl time.Duration, value string) *StatusCmd
RestoreReplace(ctx context.Context, key string, ttl time.Duration, value string) *StatusCmd
Sort(ctx context.Context, key string, sort *Sort) *StringSliceCmd
SortRO(ctx context.Context, key string, sort *Sort) *StringSliceCmd
SortStore(ctx context.Context, key, store string, sort *Sort) *IntCmd
SortInterfaces(ctx context.Context, key string, sort *Sort) *SliceCmd
Touch(ctx context.Context, keys ...string) *IntCmd
TTL(ctx context.Context, key string) *DurationCmd
Type(ctx context.Context, key string) *StatusCmd
Copy(ctx context.Context, sourceKey string, destKey string, db int, replace bool) *IntCmd
Scan(ctx context.Context, cursor uint64, match string, count int64) *ScanCmd
ScanType(ctx context.Context, cursor uint64, match string, count int64, keyType string) *ScanCmd
}
func (c cmdable) Del(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "del"
for i, key := range keys {
args[1+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Unlink(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "unlink"
for i, key := range keys {
args[1+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Dump(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "dump", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Exists(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "exists"
for i, key := range keys {
args[1+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Expire(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
return c.expire(ctx, key, expiration, "")
}
func (c cmdable) ExpireNX(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
return c.expire(ctx, key, expiration, "NX")
}
func (c cmdable) ExpireXX(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
return c.expire(ctx, key, expiration, "XX")
}
func (c cmdable) ExpireGT(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
return c.expire(ctx, key, expiration, "GT")
}
func (c cmdable) ExpireLT(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
return c.expire(ctx, key, expiration, "LT")
}
func (c cmdable) expire(
ctx context.Context, key string, expiration time.Duration, mode string,
) *BoolCmd {
args := make([]interface{}, 3, 4)
args[0] = "expire"
args[1] = key
args[2] = formatSec(ctx, expiration)
if mode != "" {
args = append(args, mode)
}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ExpireAt(ctx context.Context, key string, tm time.Time) *BoolCmd {
cmd := NewBoolCmd(ctx, "expireat", key, tm.Unix())
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ExpireTime(ctx context.Context, key string) *DurationCmd {
cmd := NewDurationCmd(ctx, time.Second, "expiretime", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Keys(ctx context.Context, pattern string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "keys", pattern)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Migrate(ctx context.Context, host, port, key string, db int, timeout time.Duration) *StatusCmd {
cmd := NewStatusCmd(
ctx,
"migrate",
host,
port,
key,
db,
formatMs(ctx, timeout),
)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Move(ctx context.Context, key string, db int) *BoolCmd {
cmd := NewBoolCmd(ctx, "move", key, db)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ObjectFreq(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "object", "freq", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ObjectRefCount(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "object", "refcount", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ObjectEncoding(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "object", "encoding", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ObjectIdleTime(ctx context.Context, key string) *DurationCmd {
cmd := NewDurationCmd(ctx, time.Second, "object", "idletime", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Persist(ctx context.Context, key string) *BoolCmd {
cmd := NewBoolCmd(ctx, "persist", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PExpire(ctx context.Context, key string, expiration time.Duration) *BoolCmd {
cmd := NewBoolCmd(ctx, "pexpire", key, formatMs(ctx, expiration))
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PExpireAt(ctx context.Context, key string, tm time.Time) *BoolCmd {
cmd := NewBoolCmd(
ctx,
"pexpireat",
key,
tm.UnixNano()/int64(time.Millisecond),
)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PExpireTime(ctx context.Context, key string) *DurationCmd {
cmd := NewDurationCmd(ctx, time.Millisecond, "pexpiretime", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PTTL(ctx context.Context, key string) *DurationCmd {
cmd := NewDurationCmd(ctx, time.Millisecond, "pttl", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RandomKey(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "randomkey")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Rename(ctx context.Context, key, newkey string) *StatusCmd {
cmd := NewStatusCmd(ctx, "rename", key, newkey)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RenameNX(ctx context.Context, key, newkey string) *BoolCmd {
cmd := NewBoolCmd(ctx, "renamenx", key, newkey)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Restore(ctx context.Context, key string, ttl time.Duration, value string) *StatusCmd {
cmd := NewStatusCmd(
ctx,
"restore",
key,
formatMs(ctx, ttl),
value,
)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RestoreReplace(ctx context.Context, key string, ttl time.Duration, value string) *StatusCmd {
cmd := NewStatusCmd(
ctx,
"restore",
key,
formatMs(ctx, ttl),
value,
"replace",
)
_ = c(ctx, cmd)
return cmd
}
type Sort struct {
By string
Offset, Count int64
Get []string
Order string
Alpha bool
}
func (sort *Sort) args(command, key string) []interface{} {
args := []interface{}{command, key}
if sort.By != "" {
args = append(args, "by", sort.By)
}
if sort.Offset != 0 || sort.Count != 0 {
args = append(args, "limit", sort.Offset, sort.Count)
}
for _, get := range sort.Get {
args = append(args, "get", get)
}
if sort.Order != "" {
args = append(args, sort.Order)
}
if sort.Alpha {
args = append(args, "alpha")
}
return args
}
func (c cmdable) SortRO(ctx context.Context, key string, sort *Sort) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, sort.args("sort_ro", key)...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Sort(ctx context.Context, key string, sort *Sort) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, sort.args("sort", key)...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) SortStore(ctx context.Context, key, store string, sort *Sort) *IntCmd {
args := sort.args("sort", key)
if store != "" {
args = append(args, "store", store)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) SortInterfaces(ctx context.Context, key string, sort *Sort) *SliceCmd {
cmd := NewSliceCmd(ctx, sort.args("sort", key)...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Touch(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, len(keys)+1)
args[0] = "touch"
for i, key := range keys {
args[i+1] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) TTL(ctx context.Context, key string) *DurationCmd {
cmd := NewDurationCmd(ctx, time.Second, "ttl", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Type(ctx context.Context, key string) *StatusCmd {
cmd := NewStatusCmd(ctx, "type", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Copy(ctx context.Context, sourceKey, destKey string, db int, replace bool) *IntCmd {
args := []interface{}{"copy", sourceKey, destKey, "DB", db}
if replace {
args = append(args, "REPLACE")
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
//------------------------------------------------------------------------------
func (c cmdable) Scan(ctx context.Context, cursor uint64, match string, count int64) *ScanCmd {
args := []interface{}{"scan", cursor}
if match != "" {
args = append(args, "match", match)
}
if count > 0 {
args = append(args, "count", count)
}
cmd := NewScanCmd(ctx, c, args...)
if hashtag.Present(match) {
cmd.SetFirstKeyPos(3)
}
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ScanType(ctx context.Context, cursor uint64, match string, count int64, keyType string) *ScanCmd {
args := []interface{}{"scan", cursor}
if match != "" {
args = append(args, "match", match)
}
if count > 0 {
args = append(args, "count", count)
}
if keyType != "" {
args = append(args, "type", keyType)
}
cmd := NewScanCmd(ctx, c, args...)
if hashtag.Present(match) {
cmd.SetFirstKeyPos(3)
}
_ = c(ctx, cmd)
return cmd
}
+161
View File
@@ -0,0 +1,161 @@
package redis
import (
"context"
"errors"
)
type GeoCmdable interface {
GeoAdd(ctx context.Context, key string, geoLocation ...*GeoLocation) *IntCmd
GeoPos(ctx context.Context, key string, members ...string) *GeoPosCmd
GeoRadius(ctx context.Context, key string, longitude, latitude float64, query *GeoRadiusQuery) *GeoLocationCmd
GeoRadiusStore(ctx context.Context, key string, longitude, latitude float64, query *GeoRadiusQuery) *IntCmd
GeoRadiusByMember(ctx context.Context, key, member string, query *GeoRadiusQuery) *GeoLocationCmd
GeoRadiusByMemberStore(ctx context.Context, key, member string, query *GeoRadiusQuery) *IntCmd
GeoSearch(ctx context.Context, key string, q *GeoSearchQuery) *StringSliceCmd
GeoSearchLocation(ctx context.Context, key string, q *GeoSearchLocationQuery) *GeoSearchLocationCmd
GeoSearchStore(ctx context.Context, key, store string, q *GeoSearchStoreQuery) *IntCmd
GeoDist(ctx context.Context, key string, member1, member2, unit string) *FloatCmd
GeoHash(ctx context.Context, key string, members ...string) *StringSliceCmd
}
func (c cmdable) GeoAdd(ctx context.Context, key string, geoLocation ...*GeoLocation) *IntCmd {
args := make([]interface{}, 2+3*len(geoLocation))
args[0] = "geoadd"
args[1] = key
for i, eachLoc := range geoLocation {
args[2+3*i] = eachLoc.Longitude
args[2+3*i+1] = eachLoc.Latitude
args[2+3*i+2] = eachLoc.Name
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// GeoRadius queries a geospatial index for members within a distance from a coordinate.
// This is a read-only variant that does not support Store or StoreDist options.
//
// Deprecated: Use GeoSearch with BYRADIUS argument instead as of Redis 6.2.0.
func (c cmdable) GeoRadius(
ctx context.Context, key string, longitude, latitude float64, query *GeoRadiusQuery,
) *GeoLocationCmd {
cmd := NewGeoLocationCmd(ctx, query, "georadius_ro", key, longitude, latitude)
if query.Store != "" || query.StoreDist != "" {
cmd.SetErr(errors.New("GeoRadius does not support Store or StoreDist"))
return cmd
}
_ = c(ctx, cmd)
return cmd
}
// GeoRadiusStore is a writing GEORADIUS command.
func (c cmdable) GeoRadiusStore(
ctx context.Context, key string, longitude, latitude float64, query *GeoRadiusQuery,
) *IntCmd {
args := geoLocationArgs(query, "georadius", key, longitude, latitude)
cmd := NewIntCmd(ctx, args...)
if query.Store == "" && query.StoreDist == "" {
cmd.SetErr(errors.New("GeoRadiusStore requires Store or StoreDist"))
return cmd
}
_ = c(ctx, cmd)
return cmd
}
// GeoRadiusByMember queries a geospatial index for members within a distance from a member.
// This is a read-only variant that does not support Store or StoreDist options.
//
// Deprecated: Use GeoSearch with BYRADIUS and FROMMEMBER arguments instead as of Redis 6.2.0.
func (c cmdable) GeoRadiusByMember(
ctx context.Context, key, member string, query *GeoRadiusQuery,
) *GeoLocationCmd {
cmd := NewGeoLocationCmd(ctx, query, "georadiusbymember_ro", key, member)
if query.Store != "" || query.StoreDist != "" {
cmd.SetErr(errors.New("GeoRadiusByMember does not support Store or StoreDist"))
return cmd
}
_ = c(ctx, cmd)
return cmd
}
// GeoRadiusByMemberStore is a writing GEORADIUSBYMEMBER command.
func (c cmdable) GeoRadiusByMemberStore(
ctx context.Context, key, member string, query *GeoRadiusQuery,
) *IntCmd {
args := geoLocationArgs(query, "georadiusbymember", key, member)
cmd := NewIntCmd(ctx, args...)
if query.Store == "" && query.StoreDist == "" {
cmd.SetErr(errors.New("GeoRadiusByMemberStore requires Store or StoreDist"))
return cmd
}
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoSearch(ctx context.Context, key string, q *GeoSearchQuery) *StringSliceCmd {
args := make([]interface{}, 0, 13)
args = append(args, "geosearch", key)
args = geoSearchArgs(q, args)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoSearchLocation(
ctx context.Context, key string, q *GeoSearchLocationQuery,
) *GeoSearchLocationCmd {
args := make([]interface{}, 0, 16)
args = append(args, "geosearch", key)
args = geoSearchLocationArgs(q, args)
cmd := NewGeoSearchLocationCmd(ctx, q, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoSearchStore(ctx context.Context, key, store string, q *GeoSearchStoreQuery) *IntCmd {
args := make([]interface{}, 0, 15)
args = append(args, "geosearchstore", store, key)
args = geoSearchArgs(&q.GeoSearchQuery, args)
if q.StoreDist {
args = append(args, "storedist")
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoDist(
ctx context.Context, key string, member1, member2, unit string,
) *FloatCmd {
if unit == "" {
unit = "km"
}
cmd := NewFloatCmd(ctx, "geodist", key, member1, member2, unit)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoHash(ctx context.Context, key string, members ...string) *StringSliceCmd {
args := make([]interface{}, 2+len(members))
args[0] = "geohash"
args[1] = key
for i, member := range members {
args[2+i] = member
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GeoPos(ctx context.Context, key string, members ...string) *GeoPosCmd {
args := make([]interface{}, 2+len(members))
args[0] = "geopos"
args[1] = key
for i, member := range members {
args[2+i] = member
}
cmd := NewGeoPosCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
+626
View File
@@ -0,0 +1,626 @@
package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9/internal/hashtag"
)
type HashCmdable interface {
HDel(ctx context.Context, key string, fields ...string) *IntCmd
HExists(ctx context.Context, key, field string) *BoolCmd
HGet(ctx context.Context, key, field string) *StringCmd
HGetAll(ctx context.Context, key string) *MapStringStringCmd
HGetDel(ctx context.Context, key string, fields ...string) *StringSliceCmd
HGetEX(ctx context.Context, key string, fields ...string) *StringSliceCmd
HGetEXWithArgs(ctx context.Context, key string, options *HGetEXOptions, fields ...string) *StringSliceCmd
HIncrBy(ctx context.Context, key, field string, incr int64) *IntCmd
HIncrByFloat(ctx context.Context, key, field string, incr float64) *FloatCmd
HKeys(ctx context.Context, key string) *StringSliceCmd
HLen(ctx context.Context, key string) *IntCmd
HMGet(ctx context.Context, key string, fields ...string) *SliceCmd
HSet(ctx context.Context, key string, values ...interface{}) *IntCmd
HMSet(ctx context.Context, key string, values ...interface{}) *BoolCmd
HSetEX(ctx context.Context, key string, fieldsAndValues ...string) *IntCmd
HSetEXWithArgs(ctx context.Context, key string, options *HSetEXOptions, fieldsAndValues ...string) *IntCmd
HSetNX(ctx context.Context, key, field string, value interface{}) *BoolCmd
HScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd
HScanNoValues(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd
HVals(ctx context.Context, key string) *StringSliceCmd
HRandField(ctx context.Context, key string, count int) *StringSliceCmd
HRandFieldWithValues(ctx context.Context, key string, count int) *KeyValueSliceCmd
HStrLen(ctx context.Context, key, field string) *IntCmd
HExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd
HExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd
HPExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd
HPExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd
HExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd
HExpireAtWithArgs(ctx context.Context, key string, tm time.Time, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd
HPExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd
HPExpireAtWithArgs(ctx context.Context, key string, tm time.Time, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd
HPersist(ctx context.Context, key string, fields ...string) *IntSliceCmd
HExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd
HPExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd
HTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd
HPTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd
}
func (c cmdable) HDel(ctx context.Context, key string, fields ...string) *IntCmd {
args := make([]interface{}, 2+len(fields))
args[0] = "hdel"
args[1] = key
for i, field := range fields {
args[2+i] = field
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HExists(ctx context.Context, key, field string) *BoolCmd {
cmd := NewBoolCmd(ctx, "hexists", key, field)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HGet(ctx context.Context, key, field string) *StringCmd {
cmd := NewStringCmd(ctx, "hget", key, field)
_ = c(ctx, cmd)
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)
return cmd
}
func (c cmdable) HIncrBy(ctx context.Context, key, field string, incr int64) *IntCmd {
cmd := NewIntCmd(ctx, "hincrby", key, field, incr)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HIncrByFloat(ctx context.Context, key, field string, incr float64) *FloatCmd {
cmd := NewFloatCmd(ctx, "hincrbyfloat", key, field, incr)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HKeys(ctx context.Context, key string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "hkeys", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HLen(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "hlen", key)
_ = c(ctx, cmd)
return cmd
}
// HMGet returns the values for the specified fields in the hash stored at key.
// It returns an interface{} to distinguish between empty string and nil value.
func (c cmdable) HMGet(ctx context.Context, key string, fields ...string) *SliceCmd {
args := make([]interface{}, 2+len(fields))
args[0] = "hmget"
args[1] = key
for i, field := range fields {
args[2+i] = field
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HSet accepts values in following formats:
//
// - HSet(ctx, "myhash", "key1", "value1", "key2", "value2")
//
// - HSet(ctx, "myhash", []string{"key1", "value1", "key2", "value2"})
//
// - HSet(ctx, "myhash", map[string]interface{}{"key1": "value1", "key2": "value2"})
//
// Playing struct With "redis" tag.
// type MyHash struct { Key1 string `redis:"key1"`; Key2 int `redis:"key2"` }
//
// - HSet(ctx, "myhash", MyHash{"value1", "value2"}) Warn: redis-server >= 4.0
//
// For struct, can be a structure pointer type, we only parse the field whose tag is redis.
// if you don't want the field to be read, you can use the `redis:"-"` flag to ignore it,
// or you don't need to set the redis tag.
// For the type of structure field, we only support simple data types:
// string, int/uint(8,16,32,64), float(32,64), time.Time(to RFC3339Nano), time.Duration(to Nanoseconds ),
// if you are other more complex or custom data types, please implement the encoding.BinaryMarshaler interface.
//
// Note that in older versions of Redis server(redis-server < 4.0), HSet only supports a single key-value pair.
// redis-docs: https://redis.io/commands/hset (Starting with Redis version 4.0.0: Accepts multiple field and value arguments.)
// If you are using a Struct type and the number of fields is greater than one,
// you will receive an error similar to "ERR wrong number of arguments", you can use HMSet as a substitute.
func (c cmdable) HSet(ctx context.Context, key string, values ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "hset"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HMSet is a deprecated version of HSet left for compatibility with Redis 3.
func (c cmdable) HMSet(ctx context.Context, key string, values ...interface{}) *BoolCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "hmset"
args[1] = key
args = appendArgs(args, values)
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HSetNX(ctx context.Context, key, field string, value interface{}) *BoolCmd {
cmd := NewBoolCmd(ctx, "hsetnx", key, field, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HVals(ctx context.Context, key string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "hvals", key)
_ = c(ctx, cmd)
return cmd
}
// HRandField redis-server version >= 6.2.0.
func (c cmdable) HRandField(ctx context.Context, key string, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "hrandfield", key, count)
_ = c(ctx, cmd)
return cmd
}
// HRandFieldWithValues redis-server version >= 6.2.0.
func (c cmdable) HRandFieldWithValues(ctx context.Context, key string, count int) *KeyValueSliceCmd {
cmd := NewKeyValueSliceCmd(ctx, "hrandfield", key, count, "withvalues")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd {
args := []interface{}{"hscan", key, cursor}
if match != "" {
args = append(args, "match", match)
}
if count > 0 {
args = append(args, "count", count)
}
cmd := NewScanCmd(ctx, c, args...)
if hashtag.Present(match) {
cmd.SetFirstKeyPos(4)
}
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HStrLen(ctx context.Context, key, field string) *IntCmd {
cmd := NewIntCmd(ctx, "hstrlen", key, field)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HScanNoValues(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd {
args := []interface{}{"hscan", key, cursor}
if match != "" {
args = append(args, "match", match)
}
if count > 0 {
args = append(args, "count", count)
}
args = append(args, "novalues")
cmd := NewScanCmd(ctx, c, args...)
if hashtag.Present(match) {
cmd.SetFirstKeyPos(4)
}
_ = c(ctx, cmd)
return cmd
}
type HExpireArgs struct {
NX bool
XX bool
GT bool
LT bool
}
// HExpire - Sets the expiration time for specified fields in a hash in seconds.
// The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields.
// Available since Redis 7.4 CE.
// For more information refer to [HEXPIRE Documentation].
//
// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/
func (c cmdable) HExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd {
args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration), "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HExpireWithArgs - Sets the expiration time for specified fields in a hash in seconds.
// It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields.
// The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields.
// Available since Redis 7.4 CE.
// For more information refer to [HEXPIRE Documentation].
//
// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/
func (c cmdable) HExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd {
args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration)}
// only if one argument is true, we can add it to the args
// if more than one argument is true, it will cause an error
if expirationArgs.NX {
args = append(args, "NX")
} else if expirationArgs.XX {
args = append(args, "XX")
} else if expirationArgs.GT {
args = append(args, "GT")
} else if expirationArgs.LT {
args = append(args, "LT")
}
args = append(args, "FIELDS", len(fields))
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPExpire - Sets the expiration time for specified fields in a hash in milliseconds.
// Similar to HExpire, it accepts a key, an expiration duration in milliseconds, a struct with expiration condition flags, and a list of fields.
// The command modifies the standard time.Duration to milliseconds for the Redis command.
// Available since Redis 7.4 CE.
// For more information refer to [HPEXPIRE Documentation].
//
// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/
func (c cmdable) HPExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd {
args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration), "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPExpireWithArgs - Sets the expiration time for specified fields in a hash in milliseconds.
// It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields.
// The command constructs an argument list starting with "HPEXPIRE", followed by the key, duration, any conditional flags, and the specified fields.
// Available since Redis 7.4 CE.
// For more information refer to [HPEXPIRE Documentation].
//
// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/
func (c cmdable) HPExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd {
args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration)}
// only if one argument is true, we can add it to the args
// if more than one argument is true, it will cause an error
if expirationArgs.NX {
args = append(args, "NX")
} else if expirationArgs.XX {
args = append(args, "XX")
} else if expirationArgs.GT {
args = append(args, "GT")
} else if expirationArgs.LT {
args = append(args, "LT")
}
args = append(args, "FIELDS", len(fields))
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in seconds.
// Takes a key, a UNIX timestamp, a struct of conditional flags, and a list of fields.
// The command sets absolute expiration times based on the UNIX timestamp provided.
// Available since Redis 7.4 CE.
// For more information refer to [HExpireAt Documentation].
//
// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/
func (c cmdable) HExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd {
args := []interface{}{"HEXPIREAT", key, tm.Unix(), "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HExpireAtWithArgs(ctx context.Context, key string, tm time.Time, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd {
args := []interface{}{"HEXPIREAT", key, tm.Unix()}
// only if one argument is true, we can add it to the args
// if more than one argument is true, it will cause an error
if expirationArgs.NX {
args = append(args, "NX")
} else if expirationArgs.XX {
args = append(args, "XX")
} else if expirationArgs.GT {
args = append(args, "GT")
} else if expirationArgs.LT {
args = append(args, "LT")
}
args = append(args, "FIELDS", len(fields))
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in milliseconds.
// Similar to HExpireAt but for timestamps in milliseconds. It accepts the same parameters and adjusts the UNIX time to milliseconds.
// Available since Redis 7.4 CE.
// For more information refer to [HExpireAt Documentation].
//
// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/
func (c cmdable) HPExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd {
args := []interface{}{"HPEXPIREAT", key, tm.UnixNano() / int64(time.Millisecond), "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HPExpireAtWithArgs(ctx context.Context, key string, tm time.Time, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd {
args := []interface{}{"HPEXPIREAT", key, tm.UnixNano() / int64(time.Millisecond)}
// only if one argument is true, we can add it to the args
// if more than one argument is true, it will cause an error
if expirationArgs.NX {
args = append(args, "NX")
} else if expirationArgs.XX {
args = append(args, "XX")
} else if expirationArgs.GT {
args = append(args, "GT")
} else if expirationArgs.LT {
args = append(args, "LT")
}
args = append(args, "FIELDS", len(fields))
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPersist - Removes the expiration time from specified fields in a hash.
// Accepts a key and the fields themselves.
// This command ensures that each field specified will have its expiration removed if present.
// Available since Redis 7.4 CE.
// For more information refer to [HPersist Documentation].
//
// [HPersist Documentation]: https://redis.io/commands/hpersist/
func (c cmdable) HPersist(ctx context.Context, key string, fields ...string) *IntSliceCmd {
args := []interface{}{"HPERSIST", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in seconds.
// Requires a key and the fields themselves to fetch their expiration timestamps.
// This command returns the expiration times for each field or error/status codes for each field as specified.
// Available since Redis 7.4 CE.
// For more information refer to [HExpireTime Documentation].
//
// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/
// For more information - https://redis.io/commands/hexpiretime/
func (c cmdable) HExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd {
args := []interface{}{"HEXPIRETIME", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in milliseconds.
// Similar to HExpireTime, adjusted for timestamps in milliseconds. It requires the same parameters.
// Provides the expiration timestamp for each field in milliseconds.
// Available since Redis 7.4 CE.
// For more information refer to [HExpireTime Documentation].
//
// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/
// For more information - https://redis.io/commands/hexpiretime/
func (c cmdable) HPExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd {
args := []interface{}{"HPEXPIRETIME", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HTTL - Retrieves the remaining time to live for specified fields in a hash in seconds.
// Requires a key and the fields themselves. It returns the TTL for each specified field.
// This command fetches the TTL in seconds for each field or returns error/status codes as appropriate.
// Available since Redis 7.4 CE.
// For more information refer to [HTTL Documentation].
//
// [HTTL Documentation]: https://redis.io/commands/httl/
func (c cmdable) HTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd {
args := []interface{}{"HTTL", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HPTTL - Retrieves the remaining time to live for specified fields in a hash in milliseconds.
// Similar to HTTL, but returns the TTL in milliseconds. It requires a key and the specified fields.
// This command provides the TTL in milliseconds for each field or returns error/status codes as needed.
// Available since Redis 7.4 CE.
// For more information refer to [HPTTL Documentation].
//
// [HPTTL Documentation]: https://redis.io/commands/hpttl/
// For more information - https://redis.io/commands/hpttl/
func (c cmdable) HPTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd {
args := []interface{}{"HPTTL", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HGetDel(ctx context.Context, key string, fields ...string) *StringSliceCmd {
args := []interface{}{"HGETDEL", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HGetEX(ctx context.Context, key string, fields ...string) *StringSliceCmd {
args := []interface{}{"HGETEX", key, "FIELDS", len(fields)}
for _, field := range fields {
args = append(args, field)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// HGetEXExpirationType represents an expiration option for the HGETEX command.
type HGetEXExpirationType string
const (
HGetEXExpirationEX HGetEXExpirationType = "EX"
HGetEXExpirationPX HGetEXExpirationType = "PX"
HGetEXExpirationEXAT HGetEXExpirationType = "EXAT"
HGetEXExpirationPXAT HGetEXExpirationType = "PXAT"
HGetEXExpirationPERSIST HGetEXExpirationType = "PERSIST"
)
type HGetEXOptions struct {
ExpirationType HGetEXExpirationType
ExpirationVal int64
}
func (c cmdable) HGetEXWithArgs(ctx context.Context, key string, options *HGetEXOptions, fields ...string) *StringSliceCmd {
args := []interface{}{"HGETEX", key}
if options.ExpirationType != "" {
args = append(args, string(options.ExpirationType))
if options.ExpirationType != HGetEXExpirationPERSIST {
args = append(args, options.ExpirationVal)
}
}
args = append(args, "FIELDS", len(fields))
for _, field := range fields {
args = append(args, field)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type HSetEXCondition string
const (
HSetEXFNX HSetEXCondition = "FNX" // Only set the fields if none of them already exist.
HSetEXFXX HSetEXCondition = "FXX" // Only set the fields if all already exist.
)
type HSetEXExpirationType string
const (
HSetEXExpirationEX HSetEXExpirationType = "EX"
HSetEXExpirationPX HSetEXExpirationType = "PX"
HSetEXExpirationEXAT HSetEXExpirationType = "EXAT"
HSetEXExpirationPXAT HSetEXExpirationType = "PXAT"
HSetEXExpirationKEEPTTL HSetEXExpirationType = "KEEPTTL"
)
type HSetEXOptions struct {
Condition HSetEXCondition
ExpirationType HSetEXExpirationType
ExpirationVal int64
}
func (c cmdable) HSetEX(ctx context.Context, key string, fieldsAndValues ...string) *IntCmd {
args := []interface{}{"HSETEX", key, "FIELDS", len(fieldsAndValues) / 2}
for _, field := range fieldsAndValues {
args = append(args, field)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) HSetEXWithArgs(ctx context.Context, key string, options *HSetEXOptions, fieldsAndValues ...string) *IntCmd {
args := []interface{}{"HSETEX", key}
if options.Condition != "" {
args = append(args, string(options.Condition))
}
if options.ExpirationType != "" {
args = append(args, string(options.ExpirationType))
if options.ExpirationType != HSetEXExpirationKEEPTTL {
args = append(args, options.ExpirationVal)
}
}
args = append(args, "FIELDS", len(fieldsAndValues)/2)
for _, field := range fieldsAndValues {
args = append(args, field)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
+122
View File
@@ -0,0 +1,122 @@
package redis
import (
"context"
"errors"
"strings"
)
// HOTKEYS commands are only available on standalone *Client instances.
// They are NOT available on ClusterClient, Ring, or UniversalClient because
// HOTKEYS is a stateful command requiring session affinity - all operations
// (START, GET, STOP, RESET) must be sent to the same Redis node.
//
// If you are using UniversalClient and need HOTKEYS functionality, you must
// type assert to *Client first:
//
// if client, ok := universalClient.(*redis.Client); ok {
// result, err := client.HotKeysStart(ctx, args)
// // ...
// }
// HotKeysMetric represents the metrics that can be tracked by the HOTKEYS command.
type HotKeysMetric string
const (
// HotKeysMetricCPU tracks CPU time spent on the key (in microseconds).
HotKeysMetricCPU HotKeysMetric = "CPU"
// HotKeysMetricNET tracks network bytes used by the key (ingress + egress + replication).
HotKeysMetricNET HotKeysMetric = "NET"
)
// HotKeysStartArgs contains the arguments for the HOTKEYS START command.
// This command is only available on standalone clients due to its stateful nature
// requiring session affinity. It must NOT be used on cluster or pooled clients.
type HotKeysStartArgs struct {
// Metrics to track. At least one must be specified.
Metrics []HotKeysMetric
// Count is the number of top keys to report.
// Default: 10, Min: 10, Max: 64
Count uint8
// Duration is the auto-stop tracking after this many seconds.
// Default: 0 (no auto-stop)
Duration int64
// Sample is the sample ratio - track keys with probability 1/sample.
// Default: 1 (track every key), Min: 1
Sample int64
// Slots specifies specific hash slots to track (0-16383).
// All specified slots must be hosted by the receiving node.
// If not specified, all slots are tracked.
Slots []uint16
}
// ErrHotKeysNoMetrics is returned when HotKeysStart is called without any metrics specified.
var ErrHotKeysNoMetrics = errors.New("redis: at least one metric must be specified for HOTKEYS START")
// HotKeysStart starts collecting hotkeys data.
// At least one metric must be specified in args.Metrics.
// This command is only available on standalone clients.
func (c *Client) HotKeysStart(ctx context.Context, args *HotKeysStartArgs) *StatusCmd {
cmdArgs := make([]interface{}, 0, 16)
cmdArgs = append(cmdArgs, "hotkeys", "start")
// Validate that at least one metric is specified
if len(args.Metrics) == 0 {
cmd := NewStatusCmd(ctx, cmdArgs...)
cmd.SetErr(ErrHotKeysNoMetrics)
return cmd
}
cmdArgs = append(cmdArgs, "metrics", len(args.Metrics))
for _, metric := range args.Metrics {
cmdArgs = append(cmdArgs, strings.ToLower(string(metric)))
}
if args.Count > 0 {
cmdArgs = append(cmdArgs, "count", args.Count)
}
if args.Duration > 0 {
cmdArgs = append(cmdArgs, "duration", args.Duration)
}
if args.Sample > 0 {
cmdArgs = append(cmdArgs, "sample", args.Sample)
}
if len(args.Slots) > 0 {
cmdArgs = append(cmdArgs, "slots", len(args.Slots))
for _, slot := range args.Slots {
cmdArgs = append(cmdArgs, slot)
}
}
cmd := NewStatusCmd(ctx, cmdArgs...)
_ = c.Process(ctx, cmd)
return cmd
}
// HotKeysStop stops the ongoing hotkeys collection session.
// This command is only available on standalone clients.
func (c *Client) HotKeysStop(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "hotkeys", "stop")
_ = c.Process(ctx, cmd)
return cmd
}
// HotKeysReset discards the last hotkeys collection session results.
// Returns an error if tracking is currently active.
// This command is only available on standalone clients.
func (c *Client) HotKeysReset(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "hotkeys", "reset")
_ = c.Process(ctx, cmd)
return cmd
}
// HotKeysGet retrieves the results of the ongoing or last hotkeys collection session.
// This command is only available on standalone clients.
func (c *Client) HotKeysGet(ctx context.Context) *HotKeysCmd {
cmd := NewHotKeysCmd(ctx, "hotkeys", "get")
_ = c.Process(ctx, cmd)
return cmd
}
+42
View File
@@ -0,0 +1,42 @@
package redis
import "context"
type HyperLogLogCmdable interface {
PFAdd(ctx context.Context, key string, els ...interface{}) *IntCmd
PFCount(ctx context.Context, keys ...string) *IntCmd
PFMerge(ctx context.Context, dest string, keys ...string) *StatusCmd
}
func (c cmdable) PFAdd(ctx context.Context, key string, els ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(els))
args[0] = "pfadd"
args[1] = key
args = appendArgs(args, els)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PFCount(ctx context.Context, keys ...string) *IntCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "pfcount"
for i, key := range keys {
args[1+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PFMerge(ctx context.Context, dest string, keys ...string) *StatusCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "pfmerge"
args[1] = dest
for i, key := range keys {
args[2+i] = key
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
+58
View File
@@ -0,0 +1,58 @@
package internal
import (
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9/internal/util"
)
func AppendArg(b []byte, v interface{}) []byte {
switch v := v.(type) {
case nil:
return append(b, "<nil>"...)
case string:
return appendUTF8String(b, util.StringToBytes(v))
case []byte:
return appendUTF8String(b, v)
case int:
return strconv.AppendInt(b, int64(v), 10)
case int8:
return strconv.AppendInt(b, int64(v), 10)
case int16:
return strconv.AppendInt(b, int64(v), 10)
case int32:
return strconv.AppendInt(b, int64(v), 10)
case int64:
return strconv.AppendInt(b, v, 10)
case uint:
return strconv.AppendUint(b, uint64(v), 10)
case uint8:
return strconv.AppendUint(b, uint64(v), 10)
case uint16:
return strconv.AppendUint(b, uint64(v), 10)
case uint32:
return strconv.AppendUint(b, uint64(v), 10)
case uint64:
return strconv.AppendUint(b, v, 10)
case float32:
return strconv.AppendFloat(b, float64(v), 'f', -1, 64)
case float64:
return strconv.AppendFloat(b, v, 'f', -1, 64)
case bool:
if v {
return append(b, "true"...)
}
return append(b, "false"...)
case time.Time:
return v.AppendFormat(b, time.RFC3339Nano)
default:
return append(b, fmt.Sprint(v)...)
}
}
func appendUTF8String(dst []byte, src []byte) []byte {
dst = append(dst, src...)
return dst
}
@@ -0,0 +1,100 @@
package streaming
import (
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal/pool"
)
// ConnReAuthCredentialsListener is a credentials listener for a specific connection
// that triggers re-authentication when credentials change.
//
// This listener implements the auth.CredentialsListener interface and is subscribed
// to a StreamingCredentialsProvider. When new credentials are received via OnNext,
// it marks the connection for re-authentication through the manager.
//
// The re-authentication is always performed asynchronously to avoid blocking the
// credentials provider and to prevent potential deadlocks with the pool semaphore.
// The actual re-auth happens when the connection is returned to the pool in an idle state.
//
// Lifecycle:
// - Created during connection initialization via Manager.Listener()
// - Subscribed to the StreamingCredentialsProvider
// - Receives credential updates via OnNext()
// - Cleaned up when connection is removed from pool via Manager.RemoveListener()
type ConnReAuthCredentialsListener struct {
// reAuth is the function to re-authenticate the connection with new credentials
reAuth func(conn *pool.Conn, credentials auth.Credentials) error
// onErr is the function to call when re-authentication or acquisition fails
onErr func(conn *pool.Conn, err error)
// conn is the connection this listener is associated with
conn *pool.Conn
// manager is the streaming credentials manager for coordinating re-auth
manager *Manager
}
// OnNext is called when new credentials are received from the StreamingCredentialsProvider.
//
// This method marks the connection for asynchronous re-authentication. The actual
// re-authentication happens in the background when the connection is returned to the
// pool and is in an idle state.
//
// Asynchronous re-auth is used to:
// - Avoid blocking the credentials provider's notification goroutine
// - Prevent deadlocks with the pool's semaphore (especially with small pool sizes)
// - Ensure re-auth happens when the connection is safe to use (not processing commands)
//
// The reAuthFn callback receives:
// - nil if the connection was successfully acquired for re-auth
// - error if acquisition timed out or failed
//
// Thread-safe: Called by the credentials provider's notification goroutine.
func (c *ConnReAuthCredentialsListener) OnNext(credentials auth.Credentials) {
if c.conn == nil || c.conn.IsClosed() || c.manager == nil || c.reAuth == nil {
return
}
// Always use async reauth to avoid complex pool semaphore issues
// The synchronous path can cause deadlocks in the pool's semaphore mechanism
// when called from the Subscribe goroutine, especially with small pool sizes.
// The connection pool hook will re-authenticate the connection when it is
// returned to the pool in a clean, idle state.
c.manager.MarkForReAuth(c.conn, func(err error) {
// err is from connection acquisition (timeout, etc.)
if err != nil {
// Log the error
c.OnError(err)
return
}
// err is from reauth command execution
err = c.reAuth(c.conn, credentials)
if err != nil {
// Log the error
c.OnError(err)
return
}
})
}
// OnError is called when an error occurs during credential streaming or re-authentication.
//
// This method can be called from:
// - The StreamingCredentialsProvider when there's an error in the credentials stream
// - The re-auth process when connection acquisition times out
// - The re-auth process when the AUTH command fails
//
// The error is delegated to the onErr callback provided during listener creation.
//
// Thread-safe: Can be called from multiple goroutines (provider, re-auth worker).
func (c *ConnReAuthCredentialsListener) OnError(err error) {
if c.onErr == nil {
return
}
c.onErr(c.conn, err)
}
// Ensure ConnReAuthCredentialsListener implements the CredentialsListener interface.
var _ auth.CredentialsListener = (*ConnReAuthCredentialsListener)(nil)
@@ -0,0 +1,77 @@
package streaming
import (
"sync"
"github.com/redis/go-redis/v9/auth"
)
// CredentialsListeners is a thread-safe collection of credentials listeners
// indexed by connection ID.
//
// This collection is used by the Manager to maintain a registry of listeners
// for each connection in the pool. Listeners are reused when connections are
// reinitialized (e.g., after a handoff) to avoid creating duplicate subscriptions
// to the StreamingCredentialsProvider.
//
// The collection supports concurrent access from multiple goroutines during
// connection initialization, credential updates, and connection removal.
type CredentialsListeners struct {
// listeners maps connection ID to credentials listener
listeners map[uint64]auth.CredentialsListener
// lock protects concurrent access to the listeners map
lock sync.RWMutex
}
// NewCredentialsListeners creates a new thread-safe credentials listeners collection.
func NewCredentialsListeners() *CredentialsListeners {
return &CredentialsListeners{
listeners: make(map[uint64]auth.CredentialsListener),
}
}
// Add adds or updates a credentials listener for a connection.
//
// If a listener already exists for the connection ID, it is replaced.
// This is safe because the old listener should have been unsubscribed
// before the connection was reinitialized.
//
// Thread-safe: Can be called concurrently from multiple goroutines.
func (c *CredentialsListeners) Add(connID uint64, listener auth.CredentialsListener) {
c.lock.Lock()
defer c.lock.Unlock()
if c.listeners == nil {
c.listeners = make(map[uint64]auth.CredentialsListener)
}
c.listeners[connID] = listener
}
// Get retrieves the credentials listener for a connection.
//
// Returns:
// - listener: The credentials listener for the connection, or nil if not found
// - ok: true if a listener exists for the connection ID, false otherwise
//
// Thread-safe: Can be called concurrently from multiple goroutines.
func (c *CredentialsListeners) Get(connID uint64) (auth.CredentialsListener, bool) {
c.lock.RLock()
defer c.lock.RUnlock()
if len(c.listeners) == 0 {
return nil, false
}
listener, ok := c.listeners[connID]
return listener, ok
}
// Remove removes the credentials listener for a connection.
//
// This is called when a connection is removed from the pool to prevent
// memory leaks. If no listener exists for the connection ID, this is a no-op.
//
// Thread-safe: Can be called concurrently from multiple goroutines.
func (c *CredentialsListeners) Remove(connID uint64) {
c.lock.Lock()
defer c.lock.Unlock()
delete(c.listeners, connID)
}
+137
View File
@@ -0,0 +1,137 @@
package streaming
import (
"errors"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal/pool"
)
// Manager coordinates streaming credentials and re-authentication for a connection pool.
//
// The manager is responsible for:
// - Creating and managing per-connection credentials listeners
// - Providing the pool hook for re-authentication
// - Coordinating between credentials updates and pool operations
//
// When credentials change via a StreamingCredentialsProvider:
// 1. The credentials listener (ConnReAuthCredentialsListener) receives the update
// 2. It calls MarkForReAuth on the manager
// 3. The manager delegates to the pool hook
// 4. The pool hook schedules background re-authentication
//
// The manager maintains a registry of credentials listeners indexed by connection ID,
// allowing listener reuse when connections are reinitialized (e.g., after handoff).
type Manager struct {
// credentialsListeners maps connection ID to credentials listener
credentialsListeners *CredentialsListeners
// pool is the connection pool being managed
pool pool.Pooler
// poolHookRef is the re-authentication pool hook
poolHookRef *ReAuthPoolHook
}
// NewManager creates a new streaming credentials manager.
//
// Parameters:
// - pl: The connection pool to manage
// - reAuthTimeout: Maximum time to wait for acquiring a connection for re-authentication
//
// The manager creates a ReAuthPoolHook sized to match the pool size, ensuring that
// re-auth operations don't exhaust the connection pool.
func NewManager(pl pool.Pooler, reAuthTimeout time.Duration) *Manager {
m := &Manager{
pool: pl,
poolHookRef: NewReAuthPoolHook(pl.Size(), reAuthTimeout),
credentialsListeners: NewCredentialsListeners(),
}
m.poolHookRef.manager = m
return m
}
// PoolHook returns the pool hook for re-authentication.
//
// This hook should be registered with the connection pool to enable
// automatic re-authentication when credentials change.
func (m *Manager) PoolHook() pool.PoolHook {
return m.poolHookRef
}
// Listener returns or creates a credentials listener for a connection.
//
// This method is called during connection initialization to set up the
// credentials listener. If a listener already exists for the connection ID
// (e.g., after a handoff), it is reused.
//
// Parameters:
// - poolCn: The connection to create/get a listener for
// - reAuth: Function to re-authenticate the connection with new credentials
// - onErr: Function to call when re-authentication fails
//
// Returns:
// - auth.CredentialsListener: The listener to subscribe to the credentials provider
// - error: Non-nil if poolCn is nil
//
// Note: The reAuth and onErr callbacks are captured once when the listener is
// created and reused for the connection's lifetime. They should not change.
//
// Thread-safe: Can be called concurrently during connection initialization.
func (m *Manager) Listener(
poolCn *pool.Conn,
reAuth func(*pool.Conn, auth.Credentials) error,
onErr func(*pool.Conn, error),
) (auth.CredentialsListener, error) {
if poolCn == nil {
return nil, errors.New("poolCn cannot be nil")
}
connID := poolCn.GetID()
// if we reconnect the underlying network connection, the streaming credentials listener will continue to work
// so we can get the old listener from the cache and use it.
// subscribing the same (an already subscribed) listener for a StreamingCredentialsProvider SHOULD be a no-op
listener, ok := m.credentialsListeners.Get(connID)
if !ok || listener == nil {
// Create new listener for this connection
// Note: Callbacks (reAuth, onErr) are captured once and reused for the connection's lifetime
newCredListener := &ConnReAuthCredentialsListener{
conn: poolCn,
reAuth: reAuth,
onErr: onErr,
manager: m,
}
m.credentialsListeners.Add(connID, newCredListener)
listener = newCredListener
}
return listener, nil
}
// MarkForReAuth marks a connection for re-authentication.
//
// This method is called by the credentials listener when new credentials are
// received. It delegates to the pool hook to schedule background re-authentication.
//
// Parameters:
// - poolCn: The connection to re-authenticate
// - reAuthFn: Function to call for re-authentication, receives error if acquisition fails
//
// Thread-safe: Called by credentials listeners when credentials change.
func (m *Manager) MarkForReAuth(poolCn *pool.Conn, reAuthFn func(error)) {
connID := poolCn.GetID()
m.poolHookRef.MarkForReAuth(connID, reAuthFn)
}
// RemoveListener removes the credentials listener for a connection.
//
// This method is called by the pool hook's OnRemove to clean up listeners
// when connections are removed from the pool.
//
// Parameters:
// - connID: The connection ID whose listener should be removed
//
// Thread-safe: Called during connection removal.
func (m *Manager) RemoveListener(connID uint64) {
m.credentialsListeners.Remove(connID)
}
@@ -0,0 +1,241 @@
package streaming
import (
"context"
"sync"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
)
// ReAuthPoolHook is a pool hook that manages background re-authentication of connections
// when credentials change via a streaming credentials provider.
//
// The hook uses a semaphore-based worker pool to limit concurrent re-authentication
// operations and prevent pool exhaustion. When credentials change, connections are
// marked for re-authentication and processed asynchronously in the background.
//
// The re-authentication process:
// 1. OnPut: When a connection is returned to the pool, check if it needs re-auth
// 2. If yes, schedule it for background processing (move from shouldReAuth to scheduledReAuth)
// 3. A worker goroutine acquires the connection (waits until it's not in use)
// 4. Executes the re-auth function while holding the connection
// 5. Releases the connection back to the pool
//
// The hook ensures that:
// - Only one re-auth operation runs per connection at a time
// - Connections are not used for commands during re-authentication
// - Re-auth operations timeout if they can't acquire the connection
// - Resources are properly cleaned up on connection removal
type ReAuthPoolHook struct {
// shouldReAuth maps connection ID to re-auth function
// Connections in this map need re-authentication but haven't been scheduled yet
shouldReAuth map[uint64]func(error)
shouldReAuthLock sync.RWMutex
// workers is a semaphore limiting concurrent re-auth operations
// Initialized with poolSize tokens to prevent pool exhaustion
// Uses FastSemaphore for better performance with eventual fairness
workers *internal.FastSemaphore
// reAuthTimeout is the maximum time to wait for acquiring a connection for re-auth
reAuthTimeout time.Duration
// scheduledReAuth maps connection ID to scheduled status
// Connections in this map have a background worker attempting re-authentication
scheduledReAuth map[uint64]bool
scheduledLock sync.RWMutex
// manager is a back-reference for cleanup operations
manager *Manager
}
// NewReAuthPoolHook creates a new re-authentication pool hook.
//
// Parameters:
// - poolSize: Maximum number of concurrent re-auth operations (typically matches pool size)
// - reAuthTimeout: Maximum time to wait for acquiring a connection for re-authentication
//
// The poolSize parameter is used to initialize the worker semaphore, ensuring that
// re-auth operations don't exhaust the connection pool.
func NewReAuthPoolHook(poolSize int, reAuthTimeout time.Duration) *ReAuthPoolHook {
return &ReAuthPoolHook{
shouldReAuth: make(map[uint64]func(error)),
scheduledReAuth: make(map[uint64]bool),
workers: internal.NewFastSemaphore(int32(poolSize)),
reAuthTimeout: reAuthTimeout,
}
}
// MarkForReAuth marks a connection for re-authentication.
//
// This method is called when credentials change and a connection needs to be
// re-authenticated. The actual re-authentication happens asynchronously when
// the connection is returned to the pool (in OnPut).
//
// Parameters:
// - connID: The connection ID to mark for re-authentication
// - reAuthFn: Function to call for re-authentication, receives error if acquisition fails
//
// Thread-safe: Can be called concurrently from multiple goroutines.
func (r *ReAuthPoolHook) MarkForReAuth(connID uint64, reAuthFn func(error)) {
r.shouldReAuthLock.Lock()
defer r.shouldReAuthLock.Unlock()
r.shouldReAuth[connID] = reAuthFn
}
// OnGet is called when a connection is retrieved from the pool.
//
// This hook checks if the connection needs re-authentication or has a scheduled
// re-auth operation. If so, it rejects the connection (returns accept=false),
// causing the pool to try another connection.
//
// Returns:
// - accept: false if connection needs re-auth, true otherwise
// - err: always nil (errors are not used in this hook)
//
// Thread-safe: Called concurrently by multiple goroutines getting connections.
func (r *ReAuthPoolHook) OnGet(_ context.Context, conn *pool.Conn, _ bool) (accept bool, err error) {
connID := conn.GetID()
r.shouldReAuthLock.RLock()
_, shouldReAuth := r.shouldReAuth[connID]
r.shouldReAuthLock.RUnlock()
// This connection was marked for reauth while in the pool,
// reject the connection
if shouldReAuth {
// simply reject the connection, it will be re-authenticated in OnPut
return false, nil
}
r.scheduledLock.RLock()
_, hasScheduled := r.scheduledReAuth[connID]
r.scheduledLock.RUnlock()
// has scheduled reauth, reject the connection
if hasScheduled {
// simply reject the connection, it currently has a reauth scheduled
// and the worker is waiting for slot to execute the reauth
return false, nil
}
return true, nil
}
// OnPut is called when a connection is returned to the pool.
//
// This hook checks if the connection needs re-authentication. If so, it schedules
// a background goroutine to perform the re-auth asynchronously. The goroutine:
// 1. Waits for a worker slot (semaphore)
// 2. Acquires the connection (waits until not in use)
// 3. Executes the re-auth function
// 4. Releases the connection and worker slot
//
// The connection is always pooled (not removed) since re-auth happens in background.
//
// Returns:
// - shouldPool: always true (connection stays in pool during background re-auth)
// - shouldRemove: always false
// - err: always nil
//
// Thread-safe: Called concurrently by multiple goroutines returning connections.
func (r *ReAuthPoolHook) OnPut(_ context.Context, conn *pool.Conn) (bool, bool, error) {
if conn == nil {
// noop
return true, false, nil
}
connID := conn.GetID()
// Check if reauth is needed and get the function with proper locking
r.shouldReAuthLock.RLock()
reAuthFn, ok := r.shouldReAuth[connID]
r.shouldReAuthLock.RUnlock()
if ok {
// Acquire both locks to atomically move from shouldReAuth to scheduledReAuth
// This prevents race conditions where OnGet might miss the transition
r.shouldReAuthLock.Lock()
r.scheduledLock.Lock()
r.scheduledReAuth[connID] = true
delete(r.shouldReAuth, connID)
r.scheduledLock.Unlock()
r.shouldReAuthLock.Unlock()
go func() {
r.workers.AcquireBlocking()
// safety first
if conn == nil || (conn != nil && conn.IsClosed()) {
r.workers.Release()
return
}
defer func() {
if rec := recover(); rec != nil {
// once again - safety first
internal.Logger.Printf(context.Background(), "panic in reauth worker: %v", rec)
}
r.scheduledLock.Lock()
delete(r.scheduledReAuth, connID)
r.scheduledLock.Unlock()
r.workers.Release()
}()
// Create timeout context for connection acquisition
// This prevents indefinite waiting if the connection is stuck
ctx, cancel := context.WithTimeout(context.Background(), r.reAuthTimeout)
defer cancel()
// Try to acquire the connection for re-authentication
// We need to ensure the connection is IDLE (not IN_USE) before transitioning to UNUSABLE
// This prevents re-authentication from interfering with active commands
// Use AwaitAndTransition to wait for the connection to become IDLE
stateMachine := conn.GetStateMachine()
if stateMachine == nil {
// No state machine - should not happen, but handle gracefully
reAuthFn(pool.ErrConnUnusableTimeout)
return
}
// Use predefined slice to avoid allocation
_, err := stateMachine.AwaitAndTransition(ctx, pool.ValidFromIdle(), pool.StateUnusable)
if err != nil {
// Timeout or other error occurred, cannot acquire connection
reAuthFn(err)
return
}
// safety first
if !conn.IsClosed() {
// Successfully acquired the connection, perform reauth
reAuthFn(nil)
}
// Release the connection: transition from UNUSABLE back to IDLE
stateMachine.Transition(pool.StateIdle)
}()
}
// the reauth will happen in background, as far as the pool is concerned:
// pool the connection, don't remove it, no error
return true, false, nil
}
// OnRemove is called when a connection is removed from the pool.
//
// This hook cleans up all state associated with the connection:
// - Removes from shouldReAuth map (pending re-auth)
// - Removes from scheduledReAuth map (active re-auth)
// - Removes credentials listener from manager
//
// This prevents memory leaks and ensures that removed connections don't have
// lingering re-auth operations or listeners.
//
// Thread-safe: Called when connections are removed due to errors, timeouts, or pool closure.
func (r *ReAuthPoolHook) OnRemove(_ context.Context, conn *pool.Conn, _ error) {
connID := conn.GetID()
r.shouldReAuthLock.Lock()
r.scheduledLock.Lock()
delete(r.scheduledReAuth, connID)
delete(r.shouldReAuth, connID)
r.scheduledLock.Unlock()
r.shouldReAuthLock.Unlock()
if r.manager != nil {
r.manager.RemoveListener(connID)
}
}
var _ pool.PoolHook = (*ReAuthPoolHook)(nil)
+90
View File
@@ -0,0 +1,90 @@
package hashtag
import (
"strings"
"github.com/redis/go-redis/v9/internal/rand"
)
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.
// 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,
0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823,
0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12,
0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41,
0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70,
0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3,
0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92,
0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1,
0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0,
}
func Key(key string) string {
if s := strings.IndexByte(key, '{'); s > -1 {
if e := strings.IndexByte(key[s+1:], '}'); e > 0 {
return key[s+1 : s+e+1]
}
}
return key
}
func Present(key string) bool {
if key == "" {
return false
}
if s := strings.IndexByte(key, '{'); s > -1 {
if e := strings.IndexByte(key[s+1:], '}'); e > 0 {
return true
}
}
return false
}
func RandomSlot() int {
return rand.Intn(slotNumber)
}
// Slot returns a consistent slot number between 0 and 16383
// for any given string key.
func Slot(key string) int {
if key == "" {
return RandomSlot()
}
key = Key(key)
return int(crc16sum(key)) % slotNumber
}
func crc16sum(key string) (crc uint16) {
for i := 0; i < len(key); i++ {
crc = (crc << 8) ^ crc16tab[(byte(crc>>8)^key[i])&0x00ff]
}
return
}
+54
View File
@@ -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
}
+207
View File
@@ -0,0 +1,207 @@
package hscan
import (
"errors"
"fmt"
"reflect"
"strconv"
)
// decoderFunc represents decoding functions for default built-in types.
type decoderFunc func(reflect.Value, string) error
// Scanner is the interface implemented by themselves,
// which will override the decoding behavior of decoderFunc.
type Scanner interface {
ScanRedis(s string) error
}
var (
// List of built-in decoders indexed by their numeric constant values (eg: reflect.Bool = 1).
decoders = []decoderFunc{
reflect.Bool: decodeBool,
reflect.Int: decodeInt,
reflect.Int8: decodeInt8,
reflect.Int16: decodeInt16,
reflect.Int32: decodeInt32,
reflect.Int64: decodeInt64,
reflect.Uint: decodeUint,
reflect.Uint8: decodeUint8,
reflect.Uint16: decodeUint16,
reflect.Uint32: decodeUint32,
reflect.Uint64: decodeUint64,
reflect.Float32: decodeFloat32,
reflect.Float64: decodeFloat64,
reflect.Complex64: decodeUnsupported,
reflect.Complex128: decodeUnsupported,
reflect.Array: decodeUnsupported,
reflect.Chan: decodeUnsupported,
reflect.Func: decodeUnsupported,
reflect.Interface: decodeUnsupported,
reflect.Map: decodeUnsupported,
reflect.Ptr: decodeUnsupported,
reflect.Slice: decodeSlice,
reflect.String: decodeString,
reflect.Struct: decodeUnsupported,
reflect.UnsafePointer: decodeUnsupported,
}
// Global map of struct field specs that is populated once for every new
// struct type that is scanned. This caches the field types and the corresponding
// decoder functions to avoid iterating through struct fields on subsequent scans.
globalStructMap = newStructMap()
)
func Struct(dst interface{}) (StructValue, error) {
v := reflect.ValueOf(dst)
// The destination to scan into should be a struct pointer.
if v.Kind() != reflect.Ptr || v.IsNil() {
return StructValue{}, fmt.Errorf("redis.Scan(non-pointer %T)", dst)
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return StructValue{}, fmt.Errorf("redis.Scan(non-struct %T)", dst)
}
return StructValue{
spec: globalStructMap.get(v.Type()),
value: v,
}, nil
}
// Scan scans the results from a key-value Redis map result set to a destination struct.
// The Redis keys are matched to the struct's field with the `redis` tag.
func Scan(dst interface{}, keys []interface{}, vals []interface{}) error {
if len(keys) != len(vals) {
return errors.New("args should have the same number of keys and vals")
}
strct, err := Struct(dst)
if err != nil {
return err
}
// Iterate through the (key, value) sequence.
for i := 0; i < len(vals); i++ {
key, ok := keys[i].(string)
if !ok {
continue
}
val, ok := vals[i].(string)
if !ok {
continue
}
if err := strct.Scan(key, val); err != nil {
return err
}
}
return nil
}
func decodeBool(f reflect.Value, s string) error {
b, err := strconv.ParseBool(s)
if err != nil {
return err
}
f.SetBool(b)
return nil
}
func decodeInt8(f reflect.Value, s string) error {
return decodeNumber(f, s, 8)
}
func decodeInt16(f reflect.Value, s string) error {
return decodeNumber(f, s, 16)
}
func decodeInt32(f reflect.Value, s string) error {
return decodeNumber(f, s, 32)
}
func decodeInt64(f reflect.Value, s string) error {
return decodeNumber(f, s, 64)
}
func decodeInt(f reflect.Value, s string) error {
return decodeNumber(f, s, 0)
}
func decodeNumber(f reflect.Value, s string, bitSize int) error {
v, err := strconv.ParseInt(s, 10, bitSize)
if err != nil {
return err
}
f.SetInt(v)
return nil
}
func decodeUint8(f reflect.Value, s string) error {
return decodeUnsignedNumber(f, s, 8)
}
func decodeUint16(f reflect.Value, s string) error {
return decodeUnsignedNumber(f, s, 16)
}
func decodeUint32(f reflect.Value, s string) error {
return decodeUnsignedNumber(f, s, 32)
}
func decodeUint64(f reflect.Value, s string) error {
return decodeUnsignedNumber(f, s, 64)
}
func decodeUint(f reflect.Value, s string) error {
return decodeUnsignedNumber(f, s, 0)
}
func decodeUnsignedNumber(f reflect.Value, s string, bitSize int) error {
v, err := strconv.ParseUint(s, 10, bitSize)
if err != nil {
return err
}
f.SetUint(v)
return nil
}
func decodeFloat32(f reflect.Value, s string) error {
v, err := strconv.ParseFloat(s, 32)
if err != nil {
return err
}
f.SetFloat(v)
return nil
}
// although the default is float64, but we better define it.
func decodeFloat64(f reflect.Value, s string) error {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
f.SetFloat(v)
return nil
}
func decodeString(f reflect.Value, s string) error {
f.SetString(s)
return nil
}
func decodeSlice(f reflect.Value, s string) error {
// []byte slice ([]uint8).
if f.Type().Elem().Kind() == reflect.Uint8 {
f.SetBytes([]byte(s))
}
return nil
}
func decodeUnsupported(v reflect.Value, s string) error {
return fmt.Errorf("redis.Scan(unsupported %s)", v.Type())
}
+127
View File
@@ -0,0 +1,127 @@
package hscan
import (
"encoding"
"fmt"
"reflect"
"strings"
"sync"
"github.com/redis/go-redis/v9/internal/util"
)
// structMap contains the map of struct fields for target structs
// indexed by the struct type.
type structMap struct {
m sync.Map
}
func newStructMap() *structMap {
return new(structMap)
}
func (s *structMap) get(t reflect.Type) *structSpec {
if v, ok := s.m.Load(t); ok {
return v.(*structSpec)
}
spec := newStructSpec(t, "redis")
s.m.Store(t, spec)
return spec
}
//------------------------------------------------------------------------------
// structSpec contains the list of all fields in a target struct.
type structSpec struct {
m map[string]*structField
}
func (s *structSpec) set(tag string, sf *structField) {
s.m[tag] = sf
}
func newStructSpec(t reflect.Type, fieldTag string) *structSpec {
numField := t.NumField()
out := &structSpec{
m: make(map[string]*structField, numField),
}
for i := 0; i < numField; i++ {
f := t.Field(i)
tag := f.Tag.Get(fieldTag)
if tag == "" || tag == "-" {
continue
}
tag = strings.Split(tag, ",")[0]
if tag == "" {
continue
}
// Use the built-in decoder.
kind := f.Type.Kind()
if kind == reflect.Pointer {
kind = f.Type.Elem().Kind()
}
out.set(tag, &structField{index: i, fn: decoders[kind]})
}
return out
}
//------------------------------------------------------------------------------
// structField represents a single field in a target struct.
type structField struct {
index int
fn decoderFunc
}
//------------------------------------------------------------------------------
type StructValue struct {
spec *structSpec
value reflect.Value
}
func (s StructValue) Scan(key string, value string) error {
field, ok := s.spec.m[key]
if !ok {
return nil
}
v := s.value.Field(field.index)
isPtr := v.Kind() == reflect.Ptr
if isPtr && v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
if !isPtr && v.Type().Name() != "" && v.CanAddr() {
v = v.Addr()
isPtr = true
}
if isPtr && v.Type().NumMethod() > 0 && v.CanInterface() {
switch scan := v.Interface().(type) {
case Scanner:
return scan.ScanRedis(value)
case encoding.TextUnmarshaler:
return scan.UnmarshalText(util.StringToBytes(value))
case encoding.BinaryUnmarshaler:
return scan.UnmarshalBinary(util.StringToBytes(value))
}
}
if isPtr {
v = v.Elem()
}
if err := field.fn(v, value); err != nil {
t := s.value.Type()
return fmt.Errorf("cannot scan redis.result %s into struct field %s.%s of type %s, error-%s",
value, t.Name(), t.Field(field.index).Name, t.Field(field.index).Type, err.Error())
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
// Package interfaces provides shared interfaces used by both the main redis package
// and the maintnotifications upgrade package to avoid circular dependencies.
package interfaces
import (
"context"
"net"
"time"
)
// NotificationProcessor is (most probably) a push.NotificationProcessor
// forward declaration to avoid circular imports
type NotificationProcessor interface {
RegisterHandler(pushNotificationName string, handler interface{}, protected bool) error
UnregisterHandler(pushNotificationName string) error
GetHandler(pushNotificationName string) interface{}
}
// ClientInterface defines the interface that clients must implement for maintnotifications upgrades.
type ClientInterface interface {
// GetOptions returns the client options.
GetOptions() OptionsInterface
// GetPushProcessor returns the client's push notification processor.
GetPushProcessor() NotificationProcessor
}
// OptionsInterface defines the interface for client options.
// Uses an adapter pattern to avoid circular dependencies.
type OptionsInterface interface {
// GetReadTimeout returns the read timeout.
GetReadTimeout() time.Duration
// GetWriteTimeout returns the write timeout.
GetWriteTimeout() time.Duration
// GetNetwork returns the network type.
GetNetwork() string
// GetAddr returns the connection address.
GetAddr() string
// GetNodeAddress returns the address of the Redis node as reported by the server.
// For cluster clients, this is the endpoint from CLUSTER SLOTS before any transformation.
// For standalone clients, this defaults to Addr.
GetNodeAddress() string
// IsTLSEnabled returns true if TLS is enabled.
IsTLSEnabled() bool
// GetProtocol returns the protocol version.
GetProtocol() int
// GetPoolSize returns the connection pool size.
GetPoolSize() int
// NewDialer returns a new dialer function for the connection.
NewDialer() func(context.Context) (net.Conn, error)
}
+29
View File
@@ -0,0 +1,29 @@
package internal
import (
"time"
"github.com/redis/go-redis/v9/internal/rand"
)
func RetryBackoff(retry int, minBackoff, maxBackoff time.Duration) time.Duration {
if retry < 0 {
panic("not reached")
}
if minBackoff == 0 {
return 0
}
d := minBackoff << uint(retry)
if d < minBackoff {
return maxBackoff
}
d = minBackoff + time.Duration(rand.Int63n(int64(d)))
if d > maxBackoff || d < minBackoff {
d = maxBackoff
}
return d
}
+79
View File
@@ -0,0 +1,79 @@
package internal
import (
"context"
"fmt"
"log"
"os"
)
// TODO (ned): Revisit logging
// Add more standardized approach with log levels and configurability
type Logging interface {
Printf(ctx context.Context, format string, v ...interface{})
}
type DefaultLogger struct {
log *log.Logger
}
func (l *DefaultLogger) Printf(ctx context.Context, format string, v ...interface{}) {
_ = l.log.Output(2, fmt.Sprintf(format, v...))
}
func NewDefaultLogger() Logging {
return &DefaultLogger{
log: log.New(os.Stderr, "redis: ", log.LstdFlags|log.Lshortfile),
}
}
// Logger calls Output to print to the stderr.
// Arguments are handled in the manner of fmt.Print.
var Logger Logging = NewDefaultLogger()
var LogLevel LogLevelT = LogLevelError
// LogLevelT represents the logging level
type LogLevelT int
// Log level constants for the entire go-redis library
const (
LogLevelError LogLevelT = iota // 0 - errors only
LogLevelWarn // 1 - warnings and errors
LogLevelInfo // 2 - info, warnings, and errors
LogLevelDebug // 3 - debug, info, warnings, and errors
)
// String returns the string representation of the log level
func (l LogLevelT) String() string {
switch l {
case LogLevelError:
return "ERROR"
case LogLevelWarn:
return "WARN"
case LogLevelInfo:
return "INFO"
case LogLevelDebug:
return "DEBUG"
default:
return "UNKNOWN"
}
}
// IsValid returns true if the log level is valid
func (l LogLevelT) IsValid() bool {
return l >= LogLevelError && l <= LogLevelDebug
}
func (l LogLevelT) WarnOrAbove() bool {
return l >= LogLevelWarn
}
func (l LogLevelT) InfoOrAbove() bool {
return l >= LogLevelInfo
}
func (l LogLevelT) DebugOrAbove() bool {
return l >= LogLevelDebug
}
@@ -0,0 +1,663 @@
package logs
import (
"encoding/json"
"fmt"
"regexp"
"github.com/redis/go-redis/v9/internal"
)
// appendJSONIfDebug appends JSON data to a message only if the global log level is Debug
func appendJSONIfDebug(message string, data map[string]interface{}) string {
if internal.LogLevel.DebugOrAbove() {
jsonData, _ := json.Marshal(data)
return fmt.Sprintf("%s %s", message, string(jsonData))
}
return message
}
const (
// ========================================
// CIRCUIT_BREAKER.GO - Circuit breaker management
// ========================================
CircuitBreakerTransitioningToHalfOpenMessage = "circuit breaker transitioning to half-open"
CircuitBreakerOpenedMessage = "circuit breaker opened"
CircuitBreakerReopenedMessage = "circuit breaker reopened"
CircuitBreakerClosedMessage = "circuit breaker closed"
CircuitBreakerCleanupMessage = "circuit breaker cleanup"
CircuitBreakerOpenMessage = "circuit breaker is open, failing fast"
// ========================================
// CONFIG.GO - Configuration and debug
// ========================================
DebugLoggingEnabledMessage = "debug logging enabled"
ConfigDebugMessage = "config debug"
// ========================================
// ERRORS.GO - Error message constants
// ========================================
InvalidRelaxedTimeoutErrorMessage = "relaxed timeout must be greater than 0"
InvalidHandoffTimeoutErrorMessage = "handoff timeout must be greater than 0"
InvalidHandoffWorkersErrorMessage = "MaxWorkers must be greater than or equal to 0"
InvalidHandoffQueueSizeErrorMessage = "handoff queue size must be greater than 0"
InvalidPostHandoffRelaxedDurationErrorMessage = "post-handoff relaxed duration must be greater than or equal to 0"
InvalidEndpointTypeErrorMessage = "invalid endpoint type"
InvalidMaintNotificationsErrorMessage = "invalid maintenance notifications setting (must be 'disabled', 'enabled', or 'auto')"
InvalidHandoffRetriesErrorMessage = "MaxHandoffRetries must be between 1 and 10"
InvalidClientErrorMessage = "invalid client type"
InvalidNotificationErrorMessage = "invalid notification format"
MaxHandoffRetriesReachedErrorMessage = "max handoff retries reached"
HandoffQueueFullErrorMessage = "handoff queue is full, cannot queue new handoff requests - consider increasing HandoffQueueSize or MaxWorkers in configuration"
InvalidCircuitBreakerFailureThresholdErrorMessage = "circuit breaker failure threshold must be >= 1"
InvalidCircuitBreakerResetTimeoutErrorMessage = "circuit breaker reset timeout must be >= 0"
InvalidCircuitBreakerMaxRequestsErrorMessage = "circuit breaker max requests must be >= 1"
ConnectionMarkedForHandoffErrorMessage = "connection marked for handoff"
ConnectionInvalidHandoffStateErrorMessage = "connection is in invalid state for handoff"
ShutdownErrorMessage = "shutdown"
CircuitBreakerOpenErrorMessage = "circuit breaker is open, failing fast"
// ========================================
// EXAMPLE_HOOKS.GO - Example metrics hooks
// ========================================
MetricsHookProcessingNotificationMessage = "metrics hook processing"
MetricsHookRecordedErrorMessage = "metrics hook recorded error"
// ========================================
// HANDOFF_WORKER.GO - Connection handoff processing
// ========================================
HandoffStartedMessage = "handoff started"
HandoffFailedMessage = "handoff failed"
ConnectionNotMarkedForHandoffMessage = "is not marked for handoff and has no retries"
ConnectionNotMarkedForHandoffErrorMessage = "is not marked for handoff"
HandoffRetryAttemptMessage = "Performing handoff"
CannotQueueHandoffForRetryMessage = "can't queue handoff for retry"
HandoffQueueFullMessage = "handoff queue is full"
FailedToDialNewEndpointMessage = "failed to dial new endpoint"
ApplyingRelaxedTimeoutDueToPostHandoffMessage = "applying relaxed timeout due to post-handoff"
HandoffSuccessMessage = "handoff succeeded"
RemovingConnectionFromPoolMessage = "removing connection from pool"
NoPoolProvidedMessageCannotRemoveMessage = "no pool provided, cannot remove connection, closing it"
WorkerExitingDueToShutdownMessage = "worker exiting due to shutdown"
WorkerExitingDueToShutdownWhileProcessingMessage = "worker exiting due to shutdown while processing request"
WorkerPanicRecoveredMessage = "worker panic recovered"
WorkerExitingDueToInactivityTimeoutMessage = "worker exiting due to inactivity timeout"
ReachedMaxHandoffRetriesMessage = "reached max handoff retries"
// ========================================
// MANAGER.GO - Moving operation tracking and handler registration
// ========================================
DuplicateMovingOperationMessage = "duplicate MOVING operation ignored"
TrackingMovingOperationMessage = "tracking MOVING operation"
UntrackingMovingOperationMessage = "untracking MOVING operation"
OperationNotTrackedMessage = "operation not tracked"
FailedToRegisterHandlerMessage = "failed to register handler"
// ========================================
// HOOKS.GO - Notification processing hooks
// ========================================
ProcessingNotificationMessage = "processing notification started"
ProcessingNotificationFailedMessage = "proccessing notification failed"
ProcessingNotificationSucceededMessage = "processing notification succeeded"
// ========================================
// POOL_HOOK.GO - Pool connection management
// ========================================
FailedToQueueHandoffMessage = "failed to queue handoff"
MarkedForHandoffMessage = "connection marked for handoff"
// ========================================
// PUSH_NOTIFICATION_HANDLER.GO - Push notification validation and processing
// ========================================
InvalidNotificationFormatMessage = "invalid notification format"
InvalidNotificationTypeFormatMessage = "invalid notification type format"
InvalidSeqIDInMovingNotificationMessage = "invalid seqID in MOVING notification"
InvalidTimeSInMovingNotificationMessage = "invalid timeS in MOVING notification"
InvalidNewEndpointInMovingNotificationMessage = "invalid newEndpoint in MOVING notification"
NoConnectionInHandlerContextMessage = "no connection in handler context"
InvalidConnectionTypeInHandlerContextMessage = "invalid connection type in handler context"
SchedulingHandoffToCurrentEndpointMessage = "scheduling handoff to current endpoint"
RelaxedTimeoutDueToNotificationMessage = "applying relaxed timeout due to notification"
UnrelaxedTimeoutMessage = "clearing relaxed timeout"
ManagerNotInitializedMessage = "manager not initialized"
FailedToMarkForHandoffMessage = "failed to mark connection for handoff"
InvalidSeqIDInSMigratingNotificationMessage = "invalid SeqID in SMIGRATING notification"
InvalidSeqIDInSMigratedNotificationMessage = "invalid SeqID in SMIGRATED notification"
TriggeringClusterStateReloadMessage = "triggering cluster state reload"
// ========================================
// used in pool/conn
// ========================================
UnrelaxedTimeoutAfterDeadlineMessage = "clearing relaxed timeout after deadline"
)
func HandoffStarted(connID uint64, newEndpoint string) string {
message := fmt.Sprintf("conn[%d] %s to %s", connID, HandoffStartedMessage, newEndpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": newEndpoint,
})
}
func HandoffFailed(connID uint64, newEndpoint string, attempt int, maxAttempts int, err error) string {
message := fmt.Sprintf("conn[%d] %s to %s (attempt %d/%d): %v", connID, HandoffFailedMessage, newEndpoint, attempt, maxAttempts, err)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": newEndpoint,
"attempt": attempt,
"maxAttempts": maxAttempts,
"error": err.Error(),
})
}
func HandoffSucceeded(connID uint64, newEndpoint string) string {
message := fmt.Sprintf("conn[%d] %s to %s", connID, HandoffSuccessMessage, newEndpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": newEndpoint,
})
}
// Timeout-related log functions
func RelaxedTimeoutDueToNotification(connID uint64, notificationType string, timeout interface{}) string {
message := fmt.Sprintf("conn[%d] %s %s (%v)", connID, RelaxedTimeoutDueToNotificationMessage, notificationType, timeout)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"notificationType": notificationType,
"timeout": fmt.Sprintf("%v", timeout),
})
}
func UnrelaxedTimeout(connID uint64) string {
message := fmt.Sprintf("conn[%d] %s", connID, UnrelaxedTimeoutMessage)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
})
}
func UnrelaxedTimeoutAfterDeadline(connID uint64) string {
message := fmt.Sprintf("conn[%d] %s", connID, UnrelaxedTimeoutAfterDeadlineMessage)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
})
}
// Handoff queue and marking functions
func HandoffQueueFull(queueLen, queueCap int) string {
message := fmt.Sprintf("%s (%d/%d), cannot queue new handoff requests - consider increasing HandoffQueueSize or MaxWorkers in configuration", HandoffQueueFullMessage, queueLen, queueCap)
return appendJSONIfDebug(message, map[string]interface{}{
"queueLen": queueLen,
"queueCap": queueCap,
})
}
func FailedToQueueHandoff(connID uint64, err error) string {
message := fmt.Sprintf("conn[%d] %s: %v", connID, FailedToQueueHandoffMessage, err)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"error": err.Error(),
})
}
func FailedToMarkForHandoff(connID uint64, err error) string {
message := fmt.Sprintf("conn[%d] %s: %v", connID, FailedToMarkForHandoffMessage, err)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"error": err.Error(),
})
}
func FailedToDialNewEndpoint(connID uint64, endpoint string, err error) string {
message := fmt.Sprintf("conn[%d] %s %s: %v", connID, FailedToDialNewEndpointMessage, endpoint, err)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": endpoint,
"error": err.Error(),
})
}
func ReachedMaxHandoffRetries(connID uint64, endpoint string, maxRetries int) string {
message := fmt.Sprintf("conn[%d] %s to %s (max retries: %d)", connID, ReachedMaxHandoffRetriesMessage, endpoint, maxRetries)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": endpoint,
"maxRetries": maxRetries,
})
}
// Notification processing functions
func ProcessingNotification(connID uint64, seqID int64, notificationType string, notification interface{}) string {
message := fmt.Sprintf("conn[%d] seqID[%d] %s %s: %v", connID, seqID, ProcessingNotificationMessage, notificationType, notification)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"seqID": seqID,
"notificationType": notificationType,
"notification": fmt.Sprintf("%v", notification),
})
}
func ProcessingNotificationFailed(connID uint64, notificationType string, err error, notification interface{}) string {
message := fmt.Sprintf("conn[%d] %s %s: %v - %v", connID, ProcessingNotificationFailedMessage, notificationType, err, notification)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"notificationType": notificationType,
"error": err.Error(),
"notification": fmt.Sprintf("%v", notification),
})
}
func ProcessingNotificationSucceeded(connID uint64, notificationType string) string {
message := fmt.Sprintf("conn[%d] %s %s", connID, ProcessingNotificationSucceededMessage, notificationType)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"notificationType": notificationType,
})
}
// Moving operation tracking functions
func DuplicateMovingOperation(connID uint64, endpoint string, seqID int64) string {
message := fmt.Sprintf("conn[%d] %s for %s seqID[%d]", connID, DuplicateMovingOperationMessage, endpoint, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": endpoint,
"seqID": seqID,
})
}
func TrackingMovingOperation(connID uint64, endpoint string, seqID int64) string {
message := fmt.Sprintf("conn[%d] %s for %s seqID[%d]", connID, TrackingMovingOperationMessage, endpoint, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": endpoint,
"seqID": seqID,
})
}
func UntrackingMovingOperation(connID uint64, seqID int64) string {
message := fmt.Sprintf("conn[%d] %s seqID[%d]", connID, UntrackingMovingOperationMessage, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"seqID": seqID,
})
}
func OperationNotTracked(connID uint64, seqID int64) string {
message := fmt.Sprintf("conn[%d] %s seqID[%d]", connID, OperationNotTrackedMessage, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"seqID": seqID,
})
}
// Connection pool functions
func RemovingConnectionFromPool(connID uint64, reason error) string {
metadata := map[string]interface{}{
"connID": connID,
"reason": "unknown", // this will be overwritten if reason is not nil
}
if reason != nil {
metadata["reason"] = reason.Error()
}
message := fmt.Sprintf("conn[%d] %s due to: %v", connID, RemovingConnectionFromPoolMessage, reason)
return appendJSONIfDebug(message, metadata)
}
func NoPoolProvidedCannotRemove(connID uint64, reason error) string {
metadata := map[string]interface{}{
"connID": connID,
"reason": "unknown", // this will be overwritten if reason is not nil
}
if reason != nil {
metadata["reason"] = reason.Error()
}
message := fmt.Sprintf("conn[%d] %s due to: %v", connID, NoPoolProvidedMessageCannotRemoveMessage, reason)
return appendJSONIfDebug(message, metadata)
}
// Circuit breaker functions
func CircuitBreakerOpen(connID uint64, endpoint string) string {
message := fmt.Sprintf("conn[%d] %s for %s", connID, CircuitBreakerOpenMessage, endpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"endpoint": endpoint,
})
}
// Additional handoff functions for specific cases
func ConnectionNotMarkedForHandoff(connID uint64) string {
message := fmt.Sprintf("conn[%d] %s", connID, ConnectionNotMarkedForHandoffMessage)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
})
}
func ConnectionNotMarkedForHandoffError(connID uint64) string {
return fmt.Sprintf("conn[%d] %s", connID, ConnectionNotMarkedForHandoffErrorMessage)
}
func HandoffRetryAttempt(connID uint64, retries int, newEndpoint string, oldEndpoint string) string {
message := fmt.Sprintf("conn[%d] Retry %d: %s to %s(was %s)", connID, retries, HandoffRetryAttemptMessage, newEndpoint, oldEndpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"retries": retries,
"newEndpoint": newEndpoint,
"oldEndpoint": oldEndpoint,
})
}
func CannotQueueHandoffForRetry(err error) string {
message := fmt.Sprintf("%s: %v", CannotQueueHandoffForRetryMessage, err)
return appendJSONIfDebug(message, map[string]interface{}{
"error": err.Error(),
})
}
// Validation and error functions
func InvalidNotificationFormat(notification interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidNotificationFormatMessage, notification)
return appendJSONIfDebug(message, map[string]interface{}{
"notification": fmt.Sprintf("%v", notification),
})
}
func InvalidNotificationTypeFormat(notificationType interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidNotificationTypeFormatMessage, notificationType)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": fmt.Sprintf("%v", notificationType),
})
}
// InvalidNotification creates a log message for invalid notifications of any type
func InvalidNotification(notificationType string, notification interface{}) string {
message := fmt.Sprintf("invalid %s notification: %v", notificationType, notification)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
"notification": fmt.Sprintf("%v", notification),
})
}
func InvalidSeqIDInMovingNotification(seqID interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidSeqIDInMovingNotificationMessage, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"seqID": fmt.Sprintf("%v", seqID),
})
}
func InvalidTimeSInMovingNotification(timeS interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidTimeSInMovingNotificationMessage, timeS)
return appendJSONIfDebug(message, map[string]interface{}{
"timeS": fmt.Sprintf("%v", timeS),
})
}
func InvalidNewEndpointInMovingNotification(newEndpoint interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidNewEndpointInMovingNotificationMessage, newEndpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"newEndpoint": fmt.Sprintf("%v", newEndpoint),
})
}
func NoConnectionInHandlerContext(notificationType string) string {
message := fmt.Sprintf("%s for %s notification", NoConnectionInHandlerContextMessage, notificationType)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
})
}
func InvalidConnectionTypeInHandlerContext(notificationType string, conn interface{}, handlerCtx interface{}) string {
message := fmt.Sprintf("%s for %s notification - %T %#v", InvalidConnectionTypeInHandlerContextMessage, notificationType, conn, handlerCtx)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
"connType": fmt.Sprintf("%T", conn),
})
}
func SchedulingHandoffToCurrentEndpoint(connID uint64, seconds float64) string {
message := fmt.Sprintf("conn[%d] %s in %v seconds", connID, SchedulingHandoffToCurrentEndpointMessage, seconds)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"seconds": seconds,
})
}
func ManagerNotInitialized() string {
return appendJSONIfDebug(ManagerNotInitializedMessage, map[string]interface{}{})
}
func FailedToRegisterHandler(notificationType string, err error) string {
message := fmt.Sprintf("%s for %s: %v", FailedToRegisterHandlerMessage, notificationType, err)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
"error": err.Error(),
})
}
func ShutdownError() string {
return appendJSONIfDebug(ShutdownErrorMessage, map[string]interface{}{})
}
// Configuration validation error functions
func InvalidRelaxedTimeoutError() string {
return appendJSONIfDebug(InvalidRelaxedTimeoutErrorMessage, map[string]interface{}{})
}
func InvalidHandoffTimeoutError() string {
return appendJSONIfDebug(InvalidHandoffTimeoutErrorMessage, map[string]interface{}{})
}
func InvalidHandoffWorkersError() string {
return appendJSONIfDebug(InvalidHandoffWorkersErrorMessage, map[string]interface{}{})
}
func InvalidHandoffQueueSizeError() string {
return appendJSONIfDebug(InvalidHandoffQueueSizeErrorMessage, map[string]interface{}{})
}
func InvalidPostHandoffRelaxedDurationError() string {
return appendJSONIfDebug(InvalidPostHandoffRelaxedDurationErrorMessage, map[string]interface{}{})
}
func InvalidEndpointTypeError() string {
return appendJSONIfDebug(InvalidEndpointTypeErrorMessage, map[string]interface{}{})
}
func InvalidMaintNotificationsError() string {
return appendJSONIfDebug(InvalidMaintNotificationsErrorMessage, map[string]interface{}{})
}
func InvalidHandoffRetriesError() string {
return appendJSONIfDebug(InvalidHandoffRetriesErrorMessage, map[string]interface{}{})
}
func InvalidClientError() string {
return appendJSONIfDebug(InvalidClientErrorMessage, map[string]interface{}{})
}
func InvalidNotificationError() string {
return appendJSONIfDebug(InvalidNotificationErrorMessage, map[string]interface{}{})
}
func MaxHandoffRetriesReachedError() string {
return appendJSONIfDebug(MaxHandoffRetriesReachedErrorMessage, map[string]interface{}{})
}
func HandoffQueueFullError() string {
return appendJSONIfDebug(HandoffQueueFullErrorMessage, map[string]interface{}{})
}
func InvalidCircuitBreakerFailureThresholdError() string {
return appendJSONIfDebug(InvalidCircuitBreakerFailureThresholdErrorMessage, map[string]interface{}{})
}
func InvalidCircuitBreakerResetTimeoutError() string {
return appendJSONIfDebug(InvalidCircuitBreakerResetTimeoutErrorMessage, map[string]interface{}{})
}
func InvalidCircuitBreakerMaxRequestsError() string {
return appendJSONIfDebug(InvalidCircuitBreakerMaxRequestsErrorMessage, map[string]interface{}{})
}
// Configuration and debug functions
func DebugLoggingEnabled() string {
return appendJSONIfDebug(DebugLoggingEnabledMessage, map[string]interface{}{})
}
func ConfigDebug(config interface{}) string {
message := fmt.Sprintf("%s: %+v", ConfigDebugMessage, config)
return appendJSONIfDebug(message, map[string]interface{}{
"config": fmt.Sprintf("%+v", config),
})
}
// Handoff worker functions
func WorkerExitingDueToShutdown() string {
return appendJSONIfDebug(WorkerExitingDueToShutdownMessage, map[string]interface{}{})
}
func WorkerExitingDueToShutdownWhileProcessing() string {
return appendJSONIfDebug(WorkerExitingDueToShutdownWhileProcessingMessage, map[string]interface{}{})
}
func WorkerPanicRecovered(panicValue interface{}) string {
message := fmt.Sprintf("%s: %v", WorkerPanicRecoveredMessage, panicValue)
return appendJSONIfDebug(message, map[string]interface{}{
"panic": fmt.Sprintf("%v", panicValue),
})
}
func WorkerExitingDueToInactivityTimeout(timeout interface{}) string {
message := fmt.Sprintf("%s (%v)", WorkerExitingDueToInactivityTimeoutMessage, timeout)
return appendJSONIfDebug(message, map[string]interface{}{
"timeout": fmt.Sprintf("%v", timeout),
})
}
func ApplyingRelaxedTimeoutDueToPostHandoff(connID uint64, timeout interface{}, until string) string {
message := fmt.Sprintf("conn[%d] %s (%v) until %s", connID, ApplyingRelaxedTimeoutDueToPostHandoffMessage, timeout, until)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
"timeout": fmt.Sprintf("%v", timeout),
"until": until,
})
}
// Example hooks functions
func MetricsHookProcessingNotification(notificationType string, connID uint64) string {
message := fmt.Sprintf("%s %s notification on conn[%d]", MetricsHookProcessingNotificationMessage, notificationType, connID)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
"connID": connID,
})
}
func MetricsHookRecordedError(notificationType string, connID uint64, err error) string {
message := fmt.Sprintf("%s for %s notification on conn[%d]: %v", MetricsHookRecordedErrorMessage, notificationType, connID, err)
return appendJSONIfDebug(message, map[string]interface{}{
"notificationType": notificationType,
"connID": connID,
"error": err.Error(),
})
}
// Pool hook functions
func MarkedForHandoff(connID uint64) string {
message := fmt.Sprintf("conn[%d] %s", connID, MarkedForHandoffMessage)
return appendJSONIfDebug(message, map[string]interface{}{
"connID": connID,
})
}
// Circuit breaker additional functions
func CircuitBreakerTransitioningToHalfOpen(endpoint string) string {
message := fmt.Sprintf("%s for %s", CircuitBreakerTransitioningToHalfOpenMessage, endpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"endpoint": endpoint,
})
}
func CircuitBreakerOpened(endpoint string, failures int64) string {
message := fmt.Sprintf("%s for endpoint %s after %d failures", CircuitBreakerOpenedMessage, endpoint, failures)
return appendJSONIfDebug(message, map[string]interface{}{
"endpoint": endpoint,
"failures": failures,
})
}
func CircuitBreakerReopened(endpoint string) string {
message := fmt.Sprintf("%s for endpoint %s due to failure in half-open state", CircuitBreakerReopenedMessage, endpoint)
return appendJSONIfDebug(message, map[string]interface{}{
"endpoint": endpoint,
})
}
func CircuitBreakerClosed(endpoint string, successes int64) string {
message := fmt.Sprintf("%s for endpoint %s after %d successful requests", CircuitBreakerClosedMessage, endpoint, successes)
return appendJSONIfDebug(message, map[string]interface{}{
"endpoint": endpoint,
"successes": successes,
})
}
func CircuitBreakerCleanup(removed int, total int) string {
message := fmt.Sprintf("%s removed %d/%d entries", CircuitBreakerCleanupMessage, removed, total)
return appendJSONIfDebug(message, map[string]interface{}{
"removed": removed,
"total": total,
})
}
// ExtractDataFromLogMessage extracts structured data from maintnotifications log messages
// Returns a map containing the parsed key-value pairs from the structured data section
// Example: "conn[123] handoff started to localhost:6379 {"connID":123,"endpoint":"localhost:6379"}"
// Returns: map[string]interface{}{"connID": 123, "endpoint": "localhost:6379"}
func ExtractDataFromLogMessage(logMessage string) map[string]interface{} {
result := make(map[string]interface{})
// Find the JSON data section at the end of the message
re := regexp.MustCompile(`(\{.*\})$`)
matches := re.FindStringSubmatch(logMessage)
if len(matches) < 2 {
return result
}
jsonStr := matches[1]
if jsonStr == "" {
return result
}
// Parse the JSON directly
var jsonResult map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonResult); err == nil {
return jsonResult
}
// If JSON parsing fails, return empty map
return result
}
// Cluster notification functions
func InvalidSeqIDInSMigratingNotification(seqID interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidSeqIDInSMigratingNotificationMessage, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"seqID": fmt.Sprintf("%v", seqID),
})
}
func InvalidSeqIDInSMigratedNotification(seqID interface{}) string {
message := fmt.Sprintf("%s: %v", InvalidSeqIDInSMigratedNotificationMessage, seqID)
return appendJSONIfDebug(message, map[string]interface{}{
"seqID": fmt.Sprintf("%v", seqID),
})
}
// TriggeringClusterStateReload logs when cluster state reload is triggered (deduplicated, once per seqID)
func TriggeringClusterStateReload(seqID int64, hostPort string, slotRanges []string) string {
message := fmt.Sprintf("%s seqID=%d host:port=%s slots=%v", TriggeringClusterStateReloadMessage, seqID, hostPort, slotRanges)
return appendJSONIfDebug(message, map[string]interface{}{
"seqID": seqID,
"hostPort": hostPort,
"slotRanges": slotRanges,
})
}
+63
View File
@@ -0,0 +1,63 @@
/*
Copyright 2014 The Camlistore Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
import (
"sync"
"sync/atomic"
)
// A Once will perform a successful action exactly once.
//
// Unlike a sync.Once, this Once's func returns an error
// and is re-armed on failure.
type Once struct {
m sync.Mutex
done uint32
}
// Do calls the function f if and only if Do has not been invoked
// without error for this instance of Once. In other words, given
//
// var once Once
//
// if once.Do(f) is called multiple times, only the first call will
// invoke f, even if f has a different value in each invocation unless
// f returns an error. A new instance of Once is required for each
// function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
//
// err := config.once.Do(func() error { return config.init(filename) })
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&o.done) == 1 {
return nil
}
// Slow-path.
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 {
err = f()
if err == nil {
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
+298
View File
@@ -0,0 +1,298 @@
package otel
import (
"context"
"crypto/rand"
"encoding/hex"
"sync"
"time"
"github.com/redis/go-redis/v9/internal/pool"
)
// generateUniqueID generates a short unique identifier for pool names.
func generateUniqueID() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
// Cmder is a minimal interface for command information needed for metrics.
// This avoids circular dependencies with the main redis package.
type Cmder interface {
Name() string
FullName() string
Args() []interface{}
Err() error
}
// Recorder is the interface for recording metrics.
type Recorder interface {
// RecordOperationDuration records the total operation duration (including all retries)
// dbIndex is the Redis database index (0-15)
RecordOperationDuration(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn *pool.Conn, dbIndex int)
// RecordPipelineOperationDuration records the total pipeline/transaction duration.
// operationName should be "PIPELINE" for regular pipelines or "MULTI" for transactions.
// cmdCount is the number of commands in the pipeline.
// err is the error from the pipeline execution (can be nil).
// dbIndex is the Redis database index (0-15)
RecordPipelineOperationDuration(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int)
// RecordConnectionCreateTime records the time it took to create a new connection
RecordConnectionCreateTime(ctx context.Context, duration time.Duration, cn *pool.Conn)
// RecordConnectionRelaxedTimeout records when connection timeout is relaxed/unrelaxed
// delta: +1 for relaxed, -1 for unrelaxed
// poolName: name of the connection pool (e.g., "main", "pubsub")
// notificationType: the notification type that triggered the timeout relaxation (e.g., "MOVING")
RecordConnectionRelaxedTimeout(ctx context.Context, delta int, cn *pool.Conn, poolName, notificationType string)
// RecordConnectionHandoff records when a connection is handed off to another node
// poolName: name of the connection pool (e.g., "main", "pubsub")
RecordConnectionHandoff(ctx context.Context, cn *pool.Conn, poolName string)
// RecordError records client errors (ASK, MOVED, handshake failures, etc.)
// errorType: type of error (e.g., "ASK", "MOVED", "HANDSHAKE_FAILED")
// statusCode: Redis response status code if available (e.g., "MOVED", "ASK")
// isInternal: whether this is an internal error
// retryAttempts: number of retry attempts made
RecordError(ctx context.Context, errorType string, cn *pool.Conn, statusCode string, isInternal bool, retryAttempts int)
// RecordMaintenanceNotification records when a maintenance notification is received
// notificationType: the type of notification (e.g., "MOVING", "MIGRATING", etc.)
RecordMaintenanceNotification(ctx context.Context, cn *pool.Conn, notificationType string)
// RecordConnectionWaitTime records the time spent waiting for a connection from the pool
RecordConnectionWaitTime(ctx context.Context, duration time.Duration, cn *pool.Conn)
// RecordConnectionClosed records when a connection is closed
// reason: reason for closing (e.g., "idle", "max_lifetime", "error", "pool_closed")
// err: the error that caused the close (nil for non-error closures)
RecordConnectionClosed(ctx context.Context, cn *pool.Conn, reason string, err error)
// RecordPubSubMessage records a Pub/Sub message
// direction: "sent" or "received"
// channel: channel name (may be hidden for cardinality reduction)
// sharded: true for sharded pub/sub (SPUBLISH/SSUBSCRIBE)
RecordPubSubMessage(ctx context.Context, cn *pool.Conn, direction, channel string, sharded bool)
// RecordStreamLag records the lag for stream consumer group processing
// lag: time difference between message creation and consumption
// streamName: name of the stream (may be hidden for cardinality reduction)
// 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 {
Stats() *pool.PubSubStats
}
type PoolRegistrar interface {
// RegisterPool is called when a new client is created with its connection pools.
// poolName: identifier for the pool (e.g., "main_abc123")
// pool: the connection pool
RegisterPool(poolName string, pool pool.Pooler)
// UnregisterPool is called when a client is closed to remove its pool from the registry.
// pool: the connection pool to unregister
UnregisterPool(pool pool.Pooler)
// RegisterPubSubPool is called when a new client is created with a PubSub pool.
// poolName: identifier for the pool (e.g., "main_abc123_pubsub")
// pool: the PubSub connection pool
RegisterPubSubPool(poolName string, pool PubSubPooler)
// UnregisterPubSubPool is called when a PubSub client is closed to remove its pool.
// pool: the PubSub connection pool to unregister
UnregisterPubSubPool(pool PubSubPooler)
}
var (
// recorderMu protects globalRecorder and operation duration callbacks
recorderMu sync.RWMutex
// Global recorder instance (initialized by extra/redisotel-native)
globalRecorder Recorder = noopRecorder{}
// Callbacks for operation duration metrics
operationDurationCallback func(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn *pool.Conn, dbIndex int)
pipelineOperationDurationCallback func(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int)
)
// GetOperationDurationCallback returns the callback for operation duration.
func GetOperationDurationCallback() func(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn *pool.Conn, dbIndex int) {
recorderMu.RLock()
cb := operationDurationCallback
recorderMu.RUnlock()
return cb
}
// GetPipelineOperationDurationCallback returns the callback for pipeline operation duration.
func GetPipelineOperationDurationCallback() func(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int) {
recorderMu.RLock()
cb := pipelineOperationDurationCallback
recorderMu.RUnlock()
return cb
}
// getRecorder returns the current global recorder under a read lock.
func getRecorder() Recorder {
recorderMu.RLock()
r := globalRecorder
recorderMu.RUnlock()
return r
}
// SetGlobalRecorder sets the global recorder (called by Init() in extra/redisotel-native)
func SetGlobalRecorder(r Recorder) {
recorderMu.Lock()
if r == nil {
globalRecorder = noopRecorder{}
operationDurationCallback = nil
pipelineOperationDurationCallback = nil
recorderMu.Unlock()
// Unregister all pool metric callbacks atomically
pool.SetAllMetricCallbacks(nil)
return
}
globalRecorder = r
// Register operation duration callbacks
// These capture r directly since we want them to use the specific recorder
// that was set at this point in time
operationDurationCallback = func(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn *pool.Conn, dbIndex int) {
getRecorder().RecordOperationDuration(ctx, duration, cmd, attempts, err, cn, dbIndex)
}
pipelineOperationDurationCallback = func(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int) {
getRecorder().RecordPipelineOperationDuration(ctx, duration, operationName, cmdCount, attempts, err, cn, dbIndex)
}
recorderMu.Unlock()
// Register all pool metric callbacks atomically
// These use getRecorder() to safely access the current recorder
pool.SetAllMetricCallbacks(&pool.MetricCallbacks{
ConnectionCreateTime: func(ctx context.Context, duration time.Duration, cn *pool.Conn) {
getRecorder().RecordConnectionCreateTime(ctx, duration, cn)
},
ConnectionRelaxedTimeout: func(ctx context.Context, delta int, cn *pool.Conn, poolName, notificationType string) {
getRecorder().RecordConnectionRelaxedTimeout(ctx, delta, cn, poolName, notificationType)
},
ConnectionHandoff: func(ctx context.Context, cn *pool.Conn, poolName string) {
getRecorder().RecordConnectionHandoff(ctx, cn, poolName)
},
Error: func(ctx context.Context, errorType string, cn *pool.Conn, statusCode string, isInternal bool, retryAttempts int) {
getRecorder().RecordError(ctx, errorType, cn, statusCode, isInternal, retryAttempts)
},
MaintenanceNotification: func(ctx context.Context, cn *pool.Conn, notificationType string) {
getRecorder().RecordMaintenanceNotification(ctx, cn, notificationType)
},
ConnectionWaitTime: func(ctx context.Context, duration time.Duration, cn *pool.Conn) {
getRecorder().RecordConnectionWaitTime(ctx, duration, cn)
},
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)
},
})
}
// RecordOperationDuration records the total operation duration.
// dbIndex is the Redis database index (0-15).
func RecordOperationDuration(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn *pool.Conn, dbIndex int) {
getRecorder().RecordOperationDuration(ctx, duration, cmd, attempts, err, cn, dbIndex)
}
// RecordPipelineOperationDuration records the total pipeline/transaction duration.
// This is called from redis.go after pipeline/transaction execution completes.
// operationName should be "PIPELINE" for regular pipelines or "MULTI" for transactions.
// err is the error from the pipeline execution (can be nil).
// dbIndex is the Redis database index (0-15).
func RecordPipelineOperationDuration(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int) {
getRecorder().RecordPipelineOperationDuration(ctx, duration, operationName, cmdCount, attempts, err, cn, dbIndex)
}
// RecordConnectionCreateTime records the time it took to create a new connection.
func RecordConnectionCreateTime(ctx context.Context, duration time.Duration, cn *pool.Conn) {
getRecorder().RecordConnectionCreateTime(ctx, duration, cn)
}
// RecordPubSubMessage records a Pub/Sub message sent or received.
func RecordPubSubMessage(ctx context.Context, cn *pool.Conn, direction, channel string, sharded bool) {
getRecorder().RecordPubSubMessage(ctx, cn, direction, channel, sharded)
}
// RecordStreamLag records the lag between message creation and consumption in a stream.
func RecordStreamLag(ctx context.Context, lag time.Duration, cn *pool.Conn, streamName, consumerGroup, consumerName string) {
getRecorder().RecordStreamLag(ctx, lag, cn, streamName, consumerGroup, consumerName)
}
type noopRecorder struct{}
func (noopRecorder) RecordOperationDuration(context.Context, time.Duration, Cmder, int, error, *pool.Conn, int) {
}
func (noopRecorder) RecordPipelineOperationDuration(context.Context, time.Duration, string, int, int, error, *pool.Conn, int) {
}
func (noopRecorder) RecordConnectionCreateTime(context.Context, time.Duration, *pool.Conn) {}
func (noopRecorder) RecordConnectionRelaxedTimeout(context.Context, int, *pool.Conn, string, string) {
}
func (noopRecorder) RecordConnectionHandoff(context.Context, *pool.Conn, string) {}
func (noopRecorder) RecordError(context.Context, string, *pool.Conn, string, bool, int) {}
func (noopRecorder) RecordMaintenanceNotification(context.Context, *pool.Conn, string) {}
func (noopRecorder) RecordConnectionWaitTime(context.Context, time.Duration, *pool.Conn) {}
func (noopRecorder) RecordConnectionClosed(context.Context, *pool.Conn, string, error) {}
func (noopRecorder) RecordPubSubMessage(context.Context, *pool.Conn, string, string, bool) {}
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) {
// Check if the global recorder implements PoolRegistrar
if registrar, ok := globalRecorder.(PoolRegistrar); ok {
// Generate a unique ID for this client's pools
uniqueID := generateUniqueID()
if connPool != nil {
poolName := addr + "_" + uniqueID
registrar.RegisterPool(poolName, connPool)
}
if pubSubPool != nil {
poolName := addr + "_" + uniqueID + "_pubsub"
registrar.RegisterPubSubPool(poolName, pubSubPool)
}
}
}
// UnregisterPools removes connection pools from the global recorder
func UnregisterPools(connPool pool.Pooler, pubSubPool PubSubPooler) {
// Check if the global recorder implements PoolRegistrar
if registrar, ok := globalRecorder.(PoolRegistrar); ok {
if connPool != nil {
registrar.UnregisterPool(connPool)
}
if pubSubPool != nil {
registrar.UnregisterPubSubPool(pubSubPool)
}
}
}
+990
View File
@@ -0,0 +1,990 @@
// Package pool implements the pool management
package pool
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"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{}
// Preallocated errors for hot paths to avoid allocations
var (
errAlreadyMarkedForHandoff = errors.New("connection is already marked for handoff")
errNotMarkedForHandoff = errors.New("connection was not marked for handoff")
errHandoffStateChanged = errors.New("handoff state changed during marking")
errConnectionNotAvailable = errors.New("redis: connection not available")
errConnNotAvailableForWrite = errors.New("redis: connection not available for write operation")
)
// getCachedTimeNs returns the current time in nanoseconds.
// This function previously used a global cache updated by a background goroutine,
// but that caused unnecessary CPU usage when the client was idle (ticker waking up
// the scheduler every 50ms). We now use time.Now() directly, which is fast enough
// on modern systems (vDSO on Linux) and only adds ~1-2% overhead in extreme
// high-concurrency benchmarks while eliminating idle CPU usage.
func getCachedTimeNs() int64 {
return time.Now().UnixNano()
}
// GetCachedTimeNs returns the current time in nanoseconds.
// Exported for use by other packages that need fast time access.
func GetCachedTimeNs() int64 {
return getCachedTimeNs()
}
// Global atomic counter for connection IDs
var connIDCounter uint64
// HandoffState represents the atomic state for connection handoffs
// This struct is stored atomically to prevent race conditions between
// checking handoff status and reading handoff parameters
type HandoffState struct {
ShouldHandoff bool // Whether connection should be handed off
Endpoint string // New endpoint for handoff
SeqID int64 // Sequence ID from MOVING notification
}
// atomicNetConn is a wrapper to ensure consistent typing in atomic.Value
type atomicNetConn struct {
conn net.Conn
}
// generateConnID generates a fast unique identifier for a connection with zero allocations
func generateConnID() uint64 {
return atomic.AddUint64(&connIDCounter, 1)
}
type Conn struct {
// Connection identifier for unique tracking
id uint64
usedAt atomic.Int64
lastPutAt atomic.Int64
dialStartNs atomic.Int64 // Time when dial started (for connection create time metric)
// Lock-free netConn access using atomic.Value
// Contains *atomicNetConn wrapper, accessed atomically for better performance
netConnAtomic atomic.Value // stores *atomicNetConn
rd *proto.Reader
bw *bufio.Writer
wr *proto.Writer
// Lightweight mutex to protect reader operations during handoff
// Only used for the brief period during SetNetConn and HasBufferedData/PeekReplyTypeSafe
readerMu sync.RWMutex
// State machine for connection state management
// Replaces: usable, Inited, used
// Provides thread-safe state transitions with FIFO waiting queue
// States: CREATED → INITIALIZING → IDLE ⇄ IN_USE
// ↓
// UNUSABLE (handoff/reauth)
// ↓
// IDLE/CLOSED
stateMachine *ConnStateMachine
// Handoff metadata - managed separately from state machine
// These are atomic for lock-free access during handoff operations
handoffStateAtomic atomic.Value // stores *HandoffState
handoffRetriesAtomic atomic.Uint32 // retry counter
pooled bool
pubsub 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
relaxedReadTimeoutNs atomic.Int64 // time.Duration as nanoseconds
relaxedWriteTimeoutNs atomic.Int64 // time.Duration as nanoseconds
relaxedDeadlineNs atomic.Int64 // time.Time as nanoseconds since epoch
// Counter to track multiple relaxed timeout setters if we have nested calls
// will be decremented when ClearRelaxedTimeout is called or deadline is reached
// if counter reaches 0, we clear the relaxed timeouts
relaxedCounter atomic.Int32
// Connection initialization function for reconnections
initConnFunc func(context.Context, *Conn) error
onClose func() error
}
func NewConn(netConn net.Conn) *Conn {
return NewConnWithBufferSize(netConn, proto.DefaultBufferSize, proto.DefaultBufferSize)
}
func NewConnWithBufferSize(netConn net.Conn, readBufSize, writeBufSize int) *Conn {
now := time.Now()
cn := &Conn{
createdAt: now,
id: generateConnID(), // Generate unique ID for this connection
stateMachine: NewConnStateMachine(),
}
// Use specified buffer sizes, or fall back to 32KiB defaults if 0
if readBufSize > 0 {
cn.rd = proto.NewReaderSize(netConn, readBufSize)
} else {
cn.rd = proto.NewReader(netConn) // Uses 32KiB default
}
if writeBufSize > 0 {
cn.bw = bufio.NewWriterSize(netConn, writeBufSize)
} else {
cn.bw = bufio.NewWriterSize(netConn, proto.DefaultBufferSize)
}
// Store netConn atomically for lock-free access using wrapper
cn.netConnAtomic.Store(&atomicNetConn{conn: netConn})
cn.wr = proto.NewWriter(cn.bw)
cn.SetUsedAt(now)
// Initialize handoff state atomically
initialHandoffState := &HandoffState{
ShouldHandoff: false,
Endpoint: "",
SeqID: 0,
}
cn.handoffStateAtomic.Store(initialHandoffState)
return cn
}
func (cn *Conn) UsedAt() time.Time {
return time.Unix(0, cn.usedAt.Load())
}
func (cn *Conn) SetUsedAt(tm time.Time) {
cn.usedAt.Store(tm.UnixNano())
}
func (cn *Conn) UsedAtNs() int64 {
return cn.usedAt.Load()
}
func (cn *Conn) SetUsedAtNs(ns int64) {
cn.usedAt.Store(ns)
}
func (cn *Conn) LastPutAtNs() int64 {
return cn.lastPutAt.Load()
}
func (cn *Conn) SetLastPutAtNs(ns int64) {
cn.lastPutAt.Store(ns)
}
// GetDialStartNs returns the time when the dial started (in nanoseconds since epoch).
// This is used to calculate the full connection creation time (TCP + handshake).
func (cn *Conn) GetDialStartNs() int64 {
return cn.dialStartNs.Load()
}
// PoolName returns the name of the pool this connection belongs to.
// This is used for metrics to identify which pool a connection is from.
func (cn *Conn) PoolName() string {
return cn.poolName
}
// SetPoolName sets the name of the pool this connection belongs to.
// This should be called when the connection is added to a pool.
func (cn *Conn) SetPoolName(name string) {
cn.poolName = name
}
// Backward-compatible wrapper methods for state machine
// These maintain the existing API while using the new state machine internally
// CompareAndSwapUsable atomically compares and swaps the usable flag (lock-free).
//
// This is used by background operations (handoff, re-auth) to acquire exclusive
// access to a connection. The operation sets usable to false, preventing the pool
// from returning the connection to clients.
//
// Returns true if the swap was successful (old value matched), false otherwise.
//
// Implementation note: This is a compatibility wrapper around the state machine.
// It checks if the current state is "usable" (IDLE or IN_USE) and transitions accordingly.
// Deprecated: Use GetStateMachine().TryTransition() directly for better state management.
func (cn *Conn) CompareAndSwapUsable(old, new bool) bool {
currentState := cn.stateMachine.GetState()
// Check if current state matches the "old" usable value
currentUsable := (currentState == StateIdle || currentState == StateInUse)
if currentUsable != old {
return false
}
// If we're trying to set to the same value, succeed immediately
if old == new {
return true
}
// Transition based on new value
if new {
// Trying to make usable - transition from UNUSABLE to IDLE
// This should only work from UNUSABLE or INITIALIZING states
// Use predefined slice to avoid allocation
_, err := cn.stateMachine.TryTransition(
validFromInitializingOrUnusable,
StateIdle,
)
return err == nil
}
// Trying to make unusable - transition from IDLE to UNUSABLE
// This is typically for acquiring the connection for background operations
// Use predefined slice to avoid allocation
_, err := cn.stateMachine.TryTransition(
validFromIdle,
StateUnusable,
)
return err == nil
}
// IsUsable returns true if the connection is safe to use for new commands (lock-free).
//
// A connection is "usable" when it's in a stable state and can be returned to clients.
// It becomes unusable during:
// - Handoff operations (network connection replacement)
// - Re-authentication (credential updates)
// - Other background operations that need exclusive access
//
// Note: CREATED state is considered usable because new connections need to pass OnGet() hook
// before initialization. The initialization happens after OnGet() in the client code.
func (cn *Conn) IsUsable() bool {
state := cn.stateMachine.GetState()
// CREATED, IDLE, and IN_USE states are considered usable
// CREATED: new connection, not yet initialized (will be initialized by client)
// IDLE: initialized and ready to be acquired
// IN_USE: usable but currently acquired by someone
return state == StateCreated || state == StateIdle || state == StateInUse
}
// SetUsable sets the usable flag for the connection (lock-free).
//
// Deprecated: Use GetStateMachine().Transition() directly for better state management.
// This method is kept for backwards compatibility.
//
// This should be called to mark a connection as usable after initialization or
// to release it after a background operation completes.
//
// Prefer CompareAndSwapUsable() when acquiring exclusive access to avoid race conditions.
// Deprecated: Use GetStateMachine().Transition() directly for better state management.
func (cn *Conn) SetUsable(usable bool) {
if usable {
// Transition to IDLE state (ready to be acquired)
cn.stateMachine.Transition(StateIdle)
} else {
// Transition to UNUSABLE state (for background operations)
cn.stateMachine.Transition(StateUnusable)
}
}
// IsInited returns true if the connection has been initialized.
// This is a backward-compatible wrapper around the state machine.
func (cn *Conn) IsInited() bool {
state := cn.stateMachine.GetState()
// Connection is initialized if it's in IDLE or any post-initialization state
return state != StateCreated && state != StateInitializing && state != StateClosed
}
// Used - State machine based implementation
// CompareAndSwapUsed atomically compares and swaps the used flag (lock-free).
// This method is kept for backwards compatibility.
//
// This is the preferred method for acquiring a connection from the pool, as it
// ensures that only one goroutine marks the connection as used.
//
// Implementation: Uses state machine transitions IDLE ⇄ IN_USE
//
// Returns true if the swap was successful (old value matched), false otherwise.
// Deprecated: Use GetStateMachine().TryTransition() directly for better state management.
func (cn *Conn) CompareAndSwapUsed(old, new bool) bool {
if old == new {
// No change needed
currentState := cn.stateMachine.GetState()
currentUsed := (currentState == StateInUse)
return currentUsed == old
}
if !old && new {
// Acquiring: IDLE → IN_USE
// Use predefined slice to avoid allocation
_, err := cn.stateMachine.TryTransition(validFromCreatedOrIdle, StateInUse)
return err == nil
} else {
// Releasing: IN_USE → IDLE
// Use predefined slice to avoid allocation
_, err := cn.stateMachine.TryTransition(validFromInUse, StateIdle)
return err == nil
}
}
// IsUsed returns true if the connection is currently in use (lock-free).
//
// Deprecated: Use GetStateMachine().GetState() == StateInUse directly for better clarity.
// This method is kept for backwards compatibility.
//
// A connection is "used" when it has been retrieved from the pool and is
// actively processing a command. Background operations (like re-auth) should
// wait until the connection is not used before executing commands.
func (cn *Conn) IsUsed() bool {
return cn.stateMachine.GetState() == StateInUse
}
// SetUsed sets the used flag for the connection (lock-free).
//
// This should be called when returning a connection to the pool (set to false)
// or when a single-connection pool retrieves its connection (set to true).
//
// Prefer CompareAndSwapUsed() when acquiring from a multi-connection pool to
// avoid race conditions.
// Deprecated: Use GetStateMachine().Transition() directly for better state management.
func (cn *Conn) SetUsed(val bool) {
if val {
cn.stateMachine.Transition(StateInUse)
} else {
cn.stateMachine.Transition(StateIdle)
}
}
// getNetConn returns the current network connection using atomic load (lock-free).
// This is the fast path for accessing netConn without mutex overhead.
func (cn *Conn) getNetConn() net.Conn {
if v := cn.netConnAtomic.Load(); v != nil {
if wrapper, ok := v.(*atomicNetConn); ok {
return wrapper.conn
}
}
return nil
}
// setNetConn stores the network connection atomically (lock-free).
// This is used for the fast path of connection replacement.
func (cn *Conn) setNetConn(netConn net.Conn) {
cn.netConnAtomic.Store(&atomicNetConn{conn: netConn})
}
// Handoff state management - atomic access to handoff metadata
// ShouldHandoff returns true if connection needs handoff (lock-free).
func (cn *Conn) ShouldHandoff() bool {
if v := cn.handoffStateAtomic.Load(); v != nil {
return v.(*HandoffState).ShouldHandoff
}
return false
}
// GetHandoffEndpoint returns the new endpoint for handoff (lock-free).
func (cn *Conn) GetHandoffEndpoint() string {
if v := cn.handoffStateAtomic.Load(); v != nil {
return v.(*HandoffState).Endpoint
}
return ""
}
// GetMovingSeqID returns the sequence ID from the MOVING notification (lock-free).
func (cn *Conn) GetMovingSeqID() int64 {
if v := cn.handoffStateAtomic.Load(); v != nil {
return v.(*HandoffState).SeqID
}
return 0
}
// GetHandoffInfo returns all handoff information atomically (lock-free).
// This method prevents race conditions by returning all handoff state in a single atomic operation.
// Returns (shouldHandoff, endpoint, seqID).
func (cn *Conn) GetHandoffInfo() (bool, string, int64) {
if v := cn.handoffStateAtomic.Load(); v != nil {
state := v.(*HandoffState)
return state.ShouldHandoff, state.Endpoint, state.SeqID
}
return false, "", 0
}
// HandoffRetries returns the current handoff retry count (lock-free).
func (cn *Conn) HandoffRetries() int {
return int(cn.handoffRetriesAtomic.Load())
}
// IncrementAndGetHandoffRetries atomically increments and returns handoff retries (lock-free).
func (cn *Conn) IncrementAndGetHandoffRetries(n int) int {
return int(cn.handoffRetriesAtomic.Add(uint32(n)))
}
// IsPooled returns true if the connection is managed by a pool and will be pooled on Put.
func (cn *Conn) IsPooled() bool {
return cn.pooled
}
// IsPubSub returns true if the connection is used for PubSub.
func (cn *Conn) IsPubSub() bool {
return cn.pubsub
}
// SetRelaxedTimeout sets relaxed timeouts for this connection during maintenanceNotifications upgrades.
// These timeouts will be used for all subsequent commands until the deadline expires.
// Uses atomic operations for lock-free access.
// Note: Metrics should be recorded by the caller (notification handler) which has context about
// the notification type and pool name.
func (cn *Conn) SetRelaxedTimeout(readTimeout, writeTimeout time.Duration) {
cn.relaxedCounter.Add(1)
cn.relaxedReadTimeoutNs.Store(int64(readTimeout))
cn.relaxedWriteTimeoutNs.Store(int64(writeTimeout))
}
// SetRelaxedTimeoutWithDeadline sets relaxed timeouts with an expiration deadline.
// After the deadline, timeouts automatically revert to normal values.
// Uses atomic operations for lock-free access.
func (cn *Conn) SetRelaxedTimeoutWithDeadline(readTimeout, writeTimeout time.Duration, deadline time.Time) {
cn.SetRelaxedTimeout(readTimeout, writeTimeout)
cn.relaxedDeadlineNs.Store(deadline.UnixNano())
}
// ClearRelaxedTimeout removes relaxed timeouts, returning to normal timeout behavior.
// Uses atomic operations for lock-free access.
func (cn *Conn) ClearRelaxedTimeout() {
// Atomically decrement counter and check if we should clear
newCount := cn.relaxedCounter.Add(-1)
deadlineNs := cn.relaxedDeadlineNs.Load()
if newCount <= 0 && (deadlineNs == 0 || time.Now().UnixNano() >= deadlineNs) {
// Use atomic load to get current value for CAS to avoid stale value race
current := cn.relaxedCounter.Load()
if current <= 0 && cn.relaxedCounter.CompareAndSwap(current, 0) {
cn.clearRelaxedTimeout()
}
}
}
func (cn *Conn) clearRelaxedTimeout() {
cn.relaxedReadTimeoutNs.Store(0)
cn.relaxedWriteTimeoutNs.Store(0)
cn.relaxedDeadlineNs.Store(0)
cn.relaxedCounter.Store(0)
// Note: Metrics for timeout unrelaxing are not recorded here because we don't have
// context about which notification type or pool triggered the relaxation.
// In practice, relaxed timeouts expire automatically via deadline, so explicit
// unrelaxing metrics are less critical than the initial relaxation metrics.
}
// HasRelaxedTimeout returns true if relaxed timeouts are currently active on this connection.
// This checks both the timeout values and the deadline (if set).
// Uses atomic operations for lock-free access.
func (cn *Conn) HasRelaxedTimeout() bool {
// Fast path: no relaxed timeouts are set
if cn.relaxedCounter.Load() <= 0 {
return false
}
readTimeoutNs := cn.relaxedReadTimeoutNs.Load()
writeTimeoutNs := cn.relaxedWriteTimeoutNs.Load()
// If no relaxed timeouts are set, return false
if readTimeoutNs <= 0 && writeTimeoutNs <= 0 {
return false
}
deadlineNs := cn.relaxedDeadlineNs.Load()
// If no deadline is set, relaxed timeouts are active
if deadlineNs == 0 {
return true
}
// If deadline is set, check if it's still in the future
return time.Now().UnixNano() < deadlineNs
}
// getEffectiveReadTimeout returns the timeout to use for read operations.
// If relaxed timeout is set and not expired, it takes precedence over the provided timeout.
// This method automatically clears expired relaxed timeouts using atomic operations.
func (cn *Conn) getEffectiveReadTimeout(normalTimeout time.Duration) time.Duration {
readTimeoutNs := cn.relaxedReadTimeoutNs.Load()
// Fast path: no relaxed timeout set
if readTimeoutNs <= 0 {
return normalTimeout
}
deadlineNs := cn.relaxedDeadlineNs.Load()
// If no deadline is set, use relaxed timeout
if deadlineNs == 0 {
return time.Duration(readTimeoutNs)
}
// Use cached time to avoid expensive syscall (max 50ms staleness is acceptable for timeout checks)
nowNs := getCachedTimeNs()
// Check if deadline has passed
if nowNs < deadlineNs {
// Deadline is in the future, use relaxed timeout
return time.Duration(readTimeoutNs)
} else {
// Deadline has passed, clear relaxed timeouts atomically and use normal timeout
newCount := cn.relaxedCounter.Add(-1)
if newCount <= 0 {
internal.Logger.Printf(context.Background(), logs.UnrelaxedTimeoutAfterDeadline(cn.GetID()))
cn.clearRelaxedTimeout()
}
return normalTimeout
}
}
// getEffectiveWriteTimeout returns the timeout to use for write operations.
// If relaxed timeout is set and not expired, it takes precedence over the provided timeout.
// This method automatically clears expired relaxed timeouts using atomic operations.
func (cn *Conn) getEffectiveWriteTimeout(normalTimeout time.Duration) time.Duration {
writeTimeoutNs := cn.relaxedWriteTimeoutNs.Load()
// Fast path: no relaxed timeout set
if writeTimeoutNs <= 0 {
return normalTimeout
}
deadlineNs := cn.relaxedDeadlineNs.Load()
// If no deadline is set, use relaxed timeout
if deadlineNs == 0 {
return time.Duration(writeTimeoutNs)
}
// Use cached time to avoid expensive syscall (max 50ms staleness is acceptable for timeout checks)
nowNs := getCachedTimeNs()
// Check if deadline has passed
if nowNs < deadlineNs {
// Deadline is in the future, use relaxed timeout
return time.Duration(writeTimeoutNs)
} else {
// Deadline has passed, clear relaxed timeouts atomically and use normal timeout
newCount := cn.relaxedCounter.Add(-1)
if newCount <= 0 {
internal.Logger.Printf(context.Background(), logs.UnrelaxedTimeoutAfterDeadline(cn.GetID()))
cn.clearRelaxedTimeout()
}
return normalTimeout
}
}
// 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
}
// SetInitConnFunc sets the connection initialization function to be called on reconnections.
func (cn *Conn) SetInitConnFunc(fn func(context.Context, *Conn) error) {
cn.initConnFunc = fn
}
// ExecuteInitConn runs the stored connection initialization function if available.
func (cn *Conn) ExecuteInitConn(ctx context.Context) error {
if cn.initConnFunc != nil {
return cn.initConnFunc(ctx, cn)
}
return fmt.Errorf("redis: no initConnFunc set for conn[%d]", cn.GetID())
}
func (cn *Conn) SetNetConn(netConn net.Conn) {
// Store the new connection atomically first (lock-free)
cn.setNetConn(netConn)
// Protect reader reset operations to avoid data races
// Use write lock since we're modifying the reader state
cn.readerMu.Lock()
cn.rd.Reset(netConn)
cn.readerMu.Unlock()
cn.bw.Reset(netConn)
}
// GetNetConn safely returns the current network connection using atomic load (lock-free).
// This method is used by the pool for health checks and provides better performance.
func (cn *Conn) GetNetConn() net.Conn {
return cn.getNetConn()
}
// SetNetConnAndInitConn replaces the underlying connection and executes the initialization.
// This method ensures only one initialization can happen at a time by using atomic state transitions.
// If another goroutine is currently initializing, this will wait for it to complete.
func (cn *Conn) SetNetConnAndInitConn(ctx context.Context, netConn net.Conn) error {
// Wait for and transition to INITIALIZING state - this prevents concurrent initializations
// Valid from states: CREATED (first init), IDLE (reconnect), UNUSABLE (handoff/reauth)
// If another goroutine is initializing, we'll wait for it to finish
// if the context has a deadline, use that, otherwise use the connection read (relaxed) timeout
// which should be set during handoff. If it is not set, use a 5 second default
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(cn.getEffectiveReadTimeout(5 * time.Second))
}
waitCtx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
// Use predefined slice to avoid allocation
finalState, err := cn.stateMachine.AwaitAndTransition(
waitCtx,
validFromCreatedIdleOrUnusable,
StateInitializing,
)
if err != nil {
return fmt.Errorf("cannot initialize connection from state %s: %w", finalState, err)
}
// Replace the underlying connection
cn.SetNetConn(netConn)
// Execute initialization
// NOTE: ExecuteInitConn (via baseClient.initConn) will transition to IDLE on success
// or CLOSED on failure. We don't need to do it here.
// NOTE: Initconn returns conn in IDLE state
initErr := cn.ExecuteInitConn(ctx)
if initErr != nil {
// ExecuteInitConn already transitioned to CLOSED, just return the error
return initErr
}
// ExecuteInitConn already transitioned to IDLE
return nil
}
// MarkForHandoff marks the connection for handoff due to MOVING notification.
// Returns an error if the connection is already marked for handoff.
// Note: This only sets metadata - the connection state is not changed until OnPut.
// This allows the current user to finish using the connection before handoff.
func (cn *Conn) MarkForHandoff(newEndpoint string, seqID int64) error {
// Check if already marked for handoff
if cn.ShouldHandoff() {
return errAlreadyMarkedForHandoff
}
// Set handoff metadata atomically
cn.handoffStateAtomic.Store(&HandoffState{
ShouldHandoff: true,
Endpoint: newEndpoint,
SeqID: seqID,
})
return nil
}
// MarkQueuedForHandoff marks the connection as queued for handoff processing.
// This makes the connection unusable until handoff completes.
// This is called from OnPut hook, where the connection is typically in IN_USE state.
// The pool will preserve the UNUSABLE state and not overwrite it with IDLE.
func (cn *Conn) MarkQueuedForHandoff() error {
// Get current handoff state
currentState := cn.handoffStateAtomic.Load()
if currentState == nil {
return errNotMarkedForHandoff
}
state := currentState.(*HandoffState)
if !state.ShouldHandoff {
return errNotMarkedForHandoff
}
// Create new state with ShouldHandoff=false but preserve endpoint and seqID
// This prevents the connection from being queued multiple times while still
// allowing the worker to access the handoff metadata
newState := &HandoffState{
ShouldHandoff: false,
Endpoint: state.Endpoint, // Preserve endpoint for handoff processing
SeqID: state.SeqID, // Preserve seqID for handoff processing
}
// Atomic compare-and-swap to update state
if !cn.handoffStateAtomic.CompareAndSwap(currentState, newState) {
// State changed between load and CAS - retry or return error
return errHandoffStateChanged
}
// Transition to UNUSABLE from IN_USE (normal flow), IDLE (edge cases), or CREATED (tests/uninitialized)
// The connection is typically in IN_USE state when OnPut is called (normal Put flow)
// But in some edge cases or tests, it might be in IDLE or CREATED state
// The pool will detect this state change and preserve it (not overwrite with IDLE)
// Use predefined slice to avoid allocation
finalState, err := cn.stateMachine.TryTransition(validFromCreatedInUseOrIdle, StateUnusable)
if err != nil {
// Check if already in UNUSABLE state (race condition or retry)
// ShouldHandoff should be false now, but check just in case
if finalState == StateUnusable && !cn.ShouldHandoff() {
// Already unusable - this is fine, keep the new handoff state
return nil
}
// Restore the original state if transition fails for other reasons
cn.handoffStateAtomic.Store(currentState)
return fmt.Errorf("failed to mark connection as unusable: %w", err)
}
return nil
}
// GetID returns the unique identifier for this connection.
func (cn *Conn) GetID() uint64 {
return cn.id
}
// GetStateMachine returns the connection's state machine for advanced state management.
// This is primarily used by internal packages like maintnotifications for handoff processing.
func (cn *Conn) GetStateMachine() *ConnStateMachine {
return cn.stateMachine
}
// TryAcquire attempts to acquire the connection for use.
// This is an optimized inline method for the hot path (Get operation).
//
// It tries to transition from IDLE -> IN_USE or CREATED -> CREATED.
// Returns true if the connection was successfully acquired, false otherwise.
// The CREATED->CREATED is done so we can keep the state correct for later
// initialization of the connection in initConn.
//
// Performance: This is faster than calling GetStateMachine() + TryTransitionFast()
//
// NOTE: We directly access cn.stateMachine.state here instead of using the state machine's
// methods. This breaks encapsulation but is necessary for performance.
// The IDLE->IN_USE and CREATED->CREATED transitions don't need
// waiter notification, and benchmarks show 1-3% improvement. If the state machine ever
// needs to notify waiters on these transitions, update this to use TryTransitionFast().
func (cn *Conn) TryAcquire() bool {
// The || operator short-circuits, so only 1 CAS in the common case
return cn.stateMachine.state.CompareAndSwap(uint32(StateIdle), uint32(StateInUse)) ||
cn.stateMachine.state.CompareAndSwap(uint32(StateCreated), uint32(StateCreated))
}
// Release releases the connection back to the pool.
// This is an optimized inline method for the hot path (Put operation).
//
// It tries to transition from IN_USE -> IDLE.
// Returns true if the connection was successfully released, false otherwise.
//
// Performance: This is faster than calling GetStateMachine() + TryTransitionFast().
//
// NOTE: We directly access cn.stateMachine.state here instead of using the state machine's
// methods. This breaks encapsulation but is necessary for performance.
// If the state machine ever needs to notify waiters
// on this transition, update this to use TryTransitionFast().
func (cn *Conn) Release() bool {
// Inline the hot path - single CAS operation
return cn.stateMachine.state.CompareAndSwap(uint32(StateInUse), uint32(StateIdle))
}
// ClearHandoffState clears the handoff state after successful handoff.
// Makes the connection usable again.
func (cn *Conn) ClearHandoffState() {
// Clear handoff metadata
cn.handoffStateAtomic.Store(&HandoffState{
ShouldHandoff: false,
Endpoint: "",
SeqID: 0,
})
// Reset retry counter
cn.handoffRetriesAtomic.Store(0)
// Mark connection as usable again
// Use state machine directly instead of deprecated SetUsable
// probably done by initConn
cn.stateMachine.Transition(StateIdle)
}
// HasBufferedData safely checks if the connection has buffered data.
// This method is used to avoid data races when checking for push notifications.
func (cn *Conn) HasBufferedData() bool {
// Use read lock for concurrent access to reader state
cn.readerMu.RLock()
defer cn.readerMu.RUnlock()
return cn.rd.Buffered() > 0
}
// PeekReplyTypeSafe safely peeks at the reply type.
// This method is used to avoid data races when checking for push notifications.
func (cn *Conn) PeekReplyTypeSafe() (byte, error) {
// Use read lock for concurrent access to reader state
cn.readerMu.RLock()
defer cn.readerMu.RUnlock()
if cn.rd.Buffered() <= 0 {
return 0, fmt.Errorf("redis: can't peek reply type, no data available")
}
return cn.rd.PeekReplyType()
}
func (cn *Conn) Write(b []byte) (int, error) {
// Lock-free netConn access for better performance
if netConn := cn.getNetConn(); netConn != nil {
return netConn.Write(b)
}
return 0, net.ErrClosed
}
func (cn *Conn) RemoteAddr() net.Addr {
// Lock-free netConn access for better performance
if netConn := cn.getNetConn(); netConn != nil {
return netConn.RemoteAddr()
}
return nil
}
func (cn *Conn) WithReader(
ctx context.Context, timeout time.Duration, fn func(rd *proto.Reader) error,
) error {
if timeout >= 0 {
// Use relaxed timeout if set, otherwise use provided timeout
effectiveTimeout := cn.getEffectiveReadTimeout(timeout)
// Get the connection directly from atomic storage
netConn := cn.getNetConn()
if netConn == nil {
return errConnectionNotAvailable
}
if err := netConn.SetReadDeadline(cn.deadline(ctx, effectiveTimeout)); err != nil {
return err
}
}
return fn(cn.rd)
}
func (cn *Conn) WithWriter(
ctx context.Context, timeout time.Duration, fn func(wr *proto.Writer) error,
) error {
if timeout >= 0 {
// Use relaxed timeout if set, otherwise use provided timeout
effectiveTimeout := cn.getEffectiveWriteTimeout(timeout)
// Set write deadline on the connection
if netConn := cn.getNetConn(); netConn != nil {
if err := netConn.SetWriteDeadline(cn.deadline(ctx, effectiveTimeout)); err != nil {
return err
}
} else {
// Connection is not available - return preallocated error
return errConnNotAvailableForWrite
}
}
// Reset the buffered writer if needed, should not happen
if cn.bw.Buffered() > 0 {
if netConn := cn.getNetConn(); netConn != nil {
cn.bw.Reset(netConn)
}
}
if err := fn(cn.wr); err != nil {
return err
}
return cn.bw.Flush()
}
func (cn *Conn) IsClosed() bool {
return cn.stateMachine.GetState() == StateClosed
}
func (cn *Conn) Close() error {
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
if netConn := cn.getNetConn(); netConn != nil {
return netConn.Close()
}
return nil
}
// MaybeHasData tries to peek at the next byte in the socket without consuming it
// This is used to check if there are push notifications available
// Important: This will work on Linux, but not on Windows
func (cn *Conn) MaybeHasData() bool {
// Lock-free netConn access for better performance
if netConn := cn.getNetConn(); netConn != nil {
return maybeHasData(netConn)
}
return false
}
// deadline computes the effective deadline time based on context and timeout.
// It updates the usedAt timestamp to now.
// Uses cached time to avoid expensive syscall (max 50ms staleness is acceptable for deadline calculation).
func (cn *Conn) deadline(ctx context.Context, timeout time.Duration) time.Time {
// Use cached time for deadline calculation (called 2x per command: read + write)
nowNs := getCachedTimeNs()
cn.SetUsedAtNs(nowNs)
tm := time.Unix(0, nowNs)
if timeout > 0 {
tm = tm.Add(timeout)
}
if ctx != nil {
deadline, ok := ctx.Deadline()
if ok {
if timeout == 0 {
return deadline
}
if deadline.Before(tm) {
return deadline
}
return tm
}
}
if timeout > 0 {
return tm
}
return noDeadline
}
+59
View File
@@ -0,0 +1,59 @@
//go:build linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris || illumos
package pool
import (
"errors"
"io"
"net"
"syscall"
"time"
)
var errUnexpectedRead = errors.New("unexpected read from socket")
// connCheck checks if the connection is still alive and if there is data in the socket
// it will try to peek at the next byte without consuming it since we may want to work with it
// later on (e.g. push notifications)
func connCheck(conn net.Conn) error {
// Reset previous timeout.
_ = conn.SetDeadline(time.Time{})
sysConn, ok := conn.(syscall.Conn)
if !ok {
return nil
}
rawConn, err := sysConn.SyscallConn()
if err != nil {
return err
}
var sysErr error
if err := rawConn.Read(func(fd uintptr) bool {
var buf [1]byte
// Use MSG_PEEK to peek at data without consuming it
n, _, err := syscall.Recvfrom(int(fd), buf[:], syscall.MSG_PEEK|syscall.MSG_DONTWAIT)
switch {
case n == 0 && err == nil:
sysErr = io.EOF
case n > 0:
sysErr = errUnexpectedRead
case err == syscall.EAGAIN || err == syscall.EWOULDBLOCK:
sysErr = nil
default:
sysErr = err
}
return true
}); err != nil {
return err
}
return sysErr
}
// maybeHasData checks if there is data in the socket without consuming it
func maybeHasData(conn net.Conn) bool {
return connCheck(conn) == errUnexpectedRead
}
+20
View File
@@ -0,0 +1,20 @@
//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !solaris && !illumos
package pool
import (
"errors"
"net"
)
// errUnexpectedRead is placeholder error variable for non-unix build constraints
var errUnexpectedRead = errors.New("unexpected read from socket")
func connCheck(_ net.Conn) error {
return nil
}
// since we can't check for data on the socket, we just assume there is some
func maybeHasData(_ net.Conn) bool {
return true
}
+336
View File
@@ -0,0 +1,336 @@
package pool
import (
"container/list"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
)
// ConnState represents the connection state in the state machine.
// States are designed to be lightweight and fast to check.
//
// State Transitions:
//
// CREATED → INITIALIZING → IDLE ⇄ IN_USE
// ↓
// UNUSABLE (handoff/reauth)
// ↓
// IDLE/CLOSED
type ConnState uint32
const (
// StateCreated - Connection just created, not yet initialized
StateCreated ConnState = iota
// StateInitializing - Connection initialization in progress
StateInitializing
// StateIdle - Connection initialized and idle in pool, ready to be acquired
StateIdle
// StateInUse - Connection actively processing a command (retrieved from pool)
StateInUse
// StateUnusable - Connection temporarily unusable due to background operation
// (handoff, reauth, etc.). Cannot be acquired from pool.
StateUnusable
// StateClosed - Connection closed
StateClosed
)
// Predefined state slices to avoid allocations in hot paths
var (
validFromInUse = []ConnState{StateInUse}
validFromCreatedOrIdle = []ConnState{StateCreated, StateIdle}
validFromCreatedInUseOrIdle = []ConnState{StateCreated, StateInUse, StateIdle}
// For AwaitAndTransition calls
validFromCreatedIdleOrUnusable = []ConnState{StateCreated, StateIdle, StateUnusable}
validFromIdle = []ConnState{StateIdle}
// For CompareAndSwapUsable
validFromInitializingOrUnusable = []ConnState{StateInitializing, StateUnusable}
)
// Accessor functions for predefined slices to avoid allocations in external packages
// These return the same slice instance, so they're zero-allocation
// ValidFromIdle returns a predefined slice containing only StateIdle.
// Use this to avoid allocations when calling AwaitAndTransition or TryTransition.
func ValidFromIdle() []ConnState {
return validFromIdle
}
// ValidFromCreatedIdleOrUnusable returns a predefined slice for initialization transitions.
// Use this to avoid allocations when calling AwaitAndTransition or TryTransition.
func ValidFromCreatedIdleOrUnusable() []ConnState {
return validFromCreatedIdleOrUnusable
}
// String returns a human-readable string representation of the state.
func (s ConnState) String() string {
switch s {
case StateCreated:
return "CREATED"
case StateInitializing:
return "INITIALIZING"
case StateIdle:
return "IDLE"
case StateInUse:
return "IN_USE"
case StateUnusable:
return "UNUSABLE"
case StateClosed:
return "CLOSED"
default:
return fmt.Sprintf("UNKNOWN(%d)", s)
}
}
var (
// ErrInvalidStateTransition is returned when a state transition is not allowed
ErrInvalidStateTransition = errors.New("invalid state transition")
// ErrStateMachineClosed is returned when operating on a closed state machine
ErrStateMachineClosed = errors.New("state machine is closed")
// ErrTimeout is returned when a state transition times out
ErrTimeout = errors.New("state transition timeout")
)
// waiter represents a goroutine waiting for a state transition.
// Designed for minimal allocations and fast processing.
type waiter struct {
validStates map[ConnState]struct{} // States we're waiting for
targetState ConnState // State to transition to
done chan error // Signaled when transition completes or times out
}
// ConnStateMachine manages connection state transitions with FIFO waiting queue.
// Optimized for:
// - Lock-free reads (hot path)
// - Minimal allocations
// - Fast state transitions
// - FIFO fairness for waiters
// Note: Handoff metadata (endpoint, seqID, retries) is managed separately in the Conn struct.
type ConnStateMachine struct {
// Current state - atomic for lock-free reads
state atomic.Uint32
// FIFO queue for waiters - only locked during waiter add/remove/notify
mu sync.Mutex
waiters *list.List // List of *waiter
waiterCount atomic.Int32 // Fast lock-free check for waiters (avoids mutex in hot path)
}
// NewConnStateMachine creates a new connection state machine.
// Initial state is StateCreated.
func NewConnStateMachine() *ConnStateMachine {
sm := &ConnStateMachine{
waiters: list.New(),
}
sm.state.Store(uint32(StateCreated))
return sm
}
// GetState returns the current state (lock-free read).
// This is the hot path - optimized for zero allocations and minimal overhead.
// Note: Zero allocations applies to state reads; converting the returned state to a string
// (via String()) may allocate if the state is unknown.
func (sm *ConnStateMachine) GetState() ConnState {
return ConnState(sm.state.Load())
}
// TryTransitionFast is an optimized version for the hot path (Get/Put operations).
// It only handles simple state transitions without waiter notification.
// This is safe because:
// 1. Get/Put don't need to wait for state changes
// 2. Background operations (handoff/reauth) use UNUSABLE state, which this won't match
// 3. If a background operation is in progress (state is UNUSABLE), this fails fast
//
// Returns true if transition succeeded, false otherwise.
// Use this for performance-critical paths where you don't need error details.
//
// Performance: Single CAS operation - as fast as the old atomic bool!
// For multiple from states, use: sm.TryTransitionFast(State1, Target) || sm.TryTransitionFast(State2, Target)
// The || operator short-circuits, so only 1 CAS is executed in the common case.
func (sm *ConnStateMachine) TryTransitionFast(fromState, targetState ConnState) bool {
return sm.state.CompareAndSwap(uint32(fromState), uint32(targetState))
}
// TryTransition attempts an immediate state transition without waiting.
// Returns the current state after the transition attempt and an error if the transition failed.
// The returned state is the CURRENT state (after the attempt), not the previous state.
// This is faster than AwaitAndTransition when you don't need to wait.
// Uses compare-and-swap to atomically transition, preventing concurrent transitions.
// This method does NOT wait - it fails immediately if the transition cannot be performed.
//
// Performance: Zero allocations on success path (hot path).
func (sm *ConnStateMachine) TryTransition(validFromStates []ConnState, targetState ConnState) (ConnState, error) {
// Try each valid from state with CAS
// This ensures only ONE goroutine can successfully transition at a time
for _, fromState := range validFromStates {
// Try to atomically swap from fromState to targetState
// If successful, we won the race and can proceed
if sm.state.CompareAndSwap(uint32(fromState), uint32(targetState)) {
// Success! We transitioned atomically
// Hot path optimization: only check for waiters if transition succeeded
// This avoids atomic load on every Get/Put when no waiters exist
if sm.waiterCount.Load() > 0 {
sm.notifyWaiters()
}
return targetState, nil
}
}
// All CAS attempts failed - state is not valid for this transition
// Return the current state so caller can decide what to do
// Note: This error path allocates, but it's the exceptional case
currentState := sm.GetState()
return currentState, fmt.Errorf("%w: cannot transition from %s to %s (valid from: %v)",
ErrInvalidStateTransition, currentState, targetState, validFromStates)
}
// Transition unconditionally transitions to the target state.
// Use with caution - prefer AwaitAndTransition or TryTransition for safety.
// This is useful for error paths or when you know the transition is valid.
func (sm *ConnStateMachine) Transition(targetState ConnState) {
sm.state.Store(uint32(targetState))
sm.notifyWaiters()
}
// AwaitAndTransition waits for the connection to reach one of the valid states,
// then atomically transitions to the target state.
// Returns the current state after the transition attempt and an error if the operation failed.
// The returned state is the CURRENT state (after the attempt), not the previous state.
// Returns error if timeout expires or context is cancelled.
//
// This method implements FIFO fairness - the first caller to wait gets priority
// when the state becomes available.
//
// Performance notes:
// - If already in a valid state, this is very fast (no allocation, no waiting)
// - If waiting is required, allocates one waiter struct and one channel
func (sm *ConnStateMachine) AwaitAndTransition(
ctx context.Context,
validFromStates []ConnState,
targetState ConnState,
) (ConnState, error) {
// Fast path: try immediate transition with CAS to prevent race conditions
// BUT: only if there are no waiters in the queue (to maintain FIFO ordering)
if sm.waiterCount.Load() == 0 {
for _, fromState := range validFromStates {
// Check if we're already in target state
if fromState == targetState && sm.GetState() == targetState {
return targetState, nil
}
// Try to atomically swap from fromState to targetState
if sm.state.CompareAndSwap(uint32(fromState), uint32(targetState)) {
// Success! We transitioned atomically
sm.notifyWaiters()
return targetState, nil
}
}
}
// Fast path failed - check if we should wait or fail
currentState := sm.GetState()
// Check if closed
if currentState == StateClosed {
return currentState, ErrStateMachineClosed
}
// Slow path: need to wait for state change
// Create waiter with valid states map for fast lookup
validStatesMap := make(map[ConnState]struct{}, len(validFromStates))
for _, s := range validFromStates {
validStatesMap[s] = struct{}{}
}
w := &waiter{
validStates: validStatesMap,
targetState: targetState,
done: make(chan error, 1), // Buffered to avoid goroutine leak
}
// Add to FIFO queue
sm.mu.Lock()
elem := sm.waiters.PushBack(w)
sm.waiterCount.Add(1)
sm.mu.Unlock()
// Wait for state change or timeout
select {
case <-ctx.Done():
// Timeout or cancellation - remove from queue
sm.mu.Lock()
sm.waiters.Remove(elem)
sm.waiterCount.Add(-1)
sm.mu.Unlock()
return sm.GetState(), ctx.Err()
case err := <-w.done:
// Transition completed (or failed)
// Note: waiterCount is decremented either in notifyWaiters (when the waiter is notified and removed)
// or here (on timeout/cancellation).
return sm.GetState(), err
}
}
// notifyWaiters checks if any waiters can proceed and notifies them in FIFO order.
// This is called after every state transition.
func (sm *ConnStateMachine) notifyWaiters() {
// Fast path: check atomic counter without acquiring lock
// This eliminates mutex overhead in the common case (no waiters)
if sm.waiterCount.Load() == 0 {
return
}
sm.mu.Lock()
defer sm.mu.Unlock()
// Double-check after acquiring lock (waiters might have been processed)
if sm.waiters.Len() == 0 {
return
}
// 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
for elem := sm.waiters.Front(); elem != nil; elem = elem.Next() {
w := elem.Value.(*waiter)
if _, valid := w.validStates[currentState]; valid {
sm.waiters.Remove(elem)
sm.waiterCount.Add(-1)
if sm.state.CompareAndSwap(uint32(currentState), uint32(w.targetState)) {
w.done <- nil
currentState = w.targetState
processed = true
break
} else {
sm.waiters.PushFront(w)
sm.waiterCount.Add(1)
currentState = sm.GetState()
processed = true
break
}
}
}
if !processed {
break
}
}
}
+165
View File
@@ -0,0 +1,165 @@
package pool
import (
"context"
"sync"
)
// PoolHook defines the interface for connection lifecycle hooks.
type PoolHook interface {
// OnGet is called when a connection is retrieved from the pool.
// It can modify the connection or return an error to prevent its use.
// The accept flag can be used to prevent the connection from being used.
// On Accept = false the connection is rejected and returned to the pool.
// The error can be used to prevent the connection from being used and returned to the pool.
// On Errors, the connection is removed from the pool.
// It has isNewConn flag to indicate if this is a new connection (rather than idle from the pool)
// The flag can be used for gathering metrics on pool hit/miss ratio.
OnGet(ctx context.Context, conn *Conn, isNewConn bool) (accept bool, err error)
// OnPut is called when a connection is returned to the pool.
// It returns whether the connection should be pooled and whether it should be removed.
OnPut(ctx context.Context, conn *Conn) (shouldPool bool, shouldRemove bool, err error)
// OnRemove is called when a connection is removed from the pool.
// This happens when:
// - Connection fails health check
// - Connection exceeds max lifetime
// - Pool is being closed
// - Connection encounters an error
// Implementations should clean up any per-connection state.
// The reason parameter indicates why the connection was removed.
OnRemove(ctx context.Context, conn *Conn, reason error)
}
// PoolHookManager manages multiple pool hooks.
type PoolHookManager struct {
hooks []PoolHook
hooksMu sync.RWMutex
}
// NewPoolHookManager creates a new pool hook manager.
func NewPoolHookManager() *PoolHookManager {
return &PoolHookManager{
hooks: make([]PoolHook, 0),
}
}
// AddHook adds a pool hook to the manager.
// Hooks are called in the order they were added.
func (phm *PoolHookManager) AddHook(hook PoolHook) {
phm.hooksMu.Lock()
defer phm.hooksMu.Unlock()
phm.hooks = append(phm.hooks, hook)
}
// RemoveHook removes a pool hook from the manager.
func (phm *PoolHookManager) RemoveHook(hook PoolHook) {
phm.hooksMu.Lock()
defer phm.hooksMu.Unlock()
for i, h := range phm.hooks {
if h == hook {
// Remove hook by swapping with last element and truncating
phm.hooks[i] = phm.hooks[len(phm.hooks)-1]
phm.hooks = phm.hooks[:len(phm.hooks)-1]
break
}
}
}
// ProcessOnGet calls all OnGet hooks in order.
// If any hook returns an error, processing stops and the error is returned.
func (phm *PoolHookManager) ProcessOnGet(ctx context.Context, conn *Conn, isNewConn bool) (acceptConn bool, err error) {
// Copy slice reference while holding lock (fast)
phm.hooksMu.RLock()
hooks := phm.hooks
phm.hooksMu.RUnlock()
// Call hooks without holding lock (slow operations)
for _, hook := range hooks {
acceptConn, err := hook.OnGet(ctx, conn, isNewConn)
if err != nil {
return false, err
}
if !acceptConn {
return false, nil
}
}
return true, nil
}
// ProcessOnPut calls all OnPut hooks in order.
// The first hook that returns shouldRemove=true or shouldPool=false will stop processing.
func (phm *PoolHookManager) ProcessOnPut(ctx context.Context, conn *Conn) (shouldPool bool, shouldRemove bool, err error) {
// Copy slice reference while holding lock (fast)
phm.hooksMu.RLock()
hooks := phm.hooks
phm.hooksMu.RUnlock()
shouldPool = true // Default to pooling the connection
// Call hooks without holding lock (slow operations)
for _, hook := range hooks {
hookShouldPool, hookShouldRemove, hookErr := hook.OnPut(ctx, conn)
if hookErr != nil {
return false, true, hookErr
}
// If any hook says to remove or not pool, respect that decision
if hookShouldRemove {
return false, true, nil
}
if !hookShouldPool {
shouldPool = false
}
}
return shouldPool, false, nil
}
// ProcessOnRemove calls all OnRemove hooks in order.
func (phm *PoolHookManager) ProcessOnRemove(ctx context.Context, conn *Conn, reason error) {
// Copy slice reference while holding lock (fast)
phm.hooksMu.RLock()
hooks := phm.hooks
phm.hooksMu.RUnlock()
// Call hooks without holding lock (slow operations)
for _, hook := range hooks {
hook.OnRemove(ctx, conn, reason)
}
}
// GetHookCount returns the number of registered hooks (for testing).
func (phm *PoolHookManager) GetHookCount() int {
phm.hooksMu.RLock()
defer phm.hooksMu.RUnlock()
return len(phm.hooks)
}
// GetHooks returns a copy of all registered hooks.
func (phm *PoolHookManager) GetHooks() []PoolHook {
phm.hooksMu.RLock()
defer phm.hooksMu.RUnlock()
hooks := make([]PoolHook, len(phm.hooks))
copy(hooks, phm.hooks)
return hooks
}
// Clone creates a copy of the hook manager with the same hooks.
// This is used for lock-free atomic updates of the hook manager.
func (phm *PoolHookManager) Clone() *PoolHookManager {
phm.hooksMu.RLock()
defer phm.hooksMu.RUnlock()
newManager := &PoolHookManager{
hooks: make([]PoolHook, len(phm.hooks)),
}
copy(newManager.hooks, phm.hooks)
return newManager
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
package pool
import (
"context"
"time"
)
// SingleConnPool is a pool that always returns the same connection.
// Note: This pool is not thread-safe.
// It is intended to be used by clients that need a single connection.
type SingleConnPool struct {
pool Pooler
cn *Conn
stickyErr error
}
var _ Pooler = (*SingleConnPool)(nil)
// NewSingleConnPool creates a new single connection pool.
// The pool will always return the same connection.
// The pool will not:
// - Close the connection
// - Reconnect the connection
// - Track the connection in any way
func NewSingleConnPool(pool Pooler, cn *Conn) *SingleConnPool {
return &SingleConnPool{
pool: pool,
cn: cn,
}
}
func (p *SingleConnPool) NewConn(ctx context.Context) (*Conn, error) {
return p.pool.NewConn(ctx)
}
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) {
if p.stickyErr != nil {
return nil, p.stickyErr
}
if p.cn == nil {
return nil, ErrClosed
}
// NOTE: SingleConnPool is NOT thread-safe by design and is used in special scenarios:
// - During initialization (connection is in INITIALIZING state)
// - During re-authentication (connection is in UNUSABLE state)
// - For transactions (connection might be in various states)
// We use SetUsed() which forces the transition, rather than TryTransition() which
// would fail if the connection is not in IDLE/CREATED state.
p.cn.SetUsed(true)
p.cn.SetUsedAt(time.Now())
return p.cn, nil
}
func (p *SingleConnPool) Put(_ context.Context, cn *Conn) {
if p.cn == nil {
return
}
if p.cn != cn {
return
}
p.cn.SetUsed(false)
}
func (p *SingleConnPool) Remove(_ context.Context, cn *Conn, reason error) {
cn.SetUsed(false)
p.cn = nil
p.stickyErr = reason
}
// RemoveWithoutTurn has the same behavior as Remove for SingleConnPool
// since SingleConnPool doesn't use a turn-based queue system.
func (p *SingleConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) {
p.Remove(ctx, cn, reason)
}
func (p *SingleConnPool) Close() error {
p.cn = nil
p.stickyErr = ErrClosed
return nil
}
func (p *SingleConnPool) Len() int {
return 0
}
func (p *SingleConnPool) IdleLen() int {
return 0
}
// Size returns the maximum pool size, which is always 1 for SingleConnPool.
func (p *SingleConnPool) Size() int { return 1 }
func (p *SingleConnPool) Stats() *Stats {
return &Stats{}
}
func (p *SingleConnPool) AddPoolHook(_ PoolHook) {}
func (p *SingleConnPool) RemovePoolHook(_ PoolHook) {}
+214
View File
@@ -0,0 +1,214 @@
package pool
import (
"context"
"errors"
"fmt"
"sync/atomic"
)
const (
stateDefault = 0
stateInited = 1
stateClosed = 2
)
type BadConnError struct {
wrapped error
}
var _ error = (*BadConnError)(nil)
func (e BadConnError) Error() string {
s := "redis: Conn is in a bad state"
if e.wrapped != nil {
s += ": " + e.wrapped.Error()
}
return s
}
func (e BadConnError) Unwrap() error {
return e.wrapped
}
//------------------------------------------------------------------------------
type StickyConnPool struct {
pool Pooler
shared int32 // atomic
state uint32 // atomic
ch chan *Conn
_badConnError atomic.Value
}
var _ Pooler = (*StickyConnPool)(nil)
func NewStickyConnPool(pool Pooler) *StickyConnPool {
p, ok := pool.(*StickyConnPool)
if !ok {
p = &StickyConnPool{
pool: pool,
ch: make(chan *Conn, 1),
}
}
atomic.AddInt32(&p.shared, 1)
return p
}
func (p *StickyConnPool) NewConn(ctx context.Context) (*Conn, error) {
return p.pool.NewConn(ctx)
}
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) {
// In worst case this races with Close which is not a very common operation.
for i := 0; i < 1000; i++ {
switch atomic.LoadUint32(&p.state) {
case stateDefault:
cn, err := p.pool.Get(ctx)
if err != nil {
return nil, err
}
if atomic.CompareAndSwapUint32(&p.state, stateDefault, stateInited) {
return cn, nil
}
p.pool.Remove(ctx, cn, ErrClosed)
case stateInited:
if err := p.badConnError(); err != nil {
return nil, err
}
cn, ok := <-p.ch
if !ok {
return nil, ErrClosed
}
return cn, nil
case stateClosed:
return nil, ErrClosed
default:
panic("not reached")
}
}
return nil, fmt.Errorf("redis: StickyConnPool.Get: infinite loop")
}
func (p *StickyConnPool) Put(ctx context.Context, cn *Conn) {
defer func() {
if recover() != nil {
p.freeConn(ctx, cn)
}
}()
p.ch <- cn
}
func (p *StickyConnPool) freeConn(ctx context.Context, cn *Conn) {
if err := p.badConnError(); err != nil {
p.pool.Remove(ctx, cn, err)
} else {
p.pool.Put(ctx, cn)
}
}
func (p *StickyConnPool) Remove(ctx context.Context, cn *Conn, reason error) {
defer func() {
if recover() != nil {
p.pool.Remove(ctx, cn, ErrClosed)
}
}()
p._badConnError.Store(BadConnError{wrapped: reason})
p.ch <- cn
}
// RemoveWithoutTurn has the same behavior as Remove for StickyConnPool
// since StickyConnPool doesn't use a turn-based queue system.
func (p *StickyConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) {
p.Remove(ctx, cn, reason)
}
func (p *StickyConnPool) Close() error {
if shared := atomic.AddInt32(&p.shared, -1); shared > 0 {
return nil
}
for i := 0; i < 1000; i++ {
state := atomic.LoadUint32(&p.state)
if state == stateClosed {
return ErrClosed
}
if atomic.CompareAndSwapUint32(&p.state, state, stateClosed) {
close(p.ch)
cn, ok := <-p.ch
if ok {
p.freeConn(context.TODO(), cn)
}
return nil
}
}
return errors.New("redis: StickyConnPool.Close: infinite loop")
}
func (p *StickyConnPool) Reset(ctx context.Context) error {
if p.badConnError() == nil {
return nil
}
select {
case cn, ok := <-p.ch:
if !ok {
return ErrClosed
}
p.pool.Remove(ctx, cn, ErrClosed)
p._badConnError.Store(BadConnError{wrapped: nil})
default:
return errors.New("redis: StickyConnPool does not have a Conn")
}
if !atomic.CompareAndSwapUint32(&p.state, stateInited, stateDefault) {
state := atomic.LoadUint32(&p.state)
return fmt.Errorf("redis: invalid StickyConnPool state: %d", state)
}
return nil
}
func (p *StickyConnPool) badConnError() error {
if v := p._badConnError.Load(); v != nil {
if err := v.(BadConnError); err.wrapped != nil {
return err
}
}
return nil
}
func (p *StickyConnPool) Len() int {
switch atomic.LoadUint32(&p.state) {
case stateDefault:
return 0
case stateInited:
return 1
case stateClosed:
return 0
default:
panic("not reached")
}
}
func (p *StickyConnPool) IdleLen() int {
return len(p.ch)
}
// Size returns the maximum pool size, which is always 1 for StickyConnPool.
func (p *StickyConnPool) Size() int { return 1 }
func (p *StickyConnPool) Stats() *Stats {
return &Stats{}
}
func (p *StickyConnPool) AddPoolHook(hook PoolHook) {}
func (p *StickyConnPool) RemovePoolHook(hook PoolHook) {}
+105
View File
@@ -0,0 +1,105 @@
package pool
import (
"context"
"net"
"sync"
"sync/atomic"
)
type PubSubStats struct {
Created uint32
Untracked uint32
Active uint32
}
// PubSubPool manages a pool of PubSub connections.
type PubSubPool struct {
opt *Options
netDialer func(ctx context.Context, network, addr string) (net.Conn, error)
// Map to track active PubSub connections
activeConns sync.Map // map[uint64]*Conn (connID -> conn)
closed atomic.Bool
stats PubSubStats
}
// NewPubSubPool implements a pool for PubSub connections.
// It intentionally does not implement the Pooler interface
func NewPubSubPool(opt *Options, netDialer func(ctx context.Context, network, addr string) (net.Conn, error)) *PubSubPool {
return &PubSubPool{
opt: opt,
netDialer: netDialer,
}
}
func (p *PubSubPool) NewConn(ctx context.Context, network string, addr string, channels []string) (*Conn, error) {
if p.closed.Load() {
return nil, ErrClosed
}
netConn, err := p.netDialer(ctx, network, addr)
if err != nil {
return nil, err
}
cn := NewConnWithBufferSize(netConn, p.opt.ReadBufferSize, p.opt.WriteBufferSize)
cn.pubsub = true
// Set pool name for metrics
cn.SetPoolName(p.opt.Name)
atomic.AddUint32(&p.stats.Created, 1)
return cn, nil
}
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)
// 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
})
return nil
}
func (p *PubSubPool) Stats() *PubSubStats {
// load stats atomically
return &PubSubStats{
Created: atomic.LoadUint32(&p.stats.Created),
Untracked: atomic.LoadUint32(&p.stats.Untracked),
Active: atomic.LoadUint32(&p.stats.Active),
}
}
+115
View File
@@ -0,0 +1,115 @@
package pool
import (
"context"
"sync"
)
type wantConn struct {
mu sync.RWMutex // protects ctx, done and sending of the result
ctx context.Context // context for dial, cleared after delivered or canceled
cancelCtx context.CancelFunc
done bool // true after delivered or canceled
result chan wantConnResult // channel to deliver connection or error
}
// getCtxForDial returns context for dial or nil if connection was delivered or canceled.
func (w *wantConn) getCtxForDial() context.Context {
w.mu.RLock()
defer w.mu.RUnlock()
return w.ctx
}
func (w *wantConn) tryDeliver(cn *Conn, err error) bool {
w.mu.Lock()
defer w.mu.Unlock()
if w.done {
return false
}
w.done = true
w.ctx = nil
w.result <- wantConnResult{cn: cn, err: err}
close(w.result)
return true
}
func (w *wantConn) cancel() *Conn {
w.mu.Lock()
var cn *Conn
if w.done {
select {
case result := <-w.result:
cn = result.cn
default:
}
} else {
close(w.result)
}
w.done = true
w.ctx = nil
w.mu.Unlock()
return cn
}
func (w *wantConn) isOngoing() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return !w.done
}
type wantConnResult struct {
cn *Conn
err error
}
type wantConnQueue struct {
mu sync.RWMutex
items []*wantConn
}
func newWantConnQueue() *wantConnQueue {
return &wantConnQueue{
items: make([]*wantConn, 0),
}
}
func (q *wantConnQueue) enqueue(w *wantConn) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, w)
}
func (q *wantConnQueue) dequeue() (*wantConn, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 {
return nil, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
func (q *wantConnQueue) discardDoneAtFront() int {
q.mu.Lock()
defer q.mu.Unlock()
count := 0
for len(q.items) > 0 {
if q.items[0].isOngoing() {
break
}
q.items = q.items[1:]
count++
}
return count
}
+838
View File
@@ -0,0 +1,838 @@
package proto
import (
"bufio"
"errors"
"fmt"
"io"
"math"
"math/big"
"strconv"
"github.com/redis/go-redis/v9/internal/util"
)
// DefaultBufferSize is the default size for read/write buffers (32 KiB).
const DefaultBufferSize = 32 * 1024
// redis resp protocol data type.
const (
RespStatus = '+' // +<string>\r\n
RespError = '-' // -<string>\r\n
RespString = '$' // $<length>\r\n<bytes>\r\n
RespInt = ':' // :<number>\r\n
RespNil = '_' // _\r\n
RespFloat = ',' // ,<floating-point-number>\r\n (golang float)
RespBool = '#' // true: #t\r\n false: #f\r\n
RespBlobError = '!' // !<length>\r\n<bytes>\r\n
RespVerbatim = '=' // =<length>\r\nFORMAT:<bytes>\r\n
RespBigInt = '(' // (<big number>\r\n
RespArray = '*' // *<len>\r\n... (same as resp2)
RespMap = '%' // %<len>\r\n(key)\r\n(value)\r\n... (golang map)
RespSet = '~' // ~<len>\r\n... (same as Array)
RespAttr = '|' // |<len>\r\n(key)\r\n(value)\r\n... + command reply
RespPush = '>' // ><len>\r\n... (same as Array)
)
// Not used temporarily.
// Redis has not used these two data types for the time being, and will implement them later.
// Streamed = "EOF:"
// StreamedAggregated = '?'
//------------------------------------------------------------------------------
const Nil = RedisError("redis: nil") // nolint:errname
type RedisError string
func (e RedisError) Error() string { return string(e) }
func (RedisError) RedisError() {}
func ParseErrorReply(line []byte) error {
msg := string(line[1:])
return parseTypedRedisError(msg)
}
//------------------------------------------------------------------------------
type Reader struct {
rd *bufio.Reader
}
func NewReader(rd io.Reader) *Reader {
return &Reader{
rd: bufio.NewReaderSize(rd, DefaultBufferSize),
}
}
func NewReaderSize(rd io.Reader, size int) *Reader {
return &Reader{
rd: bufio.NewReaderSize(rd, size),
}
}
func (r *Reader) Buffered() int {
return r.rd.Buffered()
}
func (r *Reader) Peek(n int) ([]byte, error) {
return r.rd.Peek(n)
}
func (r *Reader) Reset(rd io.Reader) {
r.rd.Reset(rd)
}
// PeekReplyType returns the data type of the next response without advancing the Reader,
// and discard the attribute type.
func (r *Reader) PeekReplyType() (byte, error) {
b, err := r.rd.Peek(1)
if err != nil {
return 0, err
}
if b[0] == RespAttr {
if err = r.DiscardNext(); err != nil {
return 0, err
}
return r.PeekReplyType()
}
return b[0], nil
}
func (r *Reader) PeekPushNotificationName() (string, error) {
// "prime" the buffer by peeking at the next byte
c, err := r.Peek(1)
if err != nil {
return "", err
}
if c[0] != RespPush {
return "", fmt.Errorf("redis: can't peek push notification name, next reply is not a push notification")
}
// peek 36 bytes at most, should be enough to read the push notification name
toPeek := 36
buffered := r.Buffered()
if buffered == 0 {
return "", fmt.Errorf("redis: can't peek push notification name, no data available")
}
if buffered < toPeek {
toPeek = buffered
}
buf, err := r.rd.Peek(toPeek)
if err != nil {
return "", err
}
if buf[0] != RespPush {
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
}
if len(buf) < 3 {
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
}
// remove push notification type
buf = buf[1:]
// remove first line - e.g. >2\r\n
for i := 0; i < len(buf)-1; i++ {
if buf[i] == '\r' && buf[i+1] == '\n' {
buf = buf[i+2:]
break
} else {
if buf[i] < '0' || buf[i] > '9' {
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
}
}
}
if len(buf) < 2 {
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
}
// next line should be $<length><string>\r\n or +<length><string>\r\n
// should have the type of the push notification name and it's length
if buf[0] != RespString && buf[0] != RespStatus {
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
}
typeOfName := buf[0]
// remove the type of the push notification name
buf = buf[1:]
if typeOfName == RespString {
// remove the length of the string
if len(buf) < 2 {
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
}
for i := 0; i < len(buf)-1; i++ {
if buf[i] == '\r' && buf[i+1] == '\n' {
buf = buf[i+2:]
break
} else {
if buf[i] < '0' || buf[i] > '9' {
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
}
}
}
}
if len(buf) < 2 {
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
}
// keep only the notification name
for i := 0; i < len(buf)-1; i++ {
if buf[i] == '\r' && buf[i+1] == '\n' {
buf = buf[:i]
break
}
}
return util.BytesToString(buf), nil
}
// ReadLine Return a valid reply, it will check the protocol or redis error,
// and discard the attribute type.
func (r *Reader) ReadLine() ([]byte, error) {
line, err := r.readLine()
if err != nil {
return nil, err
}
switch line[0] {
case RespError:
return nil, ParseErrorReply(line)
case RespNil:
return nil, Nil
case RespBlobError:
var blobErr string
blobErr, err = r.readStringReply(line)
if err == nil {
err = parseTypedRedisError(blobErr)
}
return nil, err
case RespAttr:
if err = r.Discard(line); err != nil {
return nil, err
}
return r.ReadLine()
}
// Compatible with RESP2
if IsNilReply(line) {
return nil, Nil
}
return line, nil
}
// readLine returns an error if:
// - there is a pending read error;
// - or line does not end with \r\n.
func (r *Reader) readLine() ([]byte, error) {
b, err := r.rd.ReadSlice('\n')
if err != nil {
if err != bufio.ErrBufferFull {
return nil, err
}
full := make([]byte, len(b))
copy(full, b)
b, err = r.rd.ReadBytes('\n')
if err != nil {
return nil, err
}
full = append(full, b...) //nolint:makezero
b = full
}
if len(b) <= 2 || b[len(b)-1] != '\n' || b[len(b)-2] != '\r' {
return nil, fmt.Errorf("redis: invalid reply: %q", b)
}
return b[:len(b)-2], nil
}
func (r *Reader) ReadReply() (interface{}, error) {
line, err := r.ReadLine()
if err != nil {
return nil, err
}
switch line[0] {
case RespStatus:
return string(line[1:]), nil
case RespInt:
return util.ParseInt(line[1:], 10, 64)
case RespFloat:
return r.readFloat(line)
case RespBool:
return r.readBool(line)
case RespBigInt:
return r.readBigInt(line)
case RespString:
return r.readStringReply(line)
case RespVerbatim:
return r.readVerb(line)
case RespArray, RespSet, RespPush:
return r.readSlice(line)
case RespMap:
return r.readMap(line)
}
return nil, fmt.Errorf("redis: can't parse %.100q", line)
}
func (r *Reader) readFloat(line []byte) (float64, error) {
v := util.BytesToString(line[1:])
switch v {
case "inf":
return math.Inf(1), nil
case "-inf":
return math.Inf(-1), nil
case "nan", "-nan":
return math.NaN(), nil
}
return strconv.ParseFloat(v, 64)
}
func (r *Reader) readBool(line []byte) (bool, error) {
switch util.BytesToString(line[1:]) {
case "t":
return true, nil
case "f":
return false, nil
}
return false, fmt.Errorf("redis: can't parse bool reply: %q", line)
}
func (r *Reader) readBigInt(line []byte) (*big.Int, error) {
i := new(big.Int)
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)
}
func (r *Reader) readStringReply(line []byte) (string, error) {
n, err := replyLen(line)
if err != nil {
return "", err
}
b := make([]byte, n+2)
_, err = io.ReadFull(r.rd, b)
if err != nil {
return "", err
}
return util.BytesToString(b[:n]), nil
}
func (r *Reader) readVerb(line []byte) (string, error) {
s, err := r.readStringReply(line)
if err != nil {
return "", err
}
if len(s) < 4 || s[3] != ':' {
return "", fmt.Errorf("redis: can't parse verbatim string reply: %q", line)
}
return s[4:], nil
}
func (r *Reader) readSlice(line []byte) ([]interface{}, error) {
n, err := replyLen(line)
if err != nil {
return nil, err
}
val := make([]interface{}, n)
for i := 0; i < len(val); i++ {
v, err := r.ReadReply()
if err != nil {
if err == Nil {
val[i] = nil
continue
}
if err, ok := err.(RedisError); ok {
val[i] = err
continue
}
return nil, err
}
val[i] = v
}
return val, nil
}
func (r *Reader) readMap(line []byte) (map[interface{}]interface{}, error) {
n, err := replyLen(line)
if err != nil {
return nil, err
}
m := make(map[interface{}]interface{}, n)
for i := 0; i < n; i++ {
k, err := r.ReadReply()
if err != nil {
return nil, err
}
v, err := r.ReadReply()
if err != nil {
if err == Nil {
m[k] = nil
continue
}
if err, ok := err.(RedisError); ok {
m[k] = err
continue
}
return nil, err
}
m[k] = v
}
return m, nil
}
// -------------------------------
func (r *Reader) ReadInt() (int64, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespInt, RespStatus:
return util.ParseInt(line[1:], 10, 64)
case RespString:
s, err := r.readStringReply(line)
if err != nil {
return 0, err
}
return util.ParseInt([]byte(s), 10, 64)
case RespBigInt:
b, err := r.readBigInt(line)
if err != nil {
return 0, err
}
if !b.IsInt64() {
return 0, fmt.Errorf("bigInt(%s) value out of range", b.String())
}
return b.Int64(), nil
}
return 0, fmt.Errorf("redis: can't parse int reply: %.100q", line)
}
func (r *Reader) ReadUint() (uint64, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespInt, RespStatus:
return util.ParseUint(line[1:], 10, 64)
case RespString:
s, err := r.readStringReply(line)
if err != nil {
return 0, err
}
return util.ParseUint([]byte(s), 10, 64)
case RespBigInt:
b, err := r.readBigInt(line)
if err != nil {
return 0, err
}
if !b.IsUint64() {
return 0, fmt.Errorf("bigInt(%s) value out of range", b.String())
}
return b.Uint64(), nil
}
return 0, fmt.Errorf("redis: can't parse uint reply: %.100q", line)
}
func (r *Reader) ReadFloat() (float64, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespFloat:
return r.readFloat(line)
case RespStatus:
return strconv.ParseFloat(util.BytesToString(line[1:]), 64)
case RespString:
s, err := r.readStringReply(line)
if err != nil {
return 0, err
}
return strconv.ParseFloat(s, 64)
}
return 0, fmt.Errorf("redis: can't parse float reply: %.100q", line)
}
func (r *Reader) ReadString() (string, error) {
line, err := r.ReadLine()
if err != nil {
return "", err
}
switch line[0] {
case RespStatus, RespInt, RespFloat:
return string(line[1:]), nil
case RespString:
return r.readStringReply(line)
case RespBool:
b, err := r.readBool(line)
return strconv.FormatBool(b), err
case RespVerbatim:
return r.readVerb(line)
case RespBigInt:
b, err := r.readBigInt(line)
if err != nil {
return "", err
}
return b.String(), nil
}
return "", fmt.Errorf("redis: can't parse reply=%.100q reading string", line)
}
func (r *Reader) ReadBool() (bool, error) {
s, err := r.ReadString()
if err != nil {
return false, err
}
return s == "OK" || s == "1" || s == "true", nil
}
func (r *Reader) ReadSlice() ([]interface{}, error) {
line, err := r.ReadLine()
if err != nil {
return nil, err
}
return r.readSlice(line)
}
// ReadFixedArrayLen read fixed array length.
func (r *Reader) ReadFixedArrayLen(fixedLen int) error {
n, err := r.ReadArrayLen()
if err != nil {
return err
}
if n != fixedLen {
return fmt.Errorf("redis: got %d elements in the array, wanted %d", n, fixedLen)
}
return nil
}
// ReadArrayLen Read and return the length of the array.
func (r *Reader) ReadArrayLen() (int, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespArray, RespSet, RespPush:
return replyLen(line)
default:
return 0, fmt.Errorf("redis: can't parse array/set/push reply: %.100q", line)
}
}
// ReadFixedMapLen reads fixed map length.
func (r *Reader) ReadFixedMapLen(fixedLen int) error {
n, err := r.ReadMapLen()
if err != nil {
return err
}
if n != fixedLen {
return fmt.Errorf("redis: got %d elements in the map, wanted %d", n, fixedLen)
}
return nil
}
// ReadMapLen reads the length of the map type.
// If responding to the array type (RespArray/RespSet/RespPush),
// it must be a multiple of 2 and return n/2.
// Other types will return an error.
func (r *Reader) ReadMapLen() (int, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespMap:
return replyLen(line)
case RespArray, RespSet, RespPush:
// Some commands and RESP2 protocol may respond to array types.
n, err := replyLen(line)
if err != nil {
return 0, err
}
if n%2 != 0 {
return 0, fmt.Errorf("redis: the length of the array must be a multiple of 2, got: %d", n)
}
return n / 2, nil
default:
return 0, fmt.Errorf("redis: can't parse map reply: %.100q", line)
}
}
// DiscardNext read and discard the data represented by the next line.
func (r *Reader) DiscardNext() error {
line, err := r.readLine()
if err != nil {
return err
}
return r.Discard(line)
}
// Discard the data represented by line.
func (r *Reader) Discard(line []byte) (err error) {
if len(line) == 0 {
return errors.New("redis: invalid line")
}
switch line[0] {
case RespStatus, RespError, RespInt, RespNil, RespFloat, RespBool, RespBigInt:
return nil
}
n, err := replyLen(line)
if err != nil && err != Nil {
return err
}
switch line[0] {
case RespBlobError, RespString, RespVerbatim:
// +\r\n
_, err = r.rd.Discard(n + 2)
return err
case RespArray, RespSet, RespPush:
for i := 0; i < n; i++ {
if err = r.DiscardNext(); err != nil {
return err
}
}
return nil
case RespMap, RespAttr:
// Read key & value.
for i := 0; i < n*2; i++ {
if err = r.DiscardNext(); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("redis: can't parse %.100q", line)
}
func replyLen(line []byte) (n int, err error) {
n, err = util.Atoi(line[1:])
if err != nil {
return 0, err
}
if n < -1 {
return 0, fmt.Errorf("redis: invalid reply: %q", line)
}
switch line[0] {
case RespString, RespVerbatim, RespBlobError,
RespArray, RespSet, RespPush, RespMap, RespAttr:
if n == -1 {
return 0, Nil
}
}
return n, nil
}
// IsNilReply detects redis.Nil of RESP2.
func IsNilReply(line []byte) bool {
return len(line) == 3 &&
(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)
}
+539
View File
@@ -0,0 +1,539 @@
package proto
import (
"errors"
"strings"
)
// Typed Redis errors for better error handling with wrapping support.
// These errors maintain backward compatibility by keeping the same error messages.
// LoadingError is returned when Redis is loading the dataset in memory.
type LoadingError struct {
msg string
}
func (e *LoadingError) Error() string {
return e.msg
}
func (e *LoadingError) RedisError() {}
// NewLoadingError creates a new LoadingError with the given message.
func NewLoadingError(msg string) *LoadingError {
return &LoadingError{msg: msg}
}
// ReadOnlyError is returned when trying to write to a read-only replica.
type ReadOnlyError struct {
msg string
}
func (e *ReadOnlyError) Error() string {
return e.msg
}
func (e *ReadOnlyError) RedisError() {}
// NewReadOnlyError creates a new ReadOnlyError with the given message.
func NewReadOnlyError(msg string) *ReadOnlyError {
return &ReadOnlyError{msg: msg}
}
// MovedError is returned when a key has been moved to a different node in a cluster.
type MovedError struct {
msg string
addr string
}
func (e *MovedError) Error() string {
return e.msg
}
func (e *MovedError) RedisError() {}
// Addr returns the address of the node where the key has been moved.
func (e *MovedError) Addr() string {
return e.addr
}
// NewMovedError creates a new MovedError with the given message and address.
func NewMovedError(msg string, addr string) *MovedError {
return &MovedError{msg: msg, addr: addr}
}
// AskError is returned when a key is being migrated and the client should ask another node.
type AskError struct {
msg string
addr string
}
func (e *AskError) Error() string {
return e.msg
}
func (e *AskError) RedisError() {}
// Addr returns the address of the node to ask.
func (e *AskError) Addr() string {
return e.addr
}
// NewAskError creates a new AskError with the given message and address.
func NewAskError(msg string, addr string) *AskError {
return &AskError{msg: msg, addr: addr}
}
// ClusterDownError is returned when the cluster is down.
type ClusterDownError struct {
msg string
}
func (e *ClusterDownError) Error() string {
return e.msg
}
func (e *ClusterDownError) RedisError() {}
// NewClusterDownError creates a new ClusterDownError with the given message.
func NewClusterDownError(msg string) *ClusterDownError {
return &ClusterDownError{msg: msg}
}
// TryAgainError is returned when a command cannot be processed and should be retried.
type TryAgainError struct {
msg string
}
func (e *TryAgainError) Error() string {
return e.msg
}
func (e *TryAgainError) RedisError() {}
// NewTryAgainError creates a new TryAgainError with the given message.
func NewTryAgainError(msg string) *TryAgainError {
return &TryAgainError{msg: msg}
}
// MasterDownError is returned when the master is down.
type MasterDownError struct {
msg string
}
func (e *MasterDownError) Error() string {
return e.msg
}
func (e *MasterDownError) RedisError() {}
// NewMasterDownError creates a new MasterDownError with the given message.
func NewMasterDownError(msg string) *MasterDownError {
return &MasterDownError{msg: msg}
}
// MaxClientsError is returned when the maximum number of clients has been reached.
type MaxClientsError struct {
msg string
}
func (e *MaxClientsError) Error() string {
return e.msg
}
func (e *MaxClientsError) RedisError() {}
// NewMaxClientsError creates a new MaxClientsError with the given message.
func NewMaxClientsError(msg string) *MaxClientsError {
return &MaxClientsError{msg: msg}
}
// AuthError is returned when authentication fails.
type AuthError struct {
msg string
}
func (e *AuthError) Error() string {
return e.msg
}
func (e *AuthError) RedisError() {}
// NewAuthError creates a new AuthError with the given message.
func NewAuthError(msg string) *AuthError {
return &AuthError{msg: msg}
}
// PermissionError is returned when a user lacks required permissions.
type PermissionError struct {
msg string
}
func (e *PermissionError) Error() string {
return e.msg
}
func (e *PermissionError) RedisError() {}
// NewPermissionError creates a new PermissionError with the given message.
func NewPermissionError(msg string) *PermissionError {
return &PermissionError{msg: msg}
}
// ExecAbortError is returned when a transaction is aborted.
type ExecAbortError struct {
msg string
}
func (e *ExecAbortError) Error() string {
return e.msg
}
func (e *ExecAbortError) RedisError() {}
// NewExecAbortError creates a new ExecAbortError with the given message.
func NewExecAbortError(msg string) *ExecAbortError {
return &ExecAbortError{msg: msg}
}
// OOMError is returned when Redis is out of memory.
type OOMError struct {
msg string
}
func (e *OOMError) Error() string {
return e.msg
}
func (e *OOMError) RedisError() {}
// NewOOMError creates a new OOMError with the given message.
func NewOOMError(msg string) *OOMError {
return &OOMError{msg: msg}
}
// NoReplicasError is returned when not enough replicas acknowledge a write.
// This error occurs when using WAIT/WAITAOF commands or CLUSTER SETSLOT with
// synchronous replication, and the required number of replicas cannot confirm
// the write within the timeout period.
type NoReplicasError struct {
msg string
}
func (e *NoReplicasError) Error() string {
return e.msg
}
func (e *NoReplicasError) RedisError() {}
// NewNoReplicasError creates a new NoReplicasError with the given message.
func NewNoReplicasError(msg string) *NoReplicasError {
return &NoReplicasError{msg: msg}
}
// parseTypedRedisError parses a Redis error message and returns a typed error if applicable.
// This function maintains backward compatibility by keeping the same error messages.
func parseTypedRedisError(msg string) error {
// Check for specific error patterns and return typed errors
switch {
case strings.HasPrefix(msg, "LOADING "):
return NewLoadingError(msg)
case strings.HasPrefix(msg, "READONLY "):
return NewReadOnlyError(msg)
case strings.HasPrefix(msg, "MOVED "):
// Extract address from "MOVED <slot> <addr>"
addr := extractAddr(msg)
return NewMovedError(msg, addr)
case strings.HasPrefix(msg, "ASK "):
// Extract address from "ASK <slot> <addr>"
addr := extractAddr(msg)
return NewAskError(msg, addr)
case strings.HasPrefix(msg, "CLUSTERDOWN "):
return NewClusterDownError(msg)
case strings.HasPrefix(msg, "TRYAGAIN "):
return NewTryAgainError(msg)
case strings.HasPrefix(msg, "MASTERDOWN "):
return NewMasterDownError(msg)
case strings.HasPrefix(msg, "NOREPLICAS "):
return NewNoReplicasError(msg)
case msg == "ERR max number of clients reached":
return NewMaxClientsError(msg)
case strings.HasPrefix(msg, "NOAUTH "), strings.HasPrefix(msg, "WRONGPASS "), strings.Contains(msg, "unauthenticated"):
return NewAuthError(msg)
case strings.HasPrefix(msg, "NOPERM "):
return NewPermissionError(msg)
case strings.HasPrefix(msg, "EXECABORT "):
return NewExecAbortError(msg)
case strings.HasPrefix(msg, "OOM "):
return NewOOMError(msg)
default:
// Return generic RedisError for unknown error types
return RedisError(msg)
}
}
// extractAddr extracts the address from MOVED/ASK error messages.
// Format: "MOVED <slot> <addr>" or "ASK <slot> <addr>"
func extractAddr(msg string) string {
ind := strings.LastIndex(msg, " ")
if ind == -1 {
return ""
}
return msg[ind+1:]
}
// IsLoadingError checks if an error is a LoadingError, even if wrapped.
func IsLoadingError(err error) bool {
if err == nil {
return false
}
var loadingErr *LoadingError
if errors.As(err, &loadingErr) {
return true
}
// Check if wrapped error is a RedisError with LOADING prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "LOADING ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "LOADING ")
}
// IsReadOnlyError checks if an error is a ReadOnlyError, even if wrapped.
func IsReadOnlyError(err error) bool {
if err == nil {
return false
}
var readOnlyErr *ReadOnlyError
if errors.As(err, &readOnlyErr) {
return true
}
// Check if wrapped error is a RedisError with READONLY prefix or Lua script READONLY
var redisErr RedisError
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
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.
// Returns the error and a boolean indicating if it's a MovedError.
func IsMovedError(err error) (*MovedError, bool) {
if err == nil {
return nil, false
}
var movedErr *MovedError
if errors.As(err, &movedErr) {
return movedErr, true
}
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "MOVED ") {
// Parse: MOVED 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
return &MovedError{msg: s, addr: parts[2]}, true
}
}
return nil, false
}
// IsAskError checks if an error is an AskError, even if wrapped.
// Returns the error and a boolean indicating if it's an AskError.
func IsAskError(err error) (*AskError, bool) {
if err == nil {
return nil, false
}
var askErr *AskError
if errors.As(err, &askErr) {
return askErr, true
}
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "ASK ") {
// Parse: ASK 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
return &AskError{msg: s, addr: parts[2]}, true
}
}
return nil, false
}
// IsClusterDownError checks if an error is a ClusterDownError, even if wrapped.
func IsClusterDownError(err error) bool {
if err == nil {
return false
}
var clusterDownErr *ClusterDownError
if errors.As(err, &clusterDownErr) {
return true
}
// Check if wrapped error is a RedisError with CLUSTERDOWN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "CLUSTERDOWN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "CLUSTERDOWN ")
}
// IsTryAgainError checks if an error is a TryAgainError, even if wrapped.
func IsTryAgainError(err error) bool {
if err == nil {
return false
}
var tryAgainErr *TryAgainError
if errors.As(err, &tryAgainErr) {
return true
}
// Check if wrapped error is a RedisError with TRYAGAIN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "TRYAGAIN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "TRYAGAIN ")
}
// IsMasterDownError checks if an error is a MasterDownError, even if wrapped.
func IsMasterDownError(err error) bool {
if err == nil {
return false
}
var masterDownErr *MasterDownError
if errors.As(err, &masterDownErr) {
return true
}
// Check if wrapped error is a RedisError with MASTERDOWN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "MASTERDOWN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "MASTERDOWN ")
}
// IsMaxClientsError checks if an error is a MaxClientsError, even if wrapped.
func IsMaxClientsError(err error) bool {
if err == nil {
return false
}
var maxClientsErr *MaxClientsError
if errors.As(err, &maxClientsErr) {
return true
}
// Check if wrapped error is a RedisError with max clients prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "ERR max number of clients reached") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "ERR max number of clients reached")
}
// IsAuthError checks if an error is an AuthError, even if wrapped.
func IsAuthError(err error) bool {
if err == nil {
return false
}
var authErr *AuthError
if errors.As(err, &authErr) {
return true
}
// Check if wrapped error is a RedisError with auth error prefix
var redisErr RedisError
if errors.As(err, &redisErr) {
s := redisErr.Error()
return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated")
}
// Fallback to string checking for backward compatibility
s := err.Error()
return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated")
}
// IsPermissionError checks if an error is a PermissionError, even if wrapped.
func IsPermissionError(err error) bool {
if err == nil {
return false
}
var permErr *PermissionError
if errors.As(err, &permErr) {
return true
}
// Check if wrapped error is a RedisError with NOPERM prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "NOPERM ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "NOPERM ")
}
// IsExecAbortError checks if an error is an ExecAbortError, even if wrapped.
func IsExecAbortError(err error) bool {
if err == nil {
return false
}
var execAbortErr *ExecAbortError
if errors.As(err, &execAbortErr) {
return true
}
// Check if wrapped error is a RedisError with EXECABORT prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "EXECABORT ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "EXECABORT ")
}
// IsOOMError checks if an error is an OOMError, even if wrapped.
func IsOOMError(err error) bool {
if err == nil {
return false
}
var oomErr *OOMError
if errors.As(err, &oomErr) {
return true
}
// Check if wrapped error is a RedisError with OOM prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "OOM ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "OOM ")
}
// IsNoReplicasError checks if an error is a NoReplicasError, even if wrapped.
func IsNoReplicasError(err error) bool {
if err == nil {
return false
}
var noReplicasErr *NoReplicasError
if errors.As(err, &noReplicasErr) {
return true
}
// Check if wrapped error is a RedisError with NOREPLICAS prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "NOREPLICAS ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "NOREPLICAS ")
}
+185
View File
@@ -0,0 +1,185 @@
package proto
import (
"encoding"
"fmt"
"net"
"reflect"
"time"
"github.com/redis/go-redis/v9/internal/util"
)
// Scan parses bytes `b` to `v` with appropriate type.
//
//nolint:gocyclo
func Scan(b []byte, v interface{}) error {
switch v := v.(type) {
case nil:
return fmt.Errorf("redis: Scan(nil)")
case *string:
*v = util.BytesToString(b)
return nil
case *[]byte:
*v = b
return nil
case *int:
var err error
*v, err = util.Atoi(b)
return err
case *int8:
n, err := util.ParseInt(b, 10, 8)
if err != nil {
return err
}
*v = int8(n)
return nil
case *int16:
n, err := util.ParseInt(b, 10, 16)
if err != nil {
return err
}
*v = int16(n)
return nil
case *int32:
n, err := util.ParseInt(b, 10, 32)
if err != nil {
return err
}
*v = int32(n)
return nil
case *int64:
n, err := util.ParseInt(b, 10, 64)
if err != nil {
return err
}
*v = n
return nil
case *uint:
n, err := util.ParseUint(b, 10, 64)
if err != nil {
return err
}
*v = uint(n)
return nil
case *uint8:
n, err := util.ParseUint(b, 10, 8)
if err != nil {
return err
}
*v = uint8(n)
return nil
case *uint16:
n, err := util.ParseUint(b, 10, 16)
if err != nil {
return err
}
*v = uint16(n)
return nil
case *uint32:
n, err := util.ParseUint(b, 10, 32)
if err != nil {
return err
}
*v = uint32(n)
return nil
case *uint64:
n, err := util.ParseUint(b, 10, 64)
if err != nil {
return err
}
*v = n
return nil
case *float32:
n, err := util.ParseFloat(b, 32)
if err != nil {
return err
}
*v = float32(n)
return err
case *float64:
var err error
*v, err = util.ParseFloat(b, 64)
return err
case *bool:
*v = len(b) == 1 && b[0] == '1'
return nil
case *time.Time:
var err error
*v, err = time.Parse(time.RFC3339Nano, util.BytesToString(b))
return err
case *time.Duration:
n, err := util.ParseInt(b, 10, 64)
if err != nil {
return err
}
*v = time.Duration(n)
return nil
case encoding.BinaryUnmarshaler:
return v.UnmarshalBinary(b)
case *net.IP:
*v = b
return nil
default:
return fmt.Errorf(
"redis: can't unmarshal %T (consider implementing BinaryUnmarshaler)", v)
}
}
func ScanSlice(data []string, slice interface{}) error {
v := reflect.ValueOf(slice)
if !v.IsValid() {
return fmt.Errorf("redis: ScanSlice(nil)")
}
if v.Kind() != reflect.Ptr {
return fmt.Errorf("redis: ScanSlice(non-pointer %T)", slice)
}
v = v.Elem()
if v.Kind() != reflect.Slice {
return fmt.Errorf("redis: ScanSlice(non-slice %T)", slice)
}
next := makeSliceNextElemFunc(v)
for i, s := range data {
elem := next()
if err := Scan([]byte(s), elem.Addr().Interface()); err != nil {
err = fmt.Errorf("redis: ScanSlice index=%d value=%q failed: %w", i, s, err)
return err
}
}
return nil
}
func makeSliceNextElemFunc(v reflect.Value) func() reflect.Value {
elemType := v.Type().Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
return func() reflect.Value {
if v.Len() < v.Cap() {
v.Set(v.Slice(0, v.Len()+1))
elem := v.Index(v.Len() - 1)
if elem.IsNil() {
elem.Set(reflect.New(elemType))
}
return elem.Elem()
}
elem := reflect.New(elemType)
v.Set(reflect.Append(v, elem))
return elem.Elem()
}
}
zero := reflect.Zero(elemType)
return func() reflect.Value {
if v.Len() < v.Cap() {
v.Set(v.Slice(0, v.Len()+1))
return v.Index(v.Len() - 1)
}
v.Set(reflect.Append(v, zero))
return v.Index(v.Len() - 1)
}
}
+242
View File
@@ -0,0 +1,242 @@
package proto
import (
"encoding"
"fmt"
"io"
"net"
"strconv"
"time"
"github.com/redis/go-redis/v9/internal/util"
)
type writer interface {
io.Writer
io.ByteWriter
// WriteString implement io.StringWriter.
WriteString(s string) (n int, err error)
}
type Writer struct {
writer
lenBuf []byte
numBuf []byte
}
func NewWriter(wr writer) *Writer {
return &Writer{
writer: wr,
lenBuf: make([]byte, 64),
numBuf: make([]byte, 64),
}
}
func (w *Writer) WriteArgs(args []interface{}) error {
if err := w.WriteByte(RespArray); err != nil {
return err
}
if err := w.writeLen(len(args)); err != nil {
return err
}
for _, arg := range args {
if err := w.WriteArg(arg); err != nil {
return err
}
}
return nil
}
func (w *Writer) writeLen(n int) error {
w.lenBuf = strconv.AppendUint(w.lenBuf[:0], uint64(n), 10)
w.lenBuf = append(w.lenBuf, '\r', '\n')
_, err := w.Write(w.lenBuf)
return err
}
func (w *Writer) WriteArg(v interface{}) error {
switch v := v.(type) {
case nil:
return w.string("")
case string:
return w.string(v)
case *string:
if v == nil {
return w.string("")
}
return w.string(*v)
case []byte:
return w.bytes(v)
case int:
return w.int(int64(v))
case *int:
if v == nil {
return w.int(0)
}
return w.int(int64(*v))
case int8:
return w.int(int64(v))
case *int8:
if v == nil {
return w.int(0)
}
return w.int(int64(*v))
case int16:
return w.int(int64(v))
case *int16:
if v == nil {
return w.int(0)
}
return w.int(int64(*v))
case int32:
return w.int(int64(v))
case *int32:
if v == nil {
return w.int(0)
}
return w.int(int64(*v))
case int64:
return w.int(v)
case *int64:
if v == nil {
return w.int(0)
}
return w.int(*v)
case uint:
return w.uint(uint64(v))
case *uint:
if v == nil {
return w.uint(0)
}
return w.uint(uint64(*v))
case uint8:
return w.uint(uint64(v))
case *uint8:
if v == nil {
return w.string("")
}
return w.uint(uint64(*v))
case uint16:
return w.uint(uint64(v))
case *uint16:
if v == nil {
return w.uint(0)
}
return w.uint(uint64(*v))
case uint32:
return w.uint(uint64(v))
case *uint32:
if v == nil {
return w.uint(0)
}
return w.uint(uint64(*v))
case uint64:
return w.uint(v)
case *uint64:
if v == nil {
return w.uint(0)
}
return w.uint(*v)
case float32:
return w.float(float64(v))
case *float32:
if v == nil {
return w.float(0)
}
return w.float(float64(*v))
case float64:
return w.float(v)
case *float64:
if v == nil {
return w.float(0)
}
return w.float(*v)
case bool:
if v {
return w.int(1)
}
return w.int(0)
case *bool:
if v == nil {
return w.int(0)
}
if *v {
return w.int(1)
}
return w.int(0)
case time.Time:
w.numBuf = v.AppendFormat(w.numBuf[:0], time.RFC3339Nano)
return w.bytes(w.numBuf)
case *time.Time:
if v == nil {
v = &time.Time{}
}
w.numBuf = v.AppendFormat(w.numBuf[:0], time.RFC3339Nano)
return w.bytes(w.numBuf)
case time.Duration:
return w.int(v.Nanoseconds())
case *time.Duration:
if v == nil {
return w.int(0)
}
return w.int(v.Nanoseconds())
case encoding.BinaryMarshaler:
b, err := v.MarshalBinary()
if err != nil {
return err
}
return w.bytes(b)
case net.IP:
return w.bytes(v)
default:
return fmt.Errorf(
"redis: can't marshal %T (implement encoding.BinaryMarshaler)", v)
}
}
func (w *Writer) bytes(b []byte) error {
if err := w.WriteByte(RespString); err != nil {
return err
}
if err := w.writeLen(len(b)); err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
return w.crlf()
}
func (w *Writer) string(s string) error {
return w.bytes(util.StringToBytes(s))
}
func (w *Writer) uint(n uint64) error {
w.numBuf = strconv.AppendUint(w.numBuf[:0], n, 10)
return w.bytes(w.numBuf)
}
func (w *Writer) int(n int64) error {
w.numBuf = strconv.AppendInt(w.numBuf[:0], n, 10)
return w.bytes(w.numBuf)
}
func (w *Writer) float(f float64) error {
w.numBuf = strconv.AppendFloat(w.numBuf[:0], f, 'f', -1, 64)
return w.bytes(w.numBuf)
}
func (w *Writer) crlf() error {
if err := w.WriteByte('\r'); err != nil {
return err
}
return w.WriteByte('\n')
}
+50
View File
@@ -0,0 +1,50 @@
package rand
import (
"math/rand"
"sync"
)
// Int returns a non-negative pseudo-random int.
func Int() int { return pseudo.Int() }
// Intn returns, as an int, a non-negative pseudo-random number in [0,n).
// It panics if n <= 0.
func Intn(n int) int { return pseudo.Intn(n) }
// Int63n returns, as an int64, a non-negative pseudo-random number in [0,n).
// It panics if n <= 0.
func Int63n(n int64) int64 { return pseudo.Int63n(n) }
// Perm returns, as a slice of n ints, a pseudo-random permutation of the integers [0,n).
func Perm(n int) []int { return pseudo.Perm(n) }
// Seed uses the provided seed value to initialize the default Source to a
// deterministic state. If Seed is not called, the generator behaves as if
// seeded by Seed(1).
func Seed(n int64) { pseudo.Seed(n) }
var pseudo = rand.New(&source{src: rand.NewSource(1)})
type source struct {
src rand.Source
mu sync.Mutex
}
func (s *source) Int63() int64 {
s.mu.Lock()
n := s.src.Int63()
s.mu.Unlock()
return n
}
func (s *source) Seed(seed int64) {
s.mu.Lock()
s.src.Seed(seed)
s.mu.Unlock()
}
// Shuffle pseudo-randomizes the order of elements.
// n is the number of elements.
// swap swaps the elements with indexes i and j.
func Shuffle(n int, swap func(i, j int)) { pseudo.Shuffle(n, swap) }
+3
View File
@@ -0,0 +1,3 @@
package internal
const RedisNull = "<nil>"
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
package routing
import (
"fmt"
"strings"
)
type RequestPolicy uint8
const (
ReqDefault RequestPolicy = iota
ReqAllNodes
ReqAllShards
ReqMultiShard
ReqSpecial
)
const (
ReadOnlyCMD string = "readonly"
)
func (p RequestPolicy) String() string {
switch p {
case ReqDefault:
return "default"
case ReqAllNodes:
return "all_nodes"
case ReqAllShards:
return "all_shards"
case ReqMultiShard:
return "multi_shard"
case ReqSpecial:
return "special"
default:
return fmt.Sprintf("unknown_request_policy(%d)", p)
}
}
func ParseRequestPolicy(raw string) (RequestPolicy, error) {
switch strings.ToLower(raw) {
case "", "default", "none":
return ReqDefault, nil
case "all_nodes":
return ReqAllNodes, nil
case "all_shards":
return ReqAllShards, nil
case "multi_shard":
return ReqMultiShard, nil
case "special":
return ReqSpecial, nil
default:
return ReqDefault, fmt.Errorf("routing: unknown request_policy %q", raw)
}
}
type ResponsePolicy uint8
const (
RespDefaultKeyless ResponsePolicy = iota
RespDefaultHashSlot
RespAllSucceeded
RespOneSucceeded
RespAggSum
RespAggMin
RespAggMax
RespAggLogicalAnd
RespAggLogicalOr
RespSpecial
)
func (p ResponsePolicy) String() string {
switch p {
case RespDefaultKeyless:
return "default(keyless)"
case RespDefaultHashSlot:
return "default(hashslot)"
case RespAllSucceeded:
return "all_succeeded"
case RespOneSucceeded:
return "one_succeeded"
case RespAggSum:
return "agg_sum"
case RespAggMin:
return "agg_min"
case RespAggMax:
return "agg_max"
case RespAggLogicalAnd:
return "agg_logical_and"
case RespAggLogicalOr:
return "agg_logical_or"
case RespSpecial:
return "special"
default:
return "all_succeeded"
}
}
func ParseResponsePolicy(raw string) (ResponsePolicy, error) {
switch strings.ToLower(raw) {
case "default(keyless)":
return RespDefaultKeyless, nil
case "default(hashslot)":
return RespDefaultHashSlot, nil
case "all_succeeded":
return RespAllSucceeded, nil
case "one_succeeded":
return RespOneSucceeded, nil
case "agg_sum":
return RespAggSum, nil
case "agg_min":
return RespAggMin, nil
case "agg_max":
return RespAggMax, nil
case "agg_logical_and":
return RespAggLogicalAnd, nil
case "agg_logical_or":
return RespAggLogicalOr, nil
case "special":
return RespSpecial, nil
default:
return RespDefaultKeyless, fmt.Errorf("routing: unknown response_policy %q", raw)
}
}
type CommandPolicy struct {
Request RequestPolicy
Response ResponsePolicy
// Tips that are not request_policy or response_policy
// e.g nondeterministic_output, nondeterministic_output_order.
Tips map[string]string
}
func (p *CommandPolicy) CanBeUsedInPipeline() bool {
return p.Request != ReqAllNodes && p.Request != ReqAllShards && p.Request != ReqMultiShard
}
func (p *CommandPolicy) IsReadOnly() bool {
_, readOnly := p.Tips[ReadOnlyCMD]
return readOnly
}
+57
View File
@@ -0,0 +1,57 @@
package routing
import (
"math/rand"
"sync/atomic"
)
// ShardPicker chooses “one arbitrary shard” when the request_policy is
// ReqDefault and the command has no keys.
type ShardPicker interface {
Next(total int) int // returns an index in [0,total)
}
// StaticShardPicker always returns the same shard index.
type StaticShardPicker struct {
index int
}
func NewStaticShardPicker(index int) *StaticShardPicker {
return &StaticShardPicker{index: index}
}
func (p *StaticShardPicker) Next(total int) int {
if total == 0 || p.index >= total {
return 0
}
return p.index
}
/*───────────────────────────────
Round-robin (default)
────────────────────────────────*/
type RoundRobinPicker struct {
cnt atomic.Uint32
}
func (p *RoundRobinPicker) Next(total int) int {
if total == 0 {
return 0
}
i := p.cnt.Add(1)
return int(i-1) % total
}
/*───────────────────────────────
Random
────────────────────────────────*/
type RandomPicker struct{}
func (RandomPicker) Next(total int) int {
if total == 0 {
return 0
}
return rand.Intn(total)
}
+193
View File
@@ -0,0 +1,193 @@
package internal
import (
"context"
"sync"
"time"
)
var semTimers = sync.Pool{
New: func() interface{} {
t := time.NewTimer(time.Hour)
t.Stop()
return t
},
}
// FastSemaphore is a channel-based semaphore optimized for performance.
// It uses a fast path that avoids timer allocation when tokens are available.
// The channel is pre-filled with tokens: Acquire = receive, Release = send.
// Closing the semaphore unblocks all waiting goroutines.
//
// Performance: ~30 ns/op with zero allocations on fast path.
// Fairness: Eventual fairness (no starvation) but not strict FIFO.
type FastSemaphore struct {
tokens chan struct{}
max int32
}
// NewFastSemaphore creates a new fast semaphore with the given capacity.
func NewFastSemaphore(capacity int32) *FastSemaphore {
ch := make(chan struct{}, capacity)
// Pre-fill with tokens
for i := int32(0); i < capacity; i++ {
ch <- struct{}{}
}
return &FastSemaphore{
tokens: ch,
max: capacity,
}
}
// TryAcquire attempts to acquire a token without blocking.
// Returns true if successful, false if no tokens available.
func (s *FastSemaphore) TryAcquire() bool {
select {
case <-s.tokens:
return true
default:
return false
}
}
// Acquire acquires a token, blocking if necessary until one is available.
// Returns an error if the context is cancelled or the timeout expires.
// Uses a fast path to avoid timer allocation when tokens are immediately available.
func (s *FastSemaphore) Acquire(ctx context.Context, timeout time.Duration, timeoutErr error) error {
// Check context first
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Try fast path first (no timer needed)
select {
case <-s.tokens:
return nil
default:
}
// Slow path: need to wait with timeout
timer := semTimers.Get().(*time.Timer)
defer semTimers.Put(timer)
timer.Reset(timeout)
select {
case <-s.tokens:
if !timer.Stop() {
<-timer.C
}
return nil
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
return timeoutErr
}
}
// AcquireBlocking acquires a token, blocking indefinitely until one is available.
func (s *FastSemaphore) AcquireBlocking() {
<-s.tokens
}
// Release releases a token back to the semaphore.
func (s *FastSemaphore) Release() {
s.tokens <- struct{}{}
}
// Close closes the semaphore, unblocking all waiting goroutines.
// After close, all Acquire calls will receive a closed channel signal.
func (s *FastSemaphore) Close() {
close(s.tokens)
}
// Len returns the current number of acquired tokens.
func (s *FastSemaphore) Len() int32 {
return s.max - int32(len(s.tokens))
}
// FIFOSemaphore is a channel-based semaphore with strict FIFO ordering.
// Unlike FastSemaphore, this guarantees that threads are served in the exact order they call Acquire().
// The channel is pre-filled with tokens: Acquire = receive, Release = send.
// Closing the semaphore unblocks all waiting goroutines.
//
// Performance: ~115 ns/op with zero allocations (slower than FastSemaphore due to timer allocation).
// Fairness: Strict FIFO ordering guaranteed by Go runtime.
type FIFOSemaphore struct {
tokens chan struct{}
max int32
}
// NewFIFOSemaphore creates a new FIFO semaphore with the given capacity.
func NewFIFOSemaphore(capacity int32) *FIFOSemaphore {
ch := make(chan struct{}, capacity)
// Pre-fill with tokens
for i := int32(0); i < capacity; i++ {
ch <- struct{}{}
}
return &FIFOSemaphore{
tokens: ch,
max: capacity,
}
}
// TryAcquire attempts to acquire a token without blocking.
// Returns true if successful, false if no tokens available.
func (s *FIFOSemaphore) TryAcquire() bool {
select {
case <-s.tokens:
return true
default:
return false
}
}
// Acquire acquires a token, blocking if necessary until one is available.
// Returns an error if the context is cancelled or the timeout expires.
// Always uses timer to guarantee FIFO ordering (no fast path).
func (s *FIFOSemaphore) Acquire(ctx context.Context, timeout time.Duration, timeoutErr error) error {
// No fast path - always use timer to guarantee FIFO
timer := semTimers.Get().(*time.Timer)
defer semTimers.Put(timer)
timer.Reset(timeout)
select {
case <-s.tokens:
if !timer.Stop() {
<-timer.C
}
return nil
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
return timeoutErr
}
}
// AcquireBlocking acquires a token, blocking indefinitely until one is available.
func (s *FIFOSemaphore) AcquireBlocking() {
<-s.tokens
}
// Release releases a token back to the semaphore.
func (s *FIFOSemaphore) Release() {
s.tokens <- struct{}{}
}
// Close closes the semaphore, unblocking all waiting goroutines.
// After close, all Acquire calls will receive a closed channel signal.
func (s *FIFOSemaphore) Close() {
close(s.tokens)
}
// Len returns the current number of acquired tokens.
func (s *FIFOSemaphore) Len() int32 {
return s.max - int32(len(s.tokens))
}
+113
View File
@@ -0,0 +1,113 @@
package internal
import (
"context"
"net"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9/internal/util"
)
func Sleep(ctx context.Context, dur time.Duration) error {
t := time.NewTimer(dur)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func ToLower(s string) string {
if isLower(s) {
return s
}
b := make([]byte, len(s))
for i := range b {
c := s[i]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
b[i] = c
}
return util.BytesToString(b)
}
func isLower(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
return false
}
}
return true
}
func ReplaceSpaces(s string) string {
return strings.ReplaceAll(s, " ", "-")
}
func GetAddr(addr string) string {
ind := strings.LastIndexByte(addr, ':')
if ind == -1 {
return ""
}
if strings.IndexByte(addr, '.') != -1 {
return addr
}
if addr[0] == '[' {
return addr
}
return net.JoinHostPort(addr[:ind], addr[ind+1:])
}
func ToInteger(val interface{}) int {
switch v := val.(type) {
case int:
return v
case int64:
return int(v)
case string:
i, _ := strconv.Atoi(v)
return i
default:
return 0
}
}
func ToFloat(val interface{}) float64 {
switch v := val.(type) {
case float64:
return v
case string:
f, _ := strconv.ParseFloat(v, 64)
return f
default:
return 0.0
}
}
func ToString(val interface{}) string {
if str, ok := val.(string); ok {
return str
}
return ""
}
func ToStringSlice(val interface{}) []string {
if arr, ok := val.([]interface{}); ok {
result := make([]string, len(arr))
for i, v := range arr {
result[i] = ToString(v)
}
return result
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
/*
© 2023present Harald Rudell <harald.rudell@gmail.com> (https://haraldrudell.github.io/haraldrudell/)
ISC License
Modified by htemelski-redis
Removed the treshold, adapted it to work with float64
*/
package util
import (
"math"
"go.uber.org/atomic"
)
// AtomicMax is a thread-safe max container
// - hasValue indicator true if a value was equal to or greater than threshold
// - optional threshold for minimum accepted max value
// - if threshold is not used, initialization-free
// - —
// - wait-free CompareAndSwap mechanic
type AtomicMax struct {
// value is current max
value atomic.Float64
// whether [AtomicMax.Value] has been invoked
// with value equal or greater to threshold
hasValue atomic.Bool
}
// NewAtomicMax returns a thread-safe max container
// - if threshold is not used, AtomicMax is initialization-free
func NewAtomicMax() (atomicMax *AtomicMax) {
m := AtomicMax{}
m.value.Store((-math.MaxFloat64))
return &m
}
// Value updates the container with a possible max value
// - isNewMax is true if:
// - — value is equal to or greater than any threshold and
// - — invocation recorded the first 0 or
// - — a new max
// - upon return, Max and Max1 are guaranteed to reflect the invocation
// - the return order of concurrent Value invocations is not guaranteed
// - Thread-safe
func (m *AtomicMax) Value(value float64) (isNewMax bool) {
// -math.MaxFloat64 as max case
var hasValue0 = m.hasValue.Load()
if value == (-math.MaxFloat64) {
if !hasValue0 {
isNewMax = m.hasValue.CompareAndSwap(false, true)
}
return // -math.MaxFloat64 as max: isNewMax true for first 0 writer
}
// check against present value
var current = m.value.Load()
if isNewMax = value > current; !isNewMax {
return // not a new max return: isNewMax false
}
// store the new max
for {
// try to write value to *max
if isNewMax = m.value.CompareAndSwap(current, value); isNewMax {
if !hasValue0 {
// may be rarely written multiple times
// still faster than CompareAndSwap
m.hasValue.Store(true)
}
return // new max written return: isNewMax true
}
if current = m.value.Load(); current >= value {
return // no longer a need to write return: isNewMax false
}
}
}
// Max returns current max and value-present flag
// - hasValue true indicates that value reflects a Value invocation
// - hasValue false: value is zero-value
// - Thread-safe
func (m *AtomicMax) Max() (value float64, hasValue bool) {
if hasValue = m.hasValue.Load(); !hasValue {
return
}
value = m.value.Load()
return
}
// Max1 returns current maximum whether zero-value or set by Value
// - threshold is ignored
// - Thread-safe
func (m *AtomicMax) Max1() (value float64) { return m.value.Load() }
+96
View File
@@ -0,0 +1,96 @@
package util
/*
© 2023present Harald Rudell <harald.rudell@gmail.com> (https://haraldrudell.github.io/haraldrudell/)
ISC License
Modified by htemelski-redis
Adapted from the modified atomic_max, but with inverted logic
*/
import (
"math"
"go.uber.org/atomic"
)
// AtomicMin is a thread-safe Min container
// - hasValue indicator true if a value was equal to or greater than threshold
// - optional threshold for minimum accepted Min value
// - —
// - wait-free CompareAndSwap mechanic
type AtomicMin struct {
// value is current Min
value atomic.Float64
// whether [AtomicMin.Value] has been invoked
// with value equal or greater to threshold
hasValue atomic.Bool
}
// NewAtomicMin returns a thread-safe Min container
// - if threshold is not used, AtomicMin is initialization-free
func NewAtomicMin() (atomicMin *AtomicMin) {
m := AtomicMin{}
m.value.Store(math.MaxFloat64)
return &m
}
// Value updates the container with a possible Min value
// - isNewMin is true if:
// - — value is equal to or greater than any threshold and
// - — invocation recorded the first 0 or
// - — a new Min
// - upon return, Min and Min1 are guaranteed to reflect the invocation
// - the return order of concurrent Value invocations is not guaranteed
// - Thread-safe
func (m *AtomicMin) Value(value float64) (isNewMin bool) {
// math.MaxFloat64 as Min case
var hasValue0 = m.hasValue.Load()
if value == math.MaxFloat64 {
if !hasValue0 {
isNewMin = m.hasValue.CompareAndSwap(false, true)
}
return // math.MaxFloat64 as Min: isNewMin true for first 0 writer
}
// check against present value
var current = m.value.Load()
if isNewMin = value < current; !isNewMin {
return // not a new Min return: isNewMin false
}
// store the new Min
for {
// try to write value to *Min
if isNewMin = m.value.CompareAndSwap(current, value); isNewMin {
if !hasValue0 {
// may be rarely written multiple times
// still faster than CompareAndSwap
m.hasValue.Store(true)
}
return // new Min written return: isNewMin true
}
if current = m.value.Load(); current <= value {
return // no longer a need to write return: isNewMin false
}
}
}
// Min returns current min and value-present flag
// - hasValue true indicates that value reflects a Value invocation
// - hasValue false: value is zero-value
// - Thread-safe
func (m *AtomicMin) Min() (value float64, hasValue bool) {
if hasValue = m.hasValue.Load(); !hasValue {
return
}
value = m.value.Load()
return
}
// Min1 returns current Minimum whether zero-value or set by Value
// - threshold is ignored
// - Thread-safe
func (m *AtomicMin) Min1() (value float64) { return m.value.Load() }
+41
View File
@@ -0,0 +1,41 @@
package util
import (
"fmt"
"math"
"strconv"
)
// ParseFloat parses a Redis RESP3 float reply into a Go float64,
// handling "inf", "-inf", "nan" per Redis conventions.
func ParseStringToFloat(s string) (float64, error) {
switch s {
case "inf":
return math.Inf(1), nil
case "-inf":
return math.Inf(-1), nil
case "nan", "-nan":
return math.NaN(), nil
}
return strconv.ParseFloat(s, 64)
}
// MustParseFloat is like ParseFloat but panics on parse errors.
func MustParseFloat(s string) float64 {
f, err := ParseStringToFloat(s)
if err != nil {
panic(fmt.Sprintf("redis: failed to parse float %q: %v", s, err))
}
return f
}
// SafeIntToInt32 safely converts an int to int32, returning an error if overflow would occur.
func SafeIntToInt32(value int, fieldName string) (int32, error) {
if value > math.MaxInt32 {
return 0, fmt.Errorf("redis: %s value %d exceeds maximum allowed value %d", fieldName, value, math.MaxInt32)
}
if value < math.MinInt32 {
return 0, fmt.Errorf("redis: %s value %d is below minimum allowed value %d", fieldName, value, math.MinInt32)
}
return int32(value), nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build appengine
package util
func BytesToString(b []byte) string {
return string(b)
}
func StringToBytes(s string) []byte {
return []byte(s)
}
+19
View File
@@ -0,0 +1,19 @@
package util
import "strconv"
func Atoi(b []byte) (int, error) {
return strconv.Atoi(BytesToString(b))
}
func ParseInt(b []byte, base int, bitSize int) (int64, error) {
return strconv.ParseInt(BytesToString(b), base, bitSize)
}
func ParseUint(b []byte, base int, bitSize int) (uint64, error) {
return strconv.ParseUint(BytesToString(b), base, bitSize)
}
func ParseFloat(b []byte, bitSize int) (float64, error) {
return strconv.ParseFloat(BytesToString(b), bitSize)
}
+5
View File
@@ -0,0 +1,5 @@
package util
func ToPtr[T any](v T) *T {
return &v
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !appengine
package util
import (
"unsafe"
)
// BytesToString converts byte slice to string.
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
// StringToBytes converts string to byte slice.
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
+66
View File
@@ -0,0 +1,66 @@
package redis
import (
"context"
)
// ScanIterator is used to incrementally iterate over a collection of elements.
type ScanIterator struct {
cmd *ScanCmd
pos int
}
// Err returns the last iterator error, if any.
func (it *ScanIterator) Err() error {
return it.cmd.Err()
}
// Next advances the cursor and returns true if more values can be read.
func (it *ScanIterator) Next(ctx context.Context) bool {
// Instantly return on errors.
if it.cmd.Err() != nil {
return false
}
// Advance cursor, check if we are still within range.
if it.pos < len(it.cmd.page) {
it.pos++
return true
}
for {
// Return if there is no more data to fetch.
if it.cmd.cursor == 0 {
return false
}
// Fetch next page.
switch it.cmd.args[0] {
case "scan", "qscan":
it.cmd.args[1] = it.cmd.cursor
default:
it.cmd.args[2] = it.cmd.cursor
}
err := it.cmd.process(ctx, it.cmd)
if err != nil {
return false
}
it.pos = 1
// Redis can occasionally return empty page.
if len(it.cmd.page) > 0 {
return true
}
}
}
// Val returns the key/field at the current cursor position.
func (it *ScanIterator) Val() string {
var v string
if it.cmd.Err() == nil && it.pos > 0 && it.pos <= len(it.cmd.page) {
v = it.cmd.page[it.pos-1]
}
return v
}
+650
View File
@@ -0,0 +1,650 @@
package redis
import (
"context"
"encoding/json"
"strings"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/internal/util"
)
// -------------------------------------------
type JSONCmdable interface {
JSONArrAppend(ctx context.Context, key, path string, values ...interface{}) *IntSliceCmd
JSONArrIndex(ctx context.Context, key, path string, value ...interface{}) *IntSliceCmd
JSONArrIndexWithArgs(ctx context.Context, key, path string, options *JSONArrIndexArgs, value ...interface{}) *IntSliceCmd
JSONArrInsert(ctx context.Context, key, path string, index int64, values ...interface{}) *IntSliceCmd
JSONArrLen(ctx context.Context, key, path string) *IntSliceCmd
JSONArrPop(ctx context.Context, key, path string, index int) *StringSliceCmd
JSONArrTrim(ctx context.Context, key, path string) *IntSliceCmd
JSONArrTrimWithArgs(ctx context.Context, key, path string, options *JSONArrTrimArgs) *IntSliceCmd
JSONClear(ctx context.Context, key, path string) *IntCmd
JSONDebugMemory(ctx context.Context, key, path string) *IntCmd
JSONDel(ctx context.Context, key, path string) *IntCmd
JSONForget(ctx context.Context, key, path string) *IntCmd
JSONGet(ctx context.Context, key string, paths ...string) *JSONCmd
JSONGetWithArgs(ctx context.Context, key string, options *JSONGetArgs, paths ...string) *JSONCmd
JSONMerge(ctx context.Context, key, path string, value string) *StatusCmd
JSONMSetArgs(ctx context.Context, docs []JSONSetArgs) *StatusCmd
JSONMSet(ctx context.Context, params ...interface{}) *StatusCmd
JSONMGet(ctx context.Context, path string, keys ...string) *JSONSliceCmd
JSONNumIncrBy(ctx context.Context, key, path string, value float64) *JSONCmd
JSONObjKeys(ctx context.Context, key, path string) *SliceCmd
JSONObjLen(ctx context.Context, key, path string) *IntPointerSliceCmd
JSONSet(ctx context.Context, key, path string, value interface{}) *StatusCmd
JSONSetMode(ctx context.Context, key, path string, value interface{}, mode string) *StatusCmd
JSONStrAppend(ctx context.Context, key, path, value string) *IntPointerSliceCmd
JSONStrLen(ctx context.Context, key, path string) *IntPointerSliceCmd
JSONToggle(ctx context.Context, key, path string) *IntPointerSliceCmd
JSONType(ctx context.Context, key, path string) *JSONSliceCmd
}
type JSONSetArgs struct {
Key string
Path string
Value interface{}
}
type JSONArrIndexArgs struct {
Start int
Stop *int
}
type JSONArrTrimArgs struct {
Start int
Stop *int
}
type JSONCmd struct {
baseCmd
val string
expanded interface{}
}
var _ Cmder = (*JSONCmd)(nil)
func newJSONCmd(ctx context.Context, args ...interface{}) *JSONCmd {
return &JSONCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeJSON,
},
}
}
func (cmd *JSONCmd) String() string {
return cmdString(cmd, cmd.val)
}
func (cmd *JSONCmd) SetVal(val string) {
cmd.val = val
}
// Val returns the result of the JSON.GET command as a string.
func (cmd *JSONCmd) Val() string {
if len(cmd.val) == 0 && cmd.expanded != nil {
val, err := json.Marshal(cmd.expanded)
if err != nil {
cmd.SetErr(err)
return ""
}
return string(val)
} else {
return cmd.val
}
}
func (cmd *JSONCmd) Result() (string, error) {
return cmd.Val(), cmd.Err()
}
// Expanded returns the result of the JSON.GET command as unmarshalled JSON.
func (cmd *JSONCmd) Expanded() (interface{}, error) {
if len(cmd.val) != 0 && cmd.expanded == nil {
err := json.Unmarshal([]byte(cmd.val), &cmd.expanded)
if err != nil {
return nil, err
}
}
return cmd.expanded, nil
}
func (cmd *JSONCmd) readReply(rd *proto.Reader) error {
// nil response from JSON.(M)GET (cmd.baseCmd.err will be "redis: nil")
// This happens when the key doesn't exist
if cmd.baseCmd.Err() == Nil {
cmd.val = ""
return Nil
}
// Handle other base command errors
if cmd.baseCmd.Err() != nil {
return cmd.baseCmd.Err()
}
if readType, err := rd.PeekReplyType(); err != nil {
return err
} else if readType == proto.RespArray {
size, err := rd.ReadArrayLen()
if err != nil {
return err
}
// Empty array means no results found for JSON path, but key exists
// This should return "[]", not an error
if size == 0 {
cmd.val = "[]"
return nil
}
expanded := make([]interface{}, size)
for i := 0; i < size; i++ {
if expanded[i], err = rd.ReadReply(); err != nil {
return err
}
}
cmd.expanded = expanded
} else {
if str, err := rd.ReadString(); err != nil && err != Nil {
return err
} else if str == "" || err == Nil {
cmd.val = ""
return Nil
} else {
cmd.val = str
}
}
return nil
}
func (cmd *JSONCmd) Clone() Cmder {
return &JSONCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
expanded: cmd.expanded, // interface{} can be shared as it should be immutable after parsing
}
}
// -------------------------------------------
type JSONSliceCmd struct {
baseCmd
val []interface{}
}
func NewJSONSliceCmd(ctx context.Context, args ...interface{}) *JSONSliceCmd {
return &JSONSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeJSONSlice,
},
}
}
func (cmd *JSONSliceCmd) String() string {
return cmdString(cmd, cmd.val)
}
func (cmd *JSONSliceCmd) SetVal(val []interface{}) {
cmd.val = val
}
func (cmd *JSONSliceCmd) Val() []interface{} {
return cmd.val
}
func (cmd *JSONSliceCmd) Result() ([]interface{}, error) {
return cmd.val, cmd.err
}
func (cmd *JSONSliceCmd) readReply(rd *proto.Reader) error {
if cmd.baseCmd.Err() == Nil {
cmd.val = nil
return Nil
}
if readType, err := rd.PeekReplyType(); err != nil {
return err
} else if readType == proto.RespArray {
response, err := rd.ReadReply()
if err != nil {
return nil
} else {
cmd.val = response.([]interface{})
}
} else {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]interface{}, n)
for i := 0; i < len(cmd.val); i++ {
switch s, err := rd.ReadString(); {
case err == Nil:
cmd.val[i] = ""
case err != nil:
return err
default:
cmd.val[i] = s
}
}
}
return nil
}
func (cmd *JSONSliceCmd) Clone() Cmder {
var val []interface{}
if cmd.val != nil {
val = make([]interface{}, len(cmd.val))
copy(val, cmd.val)
}
return &JSONSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
/*******************************************************************************
*
* IntPointerSliceCmd
* used to represent a RedisJSON response where the result is either an integer or nil
*
*******************************************************************************/
type IntPointerSliceCmd struct {
baseCmd
val []*int64
}
// NewIntPointerSliceCmd initialises an IntPointerSliceCmd
func NewIntPointerSliceCmd(ctx context.Context, args ...interface{}) *IntPointerSliceCmd {
return &IntPointerSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIntPointerSlice,
},
}
}
func (cmd *IntPointerSliceCmd) String() string {
return cmdString(cmd, cmd.val)
}
func (cmd *IntPointerSliceCmd) SetVal(val []*int64) {
cmd.val = val
}
func (cmd *IntPointerSliceCmd) Val() []*int64 {
return cmd.val
}
func (cmd *IntPointerSliceCmd) Result() ([]*int64, error) {
return cmd.val, cmd.err
}
func (cmd *IntPointerSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]*int64, n)
for i := 0; i < len(cmd.val); i++ {
val, err := rd.ReadInt()
if err != nil && err != Nil {
return err
} else if err != Nil {
cmd.val[i] = &val
}
}
return nil
}
func (cmd *IntPointerSliceCmd) Clone() Cmder {
var val []*int64
if cmd.val != nil {
val = make([]*int64, len(cmd.val))
copy(val, cmd.val)
}
return &IntPointerSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
// JSONArrAppend adds the provided JSON values to the end of the array at the given path.
// For more information, see https://redis.io/commands/json.arrappend
func (c cmdable) JSONArrAppend(ctx context.Context, key, path string, values ...interface{}) *IntSliceCmd {
args := []interface{}{"JSON.ARRAPPEND", key, path}
args = append(args, values...)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrIndex searches for the first occurrence of the provided JSON value in the array at the given path.
// For more information, see https://redis.io/commands/json.arrindex
func (c cmdable) JSONArrIndex(ctx context.Context, key, path string, value ...interface{}) *IntSliceCmd {
args := []interface{}{"JSON.ARRINDEX", key, path}
args = append(args, value...)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrIndexWithArgs searches for the first occurrence of a JSON value in an array while allowing the start and
// stop options to be provided.
// For more information, see https://redis.io/commands/json.arrindex
func (c cmdable) JSONArrIndexWithArgs(ctx context.Context, key, path string, options *JSONArrIndexArgs, value ...interface{}) *IntSliceCmd {
args := []interface{}{"JSON.ARRINDEX", key, path}
args = append(args, value...)
if options != nil {
args = append(args, options.Start)
if options.Stop != nil {
args = append(args, *options.Stop)
}
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrInsert inserts the JSON values into the array at the specified path before the index (shifts to the right).
// For more information, see https://redis.io/commands/json.arrinsert
func (c cmdable) JSONArrInsert(ctx context.Context, key, path string, index int64, values ...interface{}) *IntSliceCmd {
args := []interface{}{"JSON.ARRINSERT", key, path, index}
args = append(args, values...)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrLen reports the length of the JSON array at the specified path in the given key.
// For more information, see https://redis.io/commands/json.arrlen
func (c cmdable) JSONArrLen(ctx context.Context, key, path string) *IntSliceCmd {
args := []interface{}{"JSON.ARRLEN", key, path}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrPop removes and returns an element from the specified index in the array.
// For more information, see https://redis.io/commands/json.arrpop
func (c cmdable) JSONArrPop(ctx context.Context, key, path string, index int) *StringSliceCmd {
args := []interface{}{"JSON.ARRPOP", key, path, index}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrTrim trims an array to contain only the specified inclusive range of elements.
// For more information, see https://redis.io/commands/json.arrtrim
func (c cmdable) JSONArrTrim(ctx context.Context, key, path string) *IntSliceCmd {
args := []interface{}{"JSON.ARRTRIM", key, path}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONArrTrimWithArgs trims an array to contain only the specified inclusive range of elements.
// For more information, see https://redis.io/commands/json.arrtrim
func (c cmdable) JSONArrTrimWithArgs(ctx context.Context, key, path string, options *JSONArrTrimArgs) *IntSliceCmd {
args := []interface{}{"JSON.ARRTRIM", key, path}
if options != nil {
args = append(args, options.Start)
if options.Stop != nil {
args = append(args, *options.Stop)
}
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONClear clears container values (arrays/objects) and sets numeric values to 0.
// For more information, see https://redis.io/commands/json.clear
func (c cmdable) JSONClear(ctx context.Context, key, path string) *IntCmd {
args := []interface{}{"JSON.CLEAR", key, path}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONDebugMemory reports a value's memory usage in bytes (unimplemented)
// For more information, see https://redis.io/commands/json.debug-memory
func (c cmdable) JSONDebugMemory(ctx context.Context, key, path string) *IntCmd {
panic("not implemented")
}
// JSONDel deletes a value.
// For more information, see https://redis.io/commands/json.del
func (c cmdable) JSONDel(ctx context.Context, key, path string) *IntCmd {
args := []interface{}{"JSON.DEL", key, path}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONForget deletes a value.
// For more information, see https://redis.io/commands/json.forget
func (c cmdable) JSONForget(ctx context.Context, key, path string) *IntCmd {
args := []interface{}{"JSON.FORGET", key, path}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONGet returns the value at path in JSON serialized form. JSON.GET returns an
// array of strings. This function parses out the wrapping array but leaves the
// internal strings unprocessed by default (see Val())
// For more information - https://redis.io/commands/json.get/
func (c cmdable) JSONGet(ctx context.Context, key string, paths ...string) *JSONCmd {
args := make([]interface{}, len(paths)+2)
args[0] = "JSON.GET"
args[1] = key
for n, path := range paths {
args[n+2] = path
}
cmd := newJSONCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type JSONGetArgs struct {
Indent string
Newline string
Space string
}
// JSONGetWithArgs - Retrieves the value of a key from a JSON document.
// This function also allows for specifying additional options such as:
// Indention, NewLine and Space
// For more information - https://redis.io/commands/json.get/
func (c cmdable) JSONGetWithArgs(ctx context.Context, key string, options *JSONGetArgs, paths ...string) *JSONCmd {
args := []interface{}{"JSON.GET", key}
if options != nil {
if options.Indent != "" {
args = append(args, "INDENT", options.Indent)
}
if options.Newline != "" {
args = append(args, "NEWLINE", options.Newline)
}
if options.Space != "" {
args = append(args, "SPACE", options.Space)
}
for _, path := range paths {
args = append(args, path)
}
}
cmd := newJSONCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONMerge merges a given JSON value into matching paths.
// For more information, see https://redis.io/commands/json.merge
func (c cmdable) JSONMerge(ctx context.Context, key, path string, value string) *StatusCmd {
args := []interface{}{"JSON.MERGE", key, path, value}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONMGet returns the values at the specified path from multiple key arguments.
// Note - the arguments are reversed when compared with `JSON.MGET` as we want
// to follow the pattern of having the last argument be variable.
// For more information, see https://redis.io/commands/json.mget
func (c cmdable) JSONMGet(ctx context.Context, path string, keys ...string) *JSONSliceCmd {
args := make([]interface{}, len(keys)+1)
args[0] = "JSON.MGET"
for n, key := range keys {
args[n+1] = key
}
args = append(args, path)
cmd := NewJSONSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONMSetArgs sets or updates one or more JSON values according to the specified key-path-value triplets.
// For more information, see https://redis.io/commands/json.mset
func (c cmdable) JSONMSetArgs(ctx context.Context, docs []JSONSetArgs) *StatusCmd {
args := []interface{}{"JSON.MSET"}
for _, doc := range docs {
args = append(args, doc.Key, doc.Path, doc.Value)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) JSONMSet(ctx context.Context, params ...interface{}) *StatusCmd {
args := []interface{}{"JSON.MSET"}
args = append(args, params...)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONNumIncrBy increments the number value stored at the specified path by the provided number.
// For more information, see https://redis.io/docs/latest/commands/json.numincrby/
func (c cmdable) JSONNumIncrBy(ctx context.Context, key, path string, value float64) *JSONCmd {
args := []interface{}{"JSON.NUMINCRBY", key, path, value}
cmd := newJSONCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONObjKeys returns the keys in the object that's referenced by the specified path.
// For more information, see https://redis.io/commands/json.objkeys
func (c cmdable) JSONObjKeys(ctx context.Context, key, path string) *SliceCmd {
args := []interface{}{"JSON.OBJKEYS", key, path}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONObjLen reports the number of keys in the JSON object at the specified path in the given key.
// For more information, see https://redis.io/commands/json.objlen
func (c cmdable) JSONObjLen(ctx context.Context, key, path string) *IntPointerSliceCmd {
args := []interface{}{"JSON.OBJLEN", key, path}
cmd := NewIntPointerSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONSet sets the JSON value at the given path in the given key. The value must be something that
// can be marshaled to JSON (using encoding/JSON) unless the argument is a string or a []byte when we assume that
// it can be passed directly as JSON.
// For more information, see https://redis.io/commands/json.set
func (c cmdable) JSONSet(ctx context.Context, key, path string, value interface{}) *StatusCmd {
return c.JSONSetMode(ctx, key, path, value, "")
}
// JSONSetMode sets the JSON value at the given path in the given key and allows the mode to be set
// (the mode value must be "XX" or "NX"). The value must be something that can be marshaled to JSON (using encoding/JSON) unless
// the argument is a string or []byte when we assume that it can be passed directly as JSON.
// For more information, see https://redis.io/commands/json.set
func (c cmdable) JSONSetMode(ctx context.Context, key, path string, value interface{}, mode string) *StatusCmd {
var bytes []byte
var err error
switch v := value.(type) {
case string:
bytes = []byte(v)
case []byte:
bytes = v
default:
bytes, err = json.Marshal(v)
}
args := []interface{}{"JSON.SET", key, path, util.BytesToString(bytes)}
if mode != "" {
switch strings.ToUpper(mode) {
case "XX", "NX":
args = append(args, strings.ToUpper(mode))
default:
panic("redis: JSON.SET mode must be NX or XX")
}
}
cmd := NewStatusCmd(ctx, args...)
if err != nil {
cmd.SetErr(err)
} else {
_ = c(ctx, cmd)
}
return cmd
}
// JSONStrAppend appends the JSON-string values to the string at the specified path.
// For more information, see https://redis.io/commands/json.strappend
func (c cmdable) JSONStrAppend(ctx context.Context, key, path, value string) *IntPointerSliceCmd {
args := []interface{}{"JSON.STRAPPEND", key, path, value}
cmd := NewIntPointerSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONStrLen reports the length of the JSON String at the specified path in the given key.
// For more information, see https://redis.io/commands/json.strlen
func (c cmdable) JSONStrLen(ctx context.Context, key, path string) *IntPointerSliceCmd {
args := []interface{}{"JSON.STRLEN", key, path}
cmd := NewIntPointerSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONToggle toggles a Boolean value stored at the specified path.
// For more information, see https://redis.io/commands/json.toggle
func (c cmdable) JSONToggle(ctx context.Context, key, path string) *IntPointerSliceCmd {
args := []interface{}{"JSON.TOGGLE", key, path}
cmd := NewIntPointerSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// JSONType reports the type of JSON value at the specified path.
// For more information, see https://redis.io/commands/json.type
func (c cmdable) JSONType(ctx context.Context, key, path string) *JSONSliceCmd {
args := []interface{}{"JSON.TYPE", key, path}
cmd := NewJSONSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
+297
View File
@@ -0,0 +1,297 @@
package redis
import (
"context"
"strings"
"time"
)
type ListCmdable interface {
BLPop(ctx context.Context, timeout time.Duration, keys ...string) *StringSliceCmd
BLMPop(ctx context.Context, timeout time.Duration, direction string, count int64, keys ...string) *KeyValuesCmd
BRPop(ctx context.Context, timeout time.Duration, keys ...string) *StringSliceCmd
BRPopLPush(ctx context.Context, source, destination string, timeout time.Duration) *StringCmd
LIndex(ctx context.Context, key string, index int64) *StringCmd
LInsert(ctx context.Context, key, op string, pivot, value interface{}) *IntCmd
LInsertBefore(ctx context.Context, key string, pivot, value interface{}) *IntCmd
LInsertAfter(ctx context.Context, key string, pivot, value interface{}) *IntCmd
LLen(ctx context.Context, key string) *IntCmd
LMPop(ctx context.Context, direction string, count int64, keys ...string) *KeyValuesCmd
LPop(ctx context.Context, key string) *StringCmd
LPopCount(ctx context.Context, key string, count int) *StringSliceCmd
LPos(ctx context.Context, key string, value string, args LPosArgs) *IntCmd
LPosCount(ctx context.Context, key string, value string, count int64, args LPosArgs) *IntSliceCmd
LPush(ctx context.Context, key string, values ...interface{}) *IntCmd
LPushX(ctx context.Context, key string, values ...interface{}) *IntCmd
LRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd
LRem(ctx context.Context, key string, count int64, value interface{}) *IntCmd
LSet(ctx context.Context, key string, index int64, value interface{}) *StatusCmd
LTrim(ctx context.Context, key string, start, stop int64) *StatusCmd
RPop(ctx context.Context, key string) *StringCmd
RPopCount(ctx context.Context, key string, count int) *StringSliceCmd
RPopLPush(ctx context.Context, source, destination string) *StringCmd
RPush(ctx context.Context, key string, values ...interface{}) *IntCmd
RPushX(ctx context.Context, key string, values ...interface{}) *IntCmd
LMove(ctx context.Context, source, destination, srcpos, destpos string) *StringCmd
BLMove(ctx context.Context, source, destination, srcpos, destpos string, timeout time.Duration) *StringCmd
}
func (c cmdable) BLPop(ctx context.Context, timeout time.Duration, keys ...string) *StringSliceCmd {
args := make([]interface{}, 1+len(keys)+1)
args[0] = "blpop"
for i, key := range keys {
args[1+i] = key
}
args[len(args)-1] = formatSec(ctx, timeout)
cmd := NewStringSliceCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) BLMPop(ctx context.Context, timeout time.Duration, direction string, count int64, keys ...string) *KeyValuesCmd {
args := make([]interface{}, 3+len(keys), 6+len(keys))
args[0] = "blmpop"
args[1] = formatSec(ctx, timeout)
args[2] = len(keys)
for i, key := range keys {
args[3+i] = key
}
args = append(args, strings.ToLower(direction), "count", count)
cmd := NewKeyValuesCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) BRPop(ctx context.Context, timeout time.Duration, keys ...string) *StringSliceCmd {
args := make([]interface{}, 1+len(keys)+1)
args[0] = "brpop"
for i, key := range keys {
args[1+i] = key
}
args[len(keys)+1] = formatSec(ctx, timeout)
cmd := NewStringSliceCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
// BRPopLPush pops an element from a list, pushes it to another list and returns it.
// Blocks until an element is available or timeout is reached.
//
// Deprecated: Use BLMove with RIGHT and LEFT arguments instead as of Redis 6.2.0.
func (c cmdable) BRPopLPush(ctx context.Context, source, destination string, timeout time.Duration) *StringCmd {
cmd := NewStringCmd(
ctx,
"brpoplpush",
source,
destination,
formatSec(ctx, timeout),
)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LIndex(ctx context.Context, key string, index int64) *StringCmd {
cmd := NewStringCmd(ctx, "lindex", key, index)
_ = c(ctx, cmd)
return cmd
}
// LMPop Pops one or more elements from the first non-empty list key from the list of provided key names.
// direction: left or right, count: > 0
// example: client.LMPop(ctx, "left", 3, "key1", "key2")
func (c cmdable) LMPop(ctx context.Context, direction string, count int64, keys ...string) *KeyValuesCmd {
args := make([]interface{}, 2+len(keys), 5+len(keys))
args[0] = "lmpop"
args[1] = len(keys)
for i, key := range keys {
args[2+i] = key
}
args = append(args, strings.ToLower(direction), "count", count)
cmd := NewKeyValuesCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LInsert(ctx context.Context, key, op string, pivot, value interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "linsert", key, op, pivot, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LInsertBefore(ctx context.Context, key string, pivot, value interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "linsert", key, "before", pivot, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LInsertAfter(ctx context.Context, key string, pivot, value interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "linsert", key, "after", pivot, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LLen(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "llen", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LPop(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "lpop", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LPopCount(ctx context.Context, key string, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "lpop", key, count)
_ = c(ctx, cmd)
return cmd
}
type LPosArgs struct {
Rank, MaxLen int64
}
func (c cmdable) LPos(ctx context.Context, key string, value string, a LPosArgs) *IntCmd {
args := []interface{}{"lpos", key, value}
if a.Rank != 0 {
args = append(args, "rank", a.Rank)
}
if a.MaxLen != 0 {
args = append(args, "maxlen", a.MaxLen)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LPosCount(ctx context.Context, key string, value string, count int64, a LPosArgs) *IntSliceCmd {
args := []interface{}{"lpos", key, value, "count", count}
if a.Rank != 0 {
args = append(args, "rank", a.Rank)
}
if a.MaxLen != 0 {
args = append(args, "maxlen", a.MaxLen)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LPush(ctx context.Context, key string, values ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "lpush"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LPushX(ctx context.Context, key string, values ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "lpushx"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd {
cmd := NewStringSliceCmd(
ctx,
"lrange",
key,
start,
stop,
)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LRem(ctx context.Context, key string, count int64, value interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "lrem", key, count, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LSet(ctx context.Context, key string, index int64, value interface{}) *StatusCmd {
cmd := NewStatusCmd(ctx, "lset", key, index, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LTrim(ctx context.Context, key string, start, stop int64) *StatusCmd {
cmd := NewStatusCmd(
ctx,
"ltrim",
key,
start,
stop,
)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RPop(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "rpop", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RPopCount(ctx context.Context, key string, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "rpop", key, count)
_ = c(ctx, cmd)
return cmd
}
// RPopLPush atomically returns and removes the last element of the source list,
// and pushes the element as the first element of the destination list.
//
// Deprecated: Use LMove with RIGHT and LEFT arguments instead as of Redis 6.2.0.
func (c cmdable) RPopLPush(ctx context.Context, source, destination string) *StringCmd {
cmd := NewStringCmd(ctx, "rpoplpush", source, destination)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RPush(ctx context.Context, key string, values ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "rpush"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) RPushX(ctx context.Context, key string, values ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(values))
args[0] = "rpushx"
args[1] = key
args = appendArgs(args, values)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) LMove(ctx context.Context, source, destination, srcpos, destpos string) *StringCmd {
cmd := NewStringCmd(ctx, "lmove", source, destination, srcpos, destpos)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) BLMove(
ctx context.Context, source, destination, srcpos, destpos string, timeout time.Duration,
) *StringCmd {
cmd := NewStringCmd(ctx, "blmove", source, destination, srcpos, destpos, formatSec(ctx, timeout))
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
+235
View File
@@ -0,0 +1,235 @@
# Maintenance Notifications - FEATURES
## Overview
The Maintenance Notifications feature enables seamless Redis connection handoffs during cluster maintenance operations without dropping active connections. This feature leverages Redis RESP3 push notifications to provide zero-downtime maintenance for Redis Enterprise and compatible Redis deployments.
## Important
Using Maintenance Notifications may affect the read and write timeouts by relaxing them during maintenance operations.
This is necessary to prevent false failures due to increased latency during handoffs. The relaxed timeouts are automatically applied and removed as needed.
## Key Features
### Seamless Connection Handoffs
- **Zero-Downtime Maintenance**: Automatically handles connection transitions during cluster operations
- **Active Operation Preservation**: Transfers in-flight operations to new connections without interruption
- **Graceful Degradation**: Falls back to standard reconnection if handoff fails
### Push Notification Support
Supports all Redis Enterprise maintenance notification types:
- **MOVING** - Slot moving to a new node
- **MIGRATING** - Slot in migration state
- **MIGRATED** - Migration completed
- **FAILING_OVER** - Node failing over
- **FAILED_OVER** - Failover completed
### Circuit Breaker Pattern
- **Endpoint-Specific Failure Tracking**: Prevents repeated connection attempts to failing endpoints
- **Automatic Recovery Testing**: Half-open state allows gradual recovery validation
- **Configurable Thresholds**: Customize failure thresholds and reset timeouts
### Flexible Configuration
- **Auto-Detection Mode**: Automatically detects server support for maintenance notifications
- **Multiple Endpoint Types**: Support for internal/external IP/FQDN endpoint resolution
- **Auto-Scaling Workers**: Automatically sizes worker pool based on connection pool size
- **Timeout Management**: Separate timeouts for relaxed (during maintenance) and normal operations
### Extensible Hook System
- **Pre/Post Processing Hooks**: Monitor and customize notification handling
- **Built-in Hooks**: Logging and metrics collection hooks included
- **Custom Hook Support**: Implement custom business logic around maintenance events
### Comprehensive Monitoring
- **Metrics Collection**: Track notification counts, processing times, and error rates
- **Circuit Breaker Stats**: Monitor endpoint health and circuit breaker states
- **Operation Tracking**: Track active handoff operations and their lifecycle
## Architecture Highlights
### Event-Driven Handoff System
- **Asynchronous Processing**: Non-blocking handoff operations using worker pool pattern
- **Queue-Based Architecture**: Configurable queue size with auto-scaling support
- **Retry Mechanism**: Configurable retry attempts with exponential backoff
### Connection Pool Integration
- **Pool Hook Interface**: Seamless integration with go-redis connection pool
- **Connection State Management**: Atomic flags for connection usability tracking
- **Graceful Shutdown**: Ensures all in-flight handoffs complete before shutdown
### Thread-Safe Design
- **Lock-Free Operations**: Atomic operations for high-performance state tracking
- **Concurrent-Safe Maps**: sync.Map for tracking active operations
- **Minimal Lock Contention**: Read-write locks only where necessary
## Configuration Options
### Operation Modes
- **`ModeDisabled`**: Maintenance notifications completely disabled
- **`ModeEnabled`**: Forcefully enabled (fails if server doesn't support)
- **`ModeAuto`**: Auto-detect server support (recommended default)
### Endpoint Types
- **`EndpointTypeAuto`**: Auto-detect based on current connection
- **`EndpointTypeInternalIP`**: Use internal IP addresses
- **`EndpointTypeInternalFQDN`**: Use internal fully qualified domain names
- **`EndpointTypeExternalIP`**: Use external IP addresses
- **`EndpointTypeExternalFQDN`**: Use external fully qualified domain names
- **`EndpointTypeNone`**: No endpoint (reconnect with current configuration)
### Timeout Configuration
- **`RelaxedTimeout`**: Extended timeout during maintenance operations (default: 10s)
- **`HandoffTimeout`**: Maximum time for handoff completion (default: 15s)
- **`PostHandoffRelaxedDuration`**: Relaxed period after handoff (default: 2×RelaxedTimeout)
### Worker Pool Configuration
- **`MaxWorkers`**: Maximum concurrent handoff workers (auto-calculated if 0)
- **`HandoffQueueSize`**: Handoff queue capacity (auto-calculated if 0)
- **`MaxHandoffRetries`**: Maximum retry attempts for failed handoffs (default: 3)
### Circuit Breaker Configuration
- **`CircuitBreakerFailureThreshold`**: Failures before opening circuit (default: 5)
- **`CircuitBreakerResetTimeout`**: Time before testing recovery (default: 60s)
- **`CircuitBreakerMaxRequests`**: Max requests in half-open state (default: 3)
## Auto-Scaling Formulas
### Worker Pool Sizing
When `MaxWorkers = 0` (auto-calculate):
```
MaxWorkers = min(PoolSize/2, max(10, PoolSize/3))
```
### Queue Sizing
When `HandoffQueueSize = 0` (auto-calculate):
```
QueueSize = max(20 × MaxWorkers, PoolSize)
Capped by: min(MaxActiveConns + 1, 5 × PoolSize)
```
### Examples
- **Pool Size 100**: 33 workers, 660 queue (capped at 500)
- **Pool Size 100 + MaxActiveConns 150**: 33 workers, 151 queue
- **Pool Size 50**: 16 workers, 320 queue (capped at 250)
## Performance Characteristics
### Throughput
- **Non-Blocking Handoffs**: Client operations continue during handoffs
- **Concurrent Processing**: Multiple handoffs processed in parallel
- **Minimal Overhead**: Lock-free atomic operations for state tracking
### Latency
- **Relaxed Timeouts**: Extended timeouts during maintenance prevent false failures
- **Fast Path**: Connections not undergoing handoff have zero overhead
- **Graceful Degradation**: Failed handoffs fall back to standard reconnection
### Resource Usage
- **Memory Efficient**: Bounded queue sizes prevent memory exhaustion
- **Worker Pool**: Fixed worker count prevents goroutine explosion
- **Connection Reuse**: Handoff reuses existing connection objects
## Testing
### Unit Tests
- Comprehensive unit test coverage for all components
- Mock-based testing for isolation
- Concurrent operation testing
### Integration Tests
- Pool integration tests with real connection handoffs
- Circuit breaker behavior validation
- Hook system integration testing
### E2E Tests
- Real Redis Enterprise cluster testing
- Multiple scenario coverage (timeouts, endpoint types, stress tests)
- Fault injection testing
- TLS configuration testing
## Compatibility
### Requirements
- **Redis Protocol**: RESP3 required for push notifications
- **Redis Version**: Redis Enterprise or compatible Redis with maintenance notifications
- **Go Version**: Go 1.18+ (uses generics and atomic types)
### Client Support
#### Currently Supported
- **Standalone Client** (`redis.NewClient`) - Full support for MOVING, MIGRATING, MIGRATED, FAILING_OVER, FAILED_OVER notifications
- **Cluster Client** (`redis.NewClusterClient`) - Support for SMIGRATING and SMIGRATED notifications for hitless slot migrations
#### Will Not Support
- **Failover Client** (no planned support)
- **Ring Client** (no planned support)
## Migration Guide
### Enabling Maintenance Notifications (Standalone Client)
**Before:**
```go
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Protocol: 2, // RESP2
})
```
**After:**
```go
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Protocol: 3, // RESP3 required
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeAuto,
},
})
```
### Enabling Hitless Upgrades (Cluster Client)
For Redis Cluster with hitless slot migration support:
```go
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{"localhost:7000", "localhost:7001", "localhost:7002"},
Protocol: 3, // RESP3 required for push notifications
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeAuto,
RelaxedTimeout: 10 * time.Second, // Extended timeout during slot migrations
},
})
```
The cluster client automatically handles:
- **SMIGRATING**: Relaxes timeouts when slots are being migrated
- **SMIGRATED**: Triggers lazy cluster state reload when migration completes
- **SeqID Deduplication**: Same notification from multiple nodes triggers only one reload
### Adding Monitoring
```go
// Get the manager from the client
manager := client.GetMaintNotificationsManager()
if manager != nil {
// Add logging hook
loggingHook := maintnotifications.NewLoggingHook(2) // Info level
manager.AddNotificationHook(loggingHook)
// Add metrics hook
metricsHook := maintnotifications.NewMetricsHook()
manager.AddNotificationHook(metricsHook)
}
```
## Known Limitations
1. **RESP3 Required**: Push notifications require RESP3 protocol
2. **Server Support**: Requires Redis Enterprise or compatible Redis with maintenance notifications
3. **Single Connection Commands**: Some commands (MULTI/EXEC, WATCH) may need special handling
4. **No Failover/Ring Client Support**: Failover and Ring clients are not supported and there are no plans to add support
## Future Enhancements
- Enhanced metrics and observability
- TTL-based cleanup for SeqID deduplication map
+73
View File
@@ -0,0 +1,73 @@
# Maintenance Notifications
Seamless Redis connection handoffs during cluster maintenance operations without dropping connections.
## Cluster Support
**Cluster notifications are now supported for ClusterClient!**
- **SMIGRATING**: `["SMIGRATING", SeqID, slot/range, ...]` - Relaxes timeouts when slots are being migrated
- **SMIGRATED**: `["SMIGRATED", SeqID, src host:port, dst host:port, slot/range, ...]` - Reloads cluster state when slot migration completes
**Note:** Other maintenance notifications (MOVING, MIGRATING, MIGRATED, FAILING_OVER, FAILED_OVER) are supported only in standalone Redis clients. Cluster clients support SMIGRATING and SMIGRATED for cluster-specific slot migration handling.
## Quick Start
```go
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Protocol: 3, // RESP3 required
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeEnabled,
},
})
```
## Modes
- **`ModeDisabled`** - Maintenance notifications disabled
- **`ModeEnabled`** - Forcefully enabled (fails if server doesn't support)
- **`ModeAuto`** - Auto-detect server support (default)
## Configuration
```go
&maintnotifications.Config{
Mode: maintnotifications.ModeAuto,
EndpointType: maintnotifications.EndpointTypeAuto,
RelaxedTimeout: 10 * time.Second,
HandoffTimeout: 15 * time.Second,
MaxHandoffRetries: 3,
MaxWorkers: 0, // Auto-calculated
HandoffQueueSize: 0, // Auto-calculated
PostHandoffRelaxedDuration: 0, // 2 * RelaxedTimeout
}
```
### Endpoint Types
- **`EndpointTypeAuto`** - Auto-detect based on connection (default)
- **`EndpointTypeInternalIP`** - Internal IP address
- **`EndpointTypeInternalFQDN`** - Internal FQDN
- **`EndpointTypeExternalIP`** - External IP address
- **`EndpointTypeExternalFQDN`** - External FQDN
- **`EndpointTypeNone`** - No endpoint (reconnect with current config)
### Auto-Scaling
**Workers**: `min(PoolSize/2, max(10, PoolSize/3))` when auto-calculated
**Queue**: `max(20×Workers, PoolSize)` capped by `MaxActiveConns+1` or `5×PoolSize`
**Examples:**
- Pool 100: 33 workers, 660 queue (capped at 500)
- Pool 100 + MaxActiveConns 150: 33 workers, 151 queue
## How It Works
1. Redis sends push notifications about cluster maintenance operations
2. Client creates new connections to updated endpoints
3. Active operations transfer to new connections
4. Old connections close gracefully
## For more information, see [FEATURES](FEATURES.md)
@@ -0,0 +1,353 @@
package maintnotifications
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
)
// CircuitBreakerState represents the state of a circuit breaker
type CircuitBreakerState int32
const (
// CircuitBreakerClosed - normal operation, requests allowed
CircuitBreakerClosed CircuitBreakerState = iota
// CircuitBreakerOpen - failing fast, requests rejected
CircuitBreakerOpen
// CircuitBreakerHalfOpen - testing if service recovered
CircuitBreakerHalfOpen
)
func (s CircuitBreakerState) String() string {
switch s {
case CircuitBreakerClosed:
return "closed"
case CircuitBreakerOpen:
return "open"
case CircuitBreakerHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// CircuitBreaker implements the circuit breaker pattern for endpoint-specific failure handling
type CircuitBreaker struct {
// Configuration
failureThreshold int // Number of failures before opening
resetTimeout time.Duration // How long to stay open before testing
maxRequests int // Max requests allowed in half-open state
// State tracking (atomic for lock-free access)
state atomic.Int32 // CircuitBreakerState
failures atomic.Int64 // Current failure count
successes atomic.Int64 // Success count in half-open state
requests atomic.Int64 // Request count in half-open state
lastFailureTime atomic.Int64 // Unix timestamp of last failure
lastSuccessTime atomic.Int64 // Unix timestamp of last success
// Endpoint identification
endpoint string
config *Config
}
// newCircuitBreaker creates a new circuit breaker for an endpoint
func newCircuitBreaker(endpoint string, config *Config) *CircuitBreaker {
// Use configuration values with sensible defaults
failureThreshold := 5
resetTimeout := 60 * time.Second
maxRequests := 3
if config != nil {
failureThreshold = config.CircuitBreakerFailureThreshold
resetTimeout = config.CircuitBreakerResetTimeout
maxRequests = config.CircuitBreakerMaxRequests
}
return &CircuitBreaker{
failureThreshold: failureThreshold,
resetTimeout: resetTimeout,
maxRequests: maxRequests,
endpoint: endpoint,
config: config,
state: atomic.Int32{}, // Defaults to CircuitBreakerClosed (0)
}
}
// IsOpen returns true if the circuit breaker is open (rejecting requests)
func (cb *CircuitBreaker) IsOpen() bool {
state := CircuitBreakerState(cb.state.Load())
return state == CircuitBreakerOpen
}
// shouldAttemptReset checks if enough time has passed to attempt reset
func (cb *CircuitBreaker) shouldAttemptReset() bool {
lastFailure := time.Unix(cb.lastFailureTime.Load(), 0)
return time.Since(lastFailure) >= cb.resetTimeout
}
// Execute runs the given function with circuit breaker protection
func (cb *CircuitBreaker) Execute(fn func() error) error {
// Single atomic state load for consistency
state := CircuitBreakerState(cb.state.Load())
switch state {
case CircuitBreakerOpen:
if cb.shouldAttemptReset() {
// Attempt transition to half-open
if cb.state.CompareAndSwap(int32(CircuitBreakerOpen), int32(CircuitBreakerHalfOpen)) {
cb.requests.Store(0)
cb.successes.Store(0)
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.CircuitBreakerTransitioningToHalfOpen(cb.endpoint))
}
// Fall through to half-open logic
} else {
return ErrCircuitBreakerOpen
}
} else {
return ErrCircuitBreakerOpen
}
fallthrough
case CircuitBreakerHalfOpen:
requests := cb.requests.Add(1)
if requests > int64(cb.maxRequests) {
cb.requests.Add(-1) // Revert the increment
return ErrCircuitBreakerOpen
}
}
// Execute the function with consistent state
err := fn()
if err != nil {
cb.recordFailure()
return err
}
cb.recordSuccess()
return nil
}
// recordFailure records a failure and potentially opens the circuit
func (cb *CircuitBreaker) recordFailure() {
cb.lastFailureTime.Store(time.Now().Unix())
failures := cb.failures.Add(1)
state := CircuitBreakerState(cb.state.Load())
switch state {
case CircuitBreakerClosed:
if failures >= int64(cb.failureThreshold) {
if cb.state.CompareAndSwap(int32(CircuitBreakerClosed), int32(CircuitBreakerOpen)) {
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(context.Background(), logs.CircuitBreakerOpened(cb.endpoint, failures))
}
}
}
case CircuitBreakerHalfOpen:
// Any failure in half-open state immediately opens the circuit
if cb.state.CompareAndSwap(int32(CircuitBreakerHalfOpen), int32(CircuitBreakerOpen)) {
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(context.Background(), logs.CircuitBreakerReopened(cb.endpoint))
}
}
}
}
// recordSuccess records a success and potentially closes the circuit
func (cb *CircuitBreaker) recordSuccess() {
cb.lastSuccessTime.Store(time.Now().Unix())
state := CircuitBreakerState(cb.state.Load())
switch state {
case CircuitBreakerClosed:
// Reset failure count on success in closed state
cb.failures.Store(0)
case CircuitBreakerHalfOpen:
successes := cb.successes.Add(1)
// If we've had enough successful requests, close the circuit
if successes >= int64(cb.maxRequests) {
if cb.state.CompareAndSwap(int32(CircuitBreakerHalfOpen), int32(CircuitBreakerClosed)) {
cb.failures.Store(0)
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.CircuitBreakerClosed(cb.endpoint, successes))
}
}
}
}
}
// GetState returns the current state of the circuit breaker
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
return CircuitBreakerState(cb.state.Load())
}
// GetStats returns current statistics for monitoring
func (cb *CircuitBreaker) GetStats() CircuitBreakerStats {
return CircuitBreakerStats{
Endpoint: cb.endpoint,
State: cb.GetState(),
Failures: cb.failures.Load(),
Successes: cb.successes.Load(),
Requests: cb.requests.Load(),
LastFailureTime: time.Unix(cb.lastFailureTime.Load(), 0),
LastSuccessTime: time.Unix(cb.lastSuccessTime.Load(), 0),
}
}
// CircuitBreakerStats provides statistics about a circuit breaker
type CircuitBreakerStats struct {
Endpoint string
State CircuitBreakerState
Failures int64
Successes int64
Requests int64
LastFailureTime time.Time
LastSuccessTime time.Time
}
// CircuitBreakerEntry wraps a circuit breaker with access tracking
type CircuitBreakerEntry struct {
breaker *CircuitBreaker
lastAccess atomic.Int64 // Unix timestamp
created time.Time
}
// CircuitBreakerManager manages circuit breakers for multiple endpoints
type CircuitBreakerManager struct {
breakers sync.Map // map[string]*CircuitBreakerEntry
config *Config
cleanupStop chan struct{}
cleanupMu sync.Mutex
lastCleanup atomic.Int64 // Unix timestamp
}
// newCircuitBreakerManager creates a new circuit breaker manager
func newCircuitBreakerManager(config *Config) *CircuitBreakerManager {
cbm := &CircuitBreakerManager{
config: config,
cleanupStop: make(chan struct{}),
}
cbm.lastCleanup.Store(time.Now().Unix())
// Start background cleanup goroutine
go cbm.cleanupLoop()
return cbm
}
// GetCircuitBreaker returns the circuit breaker for an endpoint, creating it if necessary
func (cbm *CircuitBreakerManager) GetCircuitBreaker(endpoint string) *CircuitBreaker {
now := time.Now().Unix()
if entry, ok := cbm.breakers.Load(endpoint); ok {
cbEntry := entry.(*CircuitBreakerEntry)
cbEntry.lastAccess.Store(now)
return cbEntry.breaker
}
// Create new circuit breaker with metadata
newBreaker := newCircuitBreaker(endpoint, cbm.config)
newEntry := &CircuitBreakerEntry{
breaker: newBreaker,
created: time.Now(),
}
newEntry.lastAccess.Store(now)
actual, _ := cbm.breakers.LoadOrStore(endpoint, newEntry)
return actual.(*CircuitBreakerEntry).breaker
}
// GetAllStats returns statistics for all circuit breakers
func (cbm *CircuitBreakerManager) GetAllStats() []CircuitBreakerStats {
var stats []CircuitBreakerStats
cbm.breakers.Range(func(key, value interface{}) bool {
entry := value.(*CircuitBreakerEntry)
stats = append(stats, entry.breaker.GetStats())
return true
})
return stats
}
// cleanupLoop runs background cleanup of unused circuit breakers
func (cbm *CircuitBreakerManager) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute) // Cleanup every 5 minutes
defer ticker.Stop()
for {
select {
case <-ticker.C:
cbm.cleanup()
case <-cbm.cleanupStop:
return
}
}
}
// cleanup removes circuit breakers that haven't been accessed recently
func (cbm *CircuitBreakerManager) cleanup() {
// Prevent concurrent cleanups
if !cbm.cleanupMu.TryLock() {
return
}
defer cbm.cleanupMu.Unlock()
now := time.Now()
cutoff := now.Add(-30 * time.Minute).Unix() // 30 minute TTL
var toDelete []string
count := 0
cbm.breakers.Range(func(key, value interface{}) bool {
endpoint := key.(string)
entry := value.(*CircuitBreakerEntry)
count++
// Remove if not accessed recently
if entry.lastAccess.Load() < cutoff {
toDelete = append(toDelete, endpoint)
}
return true
})
// Delete expired entries
for _, endpoint := range toDelete {
cbm.breakers.Delete(endpoint)
}
// Log cleanup results
if len(toDelete) > 0 && internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.CircuitBreakerCleanup(len(toDelete), count))
}
cbm.lastCleanup.Store(now.Unix())
}
// Shutdown stops the cleanup goroutine
func (cbm *CircuitBreakerManager) Shutdown() {
close(cbm.cleanupStop)
}
// Reset resets all circuit breakers (useful for testing)
func (cbm *CircuitBreakerManager) Reset() {
cbm.breakers.Range(func(key, value interface{}) bool {
entry := value.(*CircuitBreakerEntry)
breaker := entry.breaker
breaker.state.Store(int32(CircuitBreakerClosed))
breaker.failures.Store(0)
breaker.successes.Store(0)
breaker.requests.Store(0)
breaker.lastFailureTime.Store(0)
breaker.lastSuccessTime.Store(0)
return true
})
}
+502
View File
@@ -0,0 +1,502 @@
package maintnotifications
import (
"context"
"net"
"runtime"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
)
// Mode represents the maintenance notifications mode
type Mode string
// Constants for maintenance push notifications modes
const (
ModeDisabled Mode = "disabled" // Client doesn't send CLIENT MAINT_NOTIFICATIONS ON command
ModeEnabled Mode = "enabled" // Client forcefully sends command, interrupts connection on error
ModeAuto Mode = "auto" // Client tries to send command, disables feature on error
)
// IsValid returns true if the maintenance notifications mode is valid
func (m Mode) IsValid() bool {
switch m {
case ModeDisabled, ModeEnabled, ModeAuto:
return true
default:
return false
}
}
// String returns the string representation of the mode
func (m Mode) String() string {
return string(m)
}
// EndpointType represents the type of endpoint to request in MOVING notifications
type EndpointType string
// Constants for endpoint types
const (
EndpointTypeAuto EndpointType = "auto" // Auto-detect based on connection
EndpointTypeInternalIP EndpointType = "internal-ip" // Internal IP address
EndpointTypeInternalFQDN EndpointType = "internal-fqdn" // Internal FQDN
EndpointTypeExternalIP EndpointType = "external-ip" // External IP address
EndpointTypeExternalFQDN EndpointType = "external-fqdn" // External FQDN
EndpointTypeNone EndpointType = "none" // No endpoint (reconnect with current config)
)
// IsValid returns true if the endpoint type is valid
func (e EndpointType) IsValid() bool {
switch e {
case EndpointTypeAuto, EndpointTypeInternalIP, EndpointTypeInternalFQDN,
EndpointTypeExternalIP, EndpointTypeExternalFQDN, EndpointTypeNone:
return true
default:
return false
}
}
// String returns the string representation of the endpoint type
func (e EndpointType) String() string {
return string(e)
}
// Config provides configuration options for maintenance notifications
type Config struct {
// Mode controls how client maintenance notifications are handled.
// Valid values: ModeDisabled, ModeEnabled, ModeAuto
// Default: ModeAuto
Mode Mode
// EndpointType specifies the type of endpoint to request in MOVING notifications.
// Valid values: EndpointTypeAuto, EndpointTypeInternalIP, EndpointTypeInternalFQDN,
// EndpointTypeExternalIP, EndpointTypeExternalFQDN, EndpointTypeNone
// Default: EndpointTypeAuto
EndpointType EndpointType
// RelaxedTimeout is the concrete timeout value to use during
// MIGRATING/FAILING_OVER states to accommodate increased latency.
// This applies to both read and write timeouts.
// Default: 10 seconds
RelaxedTimeout time.Duration
// HandoffTimeout is the maximum time to wait for connection handoff to complete.
// If handoff takes longer than this, the old connection will be forcibly closed.
// Default: 15 seconds (matches server-side eviction timeout)
HandoffTimeout time.Duration
// MaxWorkers is the maximum number of worker goroutines for processing handoff requests.
// Workers are created on-demand and automatically cleaned up when idle.
// If zero, defaults to min(10, PoolSize/2) to handle bursts effectively.
// If explicitly set, enforces minimum of PoolSize/2
//
// Default: min(PoolSize/2, max(10, PoolSize/3)), Minimum when set: PoolSize/2
MaxWorkers int
// HandoffQueueSize is the size of the buffered channel used to queue handoff requests.
// If the queue is full, new handoff requests will be rejected.
// Scales with both worker count and pool size for better burst handling.
//
// Default: max(20×MaxWorkers, PoolSize), capped by MaxActiveConns+1 (if set) or 5×PoolSize
// When set: minimum 200, capped by MaxActiveConns+1 (if set) or 5×PoolSize
HandoffQueueSize int
// PostHandoffRelaxedDuration is how long to keep relaxed timeouts on the new connection
// after a handoff completes. This provides additional resilience during cluster transitions.
// Default: 2 * RelaxedTimeout
PostHandoffRelaxedDuration time.Duration
// Circuit breaker configuration for endpoint failure handling
// CircuitBreakerFailureThreshold is the number of failures before opening the circuit.
// Default: 5
CircuitBreakerFailureThreshold int
// CircuitBreakerResetTimeout is how long to wait before testing if the endpoint recovered.
// Default: 60 seconds
CircuitBreakerResetTimeout time.Duration
// CircuitBreakerMaxRequests is the maximum number of requests allowed in half-open state.
// Default: 3
CircuitBreakerMaxRequests int
// MaxHandoffRetries is the maximum number of times to retry a failed handoff.
// After this many retries, the connection will be removed from the pool.
// Default: 3
MaxHandoffRetries int
}
func (c *Config) IsEnabled() bool {
return c != nil && c.Mode != ModeDisabled
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() *Config {
return &Config{
Mode: ModeAuto, // Enable by default for Redis Cloud
EndpointType: EndpointTypeAuto, // Auto-detect based on connection
RelaxedTimeout: 10 * time.Second,
HandoffTimeout: 15 * time.Second,
MaxWorkers: 0, // Auto-calculated based on pool size
HandoffQueueSize: 0, // Auto-calculated based on max workers
PostHandoffRelaxedDuration: 0, // Auto-calculated based on relaxed timeout
// Circuit breaker configuration
CircuitBreakerFailureThreshold: 5,
CircuitBreakerResetTimeout: 60 * time.Second,
CircuitBreakerMaxRequests: 3,
// Connection Handoff Configuration
MaxHandoffRetries: 3,
}
}
// Validate checks if the configuration is valid.
func (c *Config) Validate() error {
if c.RelaxedTimeout <= 0 {
return ErrInvalidRelaxedTimeout
}
if c.HandoffTimeout <= 0 {
return ErrInvalidHandoffTimeout
}
// Validate worker configuration
// Allow 0 for auto-calculation, but negative values are invalid
if c.MaxWorkers < 0 {
return ErrInvalidHandoffWorkers
}
// HandoffQueueSize validation - allow 0 for auto-calculation
if c.HandoffQueueSize < 0 {
return ErrInvalidHandoffQueueSize
}
if c.PostHandoffRelaxedDuration < 0 {
return ErrInvalidPostHandoffRelaxedDuration
}
// Circuit breaker validation
if c.CircuitBreakerFailureThreshold < 1 {
return ErrInvalidCircuitBreakerFailureThreshold
}
if c.CircuitBreakerResetTimeout < 0 {
return ErrInvalidCircuitBreakerResetTimeout
}
if c.CircuitBreakerMaxRequests < 1 {
return ErrInvalidCircuitBreakerMaxRequests
}
// Validate Mode (maintenance notifications mode)
if !c.Mode.IsValid() {
return ErrInvalidMaintNotifications
}
// Validate EndpointType
if !c.EndpointType.IsValid() {
return ErrInvalidEndpointType
}
// Validate configuration fields
if c.MaxHandoffRetries < 1 || c.MaxHandoffRetries > 10 {
return ErrInvalidHandoffRetries
}
return nil
}
// ApplyDefaults applies default values to any zero-value fields in the configuration.
// This ensures that partially configured structs get sensible defaults for missing fields.
func (c *Config) ApplyDefaults() *Config {
return c.ApplyDefaultsWithPoolSize(0)
}
// ApplyDefaultsWithPoolSize applies default values to any zero-value fields in the configuration,
// using the provided pool size to calculate worker defaults.
// This ensures that partially configured structs get sensible defaults for missing fields.
func (c *Config) ApplyDefaultsWithPoolSize(poolSize int) *Config {
return c.ApplyDefaultsWithPoolConfig(poolSize, 0)
}
// ApplyDefaultsWithPoolConfig applies default values to any zero-value fields in the configuration,
// using the provided pool size and max active connections to calculate worker and queue defaults.
// This ensures that partially configured structs get sensible defaults for missing fields.
func (c *Config) ApplyDefaultsWithPoolConfig(poolSize int, maxActiveConns int) *Config {
if c == nil {
return DefaultConfig().ApplyDefaultsWithPoolSize(poolSize)
}
defaults := DefaultConfig()
result := &Config{}
// Apply defaults for enum fields (empty/zero means not set)
result.Mode = defaults.Mode
if c.Mode != "" {
result.Mode = c.Mode
}
result.EndpointType = defaults.EndpointType
if c.EndpointType != "" {
result.EndpointType = c.EndpointType
}
// Apply defaults for duration fields (zero means not set)
result.RelaxedTimeout = defaults.RelaxedTimeout
if c.RelaxedTimeout > 0 {
result.RelaxedTimeout = c.RelaxedTimeout
}
result.HandoffTimeout = defaults.HandoffTimeout
if c.HandoffTimeout > 0 {
result.HandoffTimeout = c.HandoffTimeout
}
// Copy worker configuration
result.MaxWorkers = c.MaxWorkers
// Apply worker defaults based on pool size
result.applyWorkerDefaults(poolSize)
// Apply queue size defaults with new scaling approach
// Default: max(20x workers, PoolSize), capped by maxActiveConns or 5x pool size
workerBasedSize := result.MaxWorkers * 20
poolBasedSize := poolSize
result.HandoffQueueSize = max(workerBasedSize, poolBasedSize)
if c.HandoffQueueSize > 0 {
// When explicitly set: enforce minimum of 200
result.HandoffQueueSize = max(200, c.HandoffQueueSize)
}
// Cap queue size: use maxActiveConns+1 if set, otherwise 5x pool size
var queueCap int
if maxActiveConns > 0 {
queueCap = maxActiveConns + 1
// Ensure queue cap is at least 2 for very small maxActiveConns
if queueCap < 2 {
queueCap = 2
}
} else {
queueCap = poolSize * 5
}
result.HandoffQueueSize = min(result.HandoffQueueSize, queueCap)
// Ensure minimum queue size of 2 (fallback for very small pools)
if result.HandoffQueueSize < 2 {
result.HandoffQueueSize = 2
}
result.PostHandoffRelaxedDuration = result.RelaxedTimeout * 2
if c.PostHandoffRelaxedDuration > 0 {
result.PostHandoffRelaxedDuration = c.PostHandoffRelaxedDuration
}
// Apply defaults for configuration fields
result.MaxHandoffRetries = defaults.MaxHandoffRetries
if c.MaxHandoffRetries > 0 {
result.MaxHandoffRetries = c.MaxHandoffRetries
}
// Circuit breaker configuration
result.CircuitBreakerFailureThreshold = defaults.CircuitBreakerFailureThreshold
if c.CircuitBreakerFailureThreshold > 0 {
result.CircuitBreakerFailureThreshold = c.CircuitBreakerFailureThreshold
}
result.CircuitBreakerResetTimeout = defaults.CircuitBreakerResetTimeout
if c.CircuitBreakerResetTimeout > 0 {
result.CircuitBreakerResetTimeout = c.CircuitBreakerResetTimeout
}
result.CircuitBreakerMaxRequests = defaults.CircuitBreakerMaxRequests
if c.CircuitBreakerMaxRequests > 0 {
result.CircuitBreakerMaxRequests = c.CircuitBreakerMaxRequests
}
if internal.LogLevel.DebugOrAbove() {
internal.Logger.Printf(context.Background(), logs.DebugLoggingEnabled())
internal.Logger.Printf(context.Background(), logs.ConfigDebug(result))
}
return result
}
// Clone creates a deep copy of the configuration.
func (c *Config) Clone() *Config {
if c == nil {
return DefaultConfig()
}
return &Config{
Mode: c.Mode,
EndpointType: c.EndpointType,
RelaxedTimeout: c.RelaxedTimeout,
HandoffTimeout: c.HandoffTimeout,
MaxWorkers: c.MaxWorkers,
HandoffQueueSize: c.HandoffQueueSize,
PostHandoffRelaxedDuration: c.PostHandoffRelaxedDuration,
// Circuit breaker configuration
CircuitBreakerFailureThreshold: c.CircuitBreakerFailureThreshold,
CircuitBreakerResetTimeout: c.CircuitBreakerResetTimeout,
CircuitBreakerMaxRequests: c.CircuitBreakerMaxRequests,
// Configuration fields
MaxHandoffRetries: c.MaxHandoffRetries,
}
}
// applyWorkerDefaults calculates and applies worker defaults based on pool size
func (c *Config) applyWorkerDefaults(poolSize int) {
// Calculate defaults based on pool size
if poolSize <= 0 {
poolSize = 10 * runtime.GOMAXPROCS(0)
}
// When not set: min(poolSize/2, max(10, poolSize/3)) - balanced scaling approach
originalMaxWorkers := c.MaxWorkers
c.MaxWorkers = min(poolSize/2, max(10, poolSize/3))
if originalMaxWorkers != 0 {
// When explicitly set: max(poolSize/2, set_value) - ensure at least poolSize/2 workers
c.MaxWorkers = max(poolSize/2, originalMaxWorkers)
}
// Ensure minimum of 1 worker (fallback for very small pools)
if c.MaxWorkers < 1 {
c.MaxWorkers = 1
}
}
// 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.
//
// TLS behaviour:
// - If TLS is enabled: requests FQDN for proper certificate validation
// (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: 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)
if err != nil {
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
var endpointType EndpointType
if isIPAddress {
// Address is an IP - determine if it's private or public
isPrivate := isPrivateIP(ip)
if tlsEnabled {
// TLS with IP addresses - still prefer FQDN for certificate validation
if isPrivate {
endpointType = EndpointTypeInternalFQDN
} else {
endpointType = EndpointTypeExternalFQDN
}
} else {
// No TLS - can use IP addresses directly
if isPrivate {
endpointType = EndpointTypeInternalIP
} else {
endpointType = EndpointTypeExternalIP
}
}
} else {
// 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 {
// 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 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
}
if len(ips) == 0 {
return false, nil
}
for _, ia := range ips {
if !isPrivateIP(ia.IP) {
return false, nil
}
}
return true, nil
}
+76
View File
@@ -0,0 +1,76 @@
package maintnotifications
import (
"errors"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
)
// Configuration errors
var (
ErrInvalidRelaxedTimeout = errors.New(logs.InvalidRelaxedTimeoutError())
ErrInvalidHandoffTimeout = errors.New(logs.InvalidHandoffTimeoutError())
ErrInvalidHandoffWorkers = errors.New(logs.InvalidHandoffWorkersError())
ErrInvalidHandoffQueueSize = errors.New(logs.InvalidHandoffQueueSizeError())
ErrInvalidPostHandoffRelaxedDuration = errors.New(logs.InvalidPostHandoffRelaxedDurationError())
ErrInvalidEndpointType = errors.New(logs.InvalidEndpointTypeError())
ErrInvalidMaintNotifications = errors.New(logs.InvalidMaintNotificationsError())
ErrMaxHandoffRetriesReached = errors.New(logs.MaxHandoffRetriesReachedError())
// Configuration validation errors
// ErrInvalidHandoffRetries is returned when the number of handoff retries is invalid
ErrInvalidHandoffRetries = errors.New(logs.InvalidHandoffRetriesError())
)
// Integration errors
var (
// ErrInvalidClient is returned when the client does not support push notifications
ErrInvalidClient = errors.New(logs.InvalidClientError())
)
// Handoff errors
var (
// ErrHandoffQueueFull is returned when the handoff queue is full
ErrHandoffQueueFull = errors.New(logs.HandoffQueueFullError())
)
// Notification errors
var (
// ErrInvalidNotification is returned when a notification is in an invalid format
ErrInvalidNotification = errors.New(logs.InvalidNotificationError())
)
// connection handoff errors
var (
// ErrConnectionMarkedForHandoff is returned when a connection is marked for handoff
// and should not be used until the handoff is complete
ErrConnectionMarkedForHandoff = errors.New(logs.ConnectionMarkedForHandoffErrorMessage)
// ErrConnectionMarkedForHandoffWithState is returned when a connection is marked for handoff
// and should not be used until the handoff is complete
ErrConnectionMarkedForHandoffWithState = errors.New(logs.ConnectionMarkedForHandoffErrorMessage + " with state")
// ErrConnectionInvalidHandoffState is returned when a connection is in an invalid state for handoff
ErrConnectionInvalidHandoffState = errors.New(logs.ConnectionInvalidHandoffStateErrorMessage)
)
// shutdown errors
var (
// ErrShutdown is returned when the maintnotifications manager is shutdown
ErrShutdown = errors.New(logs.ShutdownError())
)
// circuit breaker errors
var (
// ErrCircuitBreakerOpen is returned when the circuit breaker is open
ErrCircuitBreakerOpen = errors.New(logs.CircuitBreakerOpenErrorMessage)
)
// circuit breaker configuration errors
var (
// ErrInvalidCircuitBreakerFailureThreshold is returned when the circuit breaker failure threshold is invalid
ErrInvalidCircuitBreakerFailureThreshold = errors.New(logs.InvalidCircuitBreakerFailureThresholdError())
// ErrInvalidCircuitBreakerResetTimeout is returned when the circuit breaker reset timeout is invalid
ErrInvalidCircuitBreakerResetTimeout = errors.New(logs.InvalidCircuitBreakerResetTimeoutError())
// ErrInvalidCircuitBreakerMaxRequests is returned when the circuit breaker max requests is invalid
ErrInvalidCircuitBreakerMaxRequests = errors.New(logs.InvalidCircuitBreakerMaxRequestsError())
)
+101
View File
@@ -0,0 +1,101 @@
package maintnotifications
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/push"
)
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
const (
startTimeKey contextKey = "maint_notif_start_time"
)
// MetricsHook collects metrics about notification processing.
type MetricsHook struct {
NotificationCounts map[string]int64
ProcessingTimes map[string]time.Duration
ErrorCounts map[string]int64
HandoffCounts int64 // Total handoffs initiated
HandoffSuccesses int64 // Successful handoffs
HandoffFailures int64 // Failed handoffs
}
// NewMetricsHook creates a new metrics collection hook.
func NewMetricsHook() *MetricsHook {
return &MetricsHook{
NotificationCounts: make(map[string]int64),
ProcessingTimes: make(map[string]time.Duration),
ErrorCounts: make(map[string]int64),
}
}
// PreHook records the start time for processing metrics.
func (mh *MetricsHook) PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) {
mh.NotificationCounts[notificationType]++
// Log connection information if available
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
internal.Logger.Printf(ctx, logs.MetricsHookProcessingNotification(notificationType, conn.GetID()))
}
// Store start time in context for duration calculation
startTime := time.Now()
_ = context.WithValue(ctx, startTimeKey, startTime) // Context not used further
return notification, true
}
// PostHook records processing completion and any errors.
func (mh *MetricsHook) PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) {
// Calculate processing duration
if startTime, ok := ctx.Value(startTimeKey).(time.Time); ok {
duration := time.Since(startTime)
mh.ProcessingTimes[notificationType] = duration
}
// Record errors
if result != nil {
mh.ErrorCounts[notificationType]++
// Log error details with connection information
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
internal.Logger.Printf(ctx, logs.MetricsHookRecordedError(notificationType, conn.GetID(), result))
}
}
}
// GetMetrics returns a summary of collected metrics.
func (mh *MetricsHook) GetMetrics() map[string]interface{} {
return map[string]interface{}{
"notification_counts": mh.NotificationCounts,
"processing_times": mh.ProcessingTimes,
"error_counts": mh.ErrorCounts,
}
}
// ExampleCircuitBreakerMonitor demonstrates how to monitor circuit breaker status
func ExampleCircuitBreakerMonitor(poolHook *PoolHook) {
// Get circuit breaker statistics
stats := poolHook.GetCircuitBreakerStats()
for _, stat := range stats {
fmt.Printf("Circuit Breaker for %s:\n", stat.Endpoint)
fmt.Printf(" State: %s\n", stat.State)
fmt.Printf(" Failures: %d\n", stat.Failures)
fmt.Printf(" Last Failure: %v\n", stat.LastFailureTime)
fmt.Printf(" Last Success: %v\n", stat.LastSuccessTime)
// Alert if circuit breaker is open
if stat.State.String() == "open" {
fmt.Printf(" ⚠️ ALERT: Circuit breaker is OPEN for %s\n", stat.Endpoint)
}
}
}
@@ -0,0 +1,525 @@
package maintnotifications
import (
"context"
"errors"
"net"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
)
// PoolNameMain is the name used for the main connection pool in metrics.
const PoolNameMain = "main"
// handoffWorkerManager manages background workers and queue for connection handoffs
type handoffWorkerManager struct {
// Event-driven handoff support
handoffQueue chan HandoffRequest // Queue for handoff requests
shutdown chan struct{} // Shutdown signal
shutdownOnce sync.Once // Ensure clean shutdown
workerWg sync.WaitGroup // Track worker goroutines
// On-demand worker management
maxWorkers int
activeWorkers atomic.Int32
workerTimeout time.Duration // How long workers wait for work before exiting
workersScaling atomic.Bool
// Simple state tracking
pending sync.Map // map[uint64]int64 (connID -> seqID)
// Configuration for the maintenance notifications
config *Config
// Pool hook reference for handoff processing
poolHook *PoolHook
// Circuit breaker manager for endpoint failure handling
circuitBreakerManager *CircuitBreakerManager
}
// newHandoffWorkerManager creates a new handoff worker manager
func newHandoffWorkerManager(config *Config, poolHook *PoolHook) *handoffWorkerManager {
return &handoffWorkerManager{
handoffQueue: make(chan HandoffRequest, config.HandoffQueueSize),
shutdown: make(chan struct{}),
maxWorkers: config.MaxWorkers,
activeWorkers: atomic.Int32{}, // Start with no workers - create on demand
workerTimeout: 15 * time.Second, // Workers exit after 15s of inactivity
config: config,
poolHook: poolHook,
circuitBreakerManager: newCircuitBreakerManager(config),
}
}
// getCurrentWorkers returns the current number of active workers (for testing)
func (hwm *handoffWorkerManager) getCurrentWorkers() int {
return int(hwm.activeWorkers.Load())
}
// getPendingMap returns the pending map for testing purposes
func (hwm *handoffWorkerManager) getPendingMap() *sync.Map {
return &hwm.pending
}
// getMaxWorkers returns the max workers for testing purposes
func (hwm *handoffWorkerManager) getMaxWorkers() int {
return hwm.maxWorkers
}
// getHandoffQueue returns the handoff queue for testing purposes
func (hwm *handoffWorkerManager) getHandoffQueue() chan HandoffRequest {
return hwm.handoffQueue
}
// getCircuitBreakerStats returns circuit breaker statistics for monitoring
func (hwm *handoffWorkerManager) getCircuitBreakerStats() []CircuitBreakerStats {
return hwm.circuitBreakerManager.GetAllStats()
}
// resetCircuitBreakers resets all circuit breakers (useful for testing)
func (hwm *handoffWorkerManager) resetCircuitBreakers() {
hwm.circuitBreakerManager.Reset()
}
// isHandoffPending returns true if the given connection has a pending handoff
func (hwm *handoffWorkerManager) isHandoffPending(conn *pool.Conn) bool {
_, pending := hwm.pending.Load(conn.GetID())
return pending
}
// ensureWorkerAvailable ensures at least one worker is available to process requests
// Creates a new worker if needed and under the max limit
func (hwm *handoffWorkerManager) ensureWorkerAvailable() {
select {
case <-hwm.shutdown:
return
default:
if hwm.workersScaling.CompareAndSwap(false, true) {
defer hwm.workersScaling.Store(false)
// Check if we need a new worker
currentWorkers := hwm.activeWorkers.Load()
workersWas := currentWorkers
for currentWorkers < int32(hwm.maxWorkers) {
hwm.workerWg.Add(1)
go hwm.onDemandWorker()
currentWorkers++
}
// workersWas is always <= currentWorkers
// currentWorkers will be maxWorkers, but if we have a worker that was closed
// while we were creating new workers, just add the difference between
// the currentWorkers and the number of workers we observed initially (i.e. the number of workers we created)
hwm.activeWorkers.Add(currentWorkers - workersWas)
}
}
}
// onDemandWorker processes handoff requests and exits when idle
func (hwm *handoffWorkerManager) onDemandWorker() {
defer func() {
// Handle panics to ensure proper cleanup
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(), logs.WorkerPanicRecovered(r))
}
// Decrement active worker count when exiting
hwm.activeWorkers.Add(-1)
hwm.workerWg.Done()
}()
// Create reusable timer to prevent timer leaks
timer := time.NewTimer(hwm.workerTimeout)
defer timer.Stop()
for {
// Reset timer for next iteration
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(hwm.workerTimeout)
select {
case <-hwm.shutdown:
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToShutdown())
}
return
case <-timer.C:
// Worker has been idle for too long, exit to save resources
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToInactivityTimeout(hwm.workerTimeout))
}
return
case request := <-hwm.handoffQueue:
// Check for shutdown before processing
select {
case <-hwm.shutdown:
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToShutdownWhileProcessing())
}
// Clean up the request before exiting
hwm.pending.Delete(request.ConnID)
return
default:
// Process the request
hwm.processHandoffRequest(request)
}
}
}
}
// processHandoffRequest processes a single handoff request
func (hwm *handoffWorkerManager) processHandoffRequest(request HandoffRequest) {
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.HandoffStarted(request.Conn.GetID(), request.Endpoint))
}
// Create a context with handoff timeout from config
handoffTimeout := 15 * time.Second // Default timeout
if hwm.config != nil && hwm.config.HandoffTimeout > 0 {
handoffTimeout = hwm.config.HandoffTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), handoffTimeout)
defer cancel()
// Create a context that also respects the shutdown signal
shutdownCtx, shutdownCancel := context.WithCancel(ctx)
defer shutdownCancel()
// Monitor shutdown signal in a separate goroutine
go func() {
select {
case <-hwm.shutdown:
shutdownCancel()
case <-shutdownCtx.Done():
}
}()
// Perform the handoff with cancellable context
shouldRetry, err := hwm.performConnectionHandoff(shutdownCtx, request.Conn)
minRetryBackoff := 500 * time.Millisecond
if err != nil {
if shouldRetry {
now := time.Now()
deadline, ok := shutdownCtx.Deadline()
thirdOfTimeout := handoffTimeout / 3
if !ok || deadline.Before(now) {
// wait half the timeout before retrying if no deadline or deadline has passed
deadline = now.Add(thirdOfTimeout)
}
afterTime := deadline.Sub(now)
if afterTime < minRetryBackoff {
afterTime = minRetryBackoff
}
if internal.LogLevel.InfoOrAbove() {
// Get current retry count for better logging
currentRetries := request.Conn.HandoffRetries()
maxRetries := 3 // Default fallback
if hwm.config != nil {
maxRetries = hwm.config.MaxHandoffRetries
}
internal.Logger.Printf(context.Background(), logs.HandoffFailed(request.ConnID, request.Endpoint, currentRetries, maxRetries, err))
}
// Schedule retry - keep connection in pending map until retry is queued
time.AfterFunc(afterTime, func() {
if err := hwm.queueHandoff(request.Conn); err != nil {
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(context.Background(), logs.CannotQueueHandoffForRetry(err))
}
// Failed to queue retry - remove from pending and close connection
hwm.pending.Delete(request.Conn.GetID())
hwm.closeConnFromRequest(context.Background(), request, err)
} else {
// Successfully queued retry - remove from pending (will be re-added by queueHandoff)
hwm.pending.Delete(request.Conn.GetID())
}
})
return
} else {
// Won't retry - remove from pending and close connection
hwm.pending.Delete(request.Conn.GetID())
go hwm.closeConnFromRequest(ctx, request, err)
}
// Clear handoff state if not returned for retry
seqID := request.Conn.GetMovingSeqID()
connID := request.Conn.GetID()
if hwm.poolHook.operationsManager != nil {
hwm.poolHook.operationsManager.UntrackOperationWithConnID(seqID, connID)
}
} else {
// Success - remove from pending map
hwm.pending.Delete(request.Conn.GetID())
}
}
// queueHandoff queues a handoff request for processing
// if err is returned, connection will be removed from pool
func (hwm *handoffWorkerManager) queueHandoff(conn *pool.Conn) error {
// Get handoff info atomically to prevent race conditions
shouldHandoff, endpoint, seqID := conn.GetHandoffInfo()
// on retries the connection will not be marked for handoff, but it will have retries > 0
// if shouldHandoff is false and retries is 0, then we are not retrying and not do a handoff
if !shouldHandoff && conn.HandoffRetries() == 0 {
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.ConnectionNotMarkedForHandoff(conn.GetID()))
}
return errors.New(logs.ConnectionNotMarkedForHandoffError(conn.GetID()))
}
// Create handoff request with atomically retrieved data
request := HandoffRequest{
Conn: conn,
ConnID: conn.GetID(),
Endpoint: endpoint,
SeqID: seqID,
Pool: hwm.poolHook.pool, // Include pool for connection removal on failure
}
select {
// priority to shutdown
case <-hwm.shutdown:
return ErrShutdown
default:
select {
case <-hwm.shutdown:
return ErrShutdown
case hwm.handoffQueue <- request:
// Store in pending map
hwm.pending.Store(request.ConnID, request.SeqID)
// Ensure we have a worker to process this request
hwm.ensureWorkerAvailable()
return nil
default:
select {
case <-hwm.shutdown:
return ErrShutdown
case hwm.handoffQueue <- request:
// Store in pending map
hwm.pending.Store(request.ConnID, request.SeqID)
// Ensure we have a worker to process this request
hwm.ensureWorkerAvailable()
return nil
case <-time.After(100 * time.Millisecond): // give workers a chance to process
// Queue is full - log and attempt scaling
queueLen := len(hwm.handoffQueue)
queueCap := cap(hwm.handoffQueue)
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(context.Background(), logs.HandoffQueueFull(queueLen, queueCap))
}
}
}
}
// Ensure we have workers available to handle the load
hwm.ensureWorkerAvailable()
return ErrHandoffQueueFull
}
// shutdownWorkers gracefully shuts down the worker manager, waiting for workers to complete
func (hwm *handoffWorkerManager) shutdownWorkers(ctx context.Context) error {
hwm.shutdownOnce.Do(func() {
close(hwm.shutdown)
// workers will exit when they finish their current request
// Shutdown circuit breaker manager cleanup goroutine
if hwm.circuitBreakerManager != nil {
hwm.circuitBreakerManager.Shutdown()
}
})
// Wait for workers to complete
done := make(chan struct{})
go func() {
hwm.workerWg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// performConnectionHandoff performs the actual connection handoff
// When error is returned, the connection handoff should be retried if err is not ErrMaxHandoffRetriesReached
func (hwm *handoffWorkerManager) performConnectionHandoff(ctx context.Context, conn *pool.Conn) (shouldRetry bool, err error) {
// Clear handoff state after successful handoff
connID := conn.GetID()
newEndpoint := conn.GetHandoffEndpoint()
if newEndpoint == "" {
return false, ErrConnectionInvalidHandoffState
}
// Use circuit breaker to protect against failing endpoints
circuitBreaker := hwm.circuitBreakerManager.GetCircuitBreaker(newEndpoint)
// Check if circuit breaker is open before attempting handoff
if circuitBreaker.IsOpen() {
internal.Logger.Printf(ctx, logs.CircuitBreakerOpen(connID, newEndpoint))
return false, ErrCircuitBreakerOpen // Don't retry when circuit breaker is open
}
// Perform the handoff
shouldRetry, err = hwm.performHandoffInternal(ctx, conn, newEndpoint, connID)
// Update circuit breaker based on result
if err != nil {
// Only track dial/network errors in circuit breaker, not initialization errors
if shouldRetry {
circuitBreaker.recordFailure()
}
return shouldRetry, err
}
// Success - record in circuit breaker
circuitBreaker.recordSuccess()
return false, nil
}
// performHandoffInternal performs the actual handoff logic (extracted for circuit breaker integration)
func (hwm *handoffWorkerManager) performHandoffInternal(
ctx context.Context,
conn *pool.Conn,
newEndpoint string,
connID uint64,
) (shouldRetry bool, err error) {
retries := conn.IncrementAndGetHandoffRetries(1)
internal.Logger.Printf(ctx, logs.HandoffRetryAttempt(connID, retries, newEndpoint, conn.RemoteAddr().String()))
maxRetries := 3 // Default fallback
if hwm.config != nil {
maxRetries = hwm.config.MaxHandoffRetries
}
if retries > maxRetries {
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(ctx, logs.ReachedMaxHandoffRetries(connID, newEndpoint, maxRetries))
}
// won't retry on ErrMaxHandoffRetriesReached
return false, ErrMaxHandoffRetriesReached
}
// Create endpoint-specific dialer
endpointDialer := hwm.createEndpointDialer(newEndpoint)
// Create new connection to the new endpoint
newNetConn, err := endpointDialer(ctx)
if err != nil {
internal.Logger.Printf(ctx, logs.FailedToDialNewEndpoint(connID, newEndpoint, err))
// will retry
// Maybe a network error - retry after a delay
return true, err
}
// Get the old connection
oldConn := conn.GetNetConn()
// Apply relaxed timeout to the new connection for the configured post-handoff duration
// This gives the new connection more time to handle operations during cluster transition
// Setting this here (before initing the connection) ensures that the connection is going
// to use the relaxed timeout for the first operation (auth/ACL select)
if hwm.config != nil && hwm.config.PostHandoffRelaxedDuration > 0 {
relaxedTimeout := hwm.config.RelaxedTimeout
// Set relaxed timeout with deadline - no background goroutine needed
deadline := time.Now().Add(hwm.config.PostHandoffRelaxedDuration)
conn.SetRelaxedTimeoutWithDeadline(relaxedTimeout, relaxedTimeout, deadline)
// Record relaxed timeout metric (post-handoff)
if relaxedTimeoutCallback := pool.GetMetricConnectionRelaxedTimeoutCallback(); relaxedTimeoutCallback != nil {
relaxedTimeoutCallback(ctx, 1, conn, PoolNameMain, "HANDOFF")
}
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(context.Background(), logs.ApplyingRelaxedTimeoutDueToPostHandoff(connID, relaxedTimeout, deadline.Format("15:04:05.000")))
}
}
// Replace the connection and execute initialization
err = conn.SetNetConnAndInitConn(ctx, newNetConn)
if err != nil {
// won't retry
// Initialization failed - remove the connection
return false, err
}
defer func() {
if oldConn != nil {
oldConn.Close()
}
}()
// Clear handoff state will:
// - set the connection as usable again
// - clear the handoff state (shouldHandoff, endpoint, seqID)
// - reset the handoff retries to 0
// Note: Theoretically there may be a short window where the connection is in the pool
// and IDLE (initConn completed) but still has handoff state set.
conn.ClearHandoffState()
internal.Logger.Printf(ctx, logs.HandoffSucceeded(connID, newEndpoint))
// successfully completed the handoff, no retry needed and no error
// Notify metrics: connection handoff succeeded
if handoffCallback := pool.GetMetricConnectionHandoffCallback(); handoffCallback != nil {
handoffCallback(ctx, conn, PoolNameMain)
}
return false, nil
}
// createEndpointDialer creates a dialer function that connects to a specific endpoint
func (hwm *handoffWorkerManager) createEndpointDialer(endpoint string) func(context.Context) (net.Conn, error) {
return func(ctx context.Context) (net.Conn, error) {
// Parse endpoint to extract host and port
host, port, err := net.SplitHostPort(endpoint)
if err != nil {
// If no port specified, assume default Redis port
host = endpoint
if port == "" {
port = "6379"
}
}
// Use the base dialer to connect to the new endpoint
return hwm.poolHook.baseDialer(ctx, hwm.poolHook.network, net.JoinHostPort(host, port))
}
}
// closeConnFromRequest closes the connection and logs the reason
func (hwm *handoffWorkerManager) closeConnFromRequest(ctx context.Context, request HandoffRequest, err error) {
pooler := request.Pool
conn := request.Conn
// Clear handoff state before closing
conn.ClearHandoffState()
if pooler != nil {
// Use RemoveWithoutTurn instead of Remove to avoid freeing a turn that we don't have.
// The handoff worker doesn't call Get(), so it doesn't have a turn to free.
// Remove() is meant to be called after Get() and frees a turn.
// RemoveWithoutTurn() removes and closes the connection without affecting the queue.
pooler.RemoveWithoutTurn(ctx, conn, err)
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(ctx, logs.RemovingConnectionFromPool(conn.GetID(), err))
}
} else {
errClose := conn.Close() // Close the connection if no pool provided
if errClose != nil {
internal.Logger.Printf(ctx, "redis: failed to close connection: %v", errClose)
}
if internal.LogLevel.WarnOrAbove() {
internal.Logger.Printf(ctx, logs.NoPoolProvidedCannotRemove(conn.GetID(), err))
}
}
}
+60
View File
@@ -0,0 +1,60 @@
package maintnotifications
import (
"context"
"slices"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/push"
)
// LoggingHook is an example hook implementation that logs all notifications.
type LoggingHook struct {
LogLevel int // 0=Error, 1=Warn, 2=Info, 3=Debug
}
// PreHook logs the notification before processing and allows modification.
func (lh *LoggingHook) PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) {
if lh.LogLevel >= 2 { // Info level
// Log the notification type and content
connID := uint64(0)
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
connID = conn.GetID()
}
seqID := int64(0)
if slices.Contains(maintenanceNotificationTypes, notificationType) {
// seqID is the second element in the notification array
if len(notification) > 1 {
if parsedSeqID, ok := notification[1].(int64); !ok {
seqID = 0
} else {
seqID = parsedSeqID
}
}
}
internal.Logger.Printf(ctx, logs.ProcessingNotification(connID, seqID, notificationType, notification))
}
return notification, true // Continue processing with unmodified notification
}
// PostHook logs the result after processing.
func (lh *LoggingHook) PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) {
connID := uint64(0)
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
connID = conn.GetID()
}
if result != nil && lh.LogLevel >= 1 { // Warning level
internal.Logger.Printf(ctx, logs.ProcessingNotificationFailed(connID, notificationType, result, notification))
} else if lh.LogLevel >= 3 { // Debug level
internal.Logger.Printf(ctx, logs.ProcessingNotificationSucceeded(connID, notificationType))
}
}
// NewLoggingHook creates a new logging hook with the specified log level.
// Log levels: 0=Error, 1=Warn, 2=Info, 3=Debug
func NewLoggingHook(logLevel int) *LoggingHook {
return &LoggingHook{LogLevel: logLevel}
}
+362
View File
@@ -0,0 +1,362 @@
package maintnotifications
import (
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/interfaces"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/push"
)
// Push notification type constants for maintenance
const (
NotificationMoving = "MOVING" // Per-connection handoff notification
NotificationMigrating = "MIGRATING" // Per-connection migration start notification - relaxes timeouts
NotificationMigrated = "MIGRATED" // Per-connection migration complete notification - clears relaxed timeouts
NotificationFailingOver = "FAILING_OVER" // Per-connection failover start notification - relaxes timeouts
NotificationFailedOver = "FAILED_OVER" // Per-connection failover complete notification - clears relaxed timeouts
NotificationSMigrating = "SMIGRATING" // Cluster slot migrating notification - relaxes timeouts
NotificationSMigrated = "SMIGRATED" // Cluster slot migrated notification - unrelaxes timeouts and triggers cluster state reload
)
// maintenanceNotificationTypes contains all notification types that maintenance handles
var maintenanceNotificationTypes = []string{
NotificationMoving,
NotificationMigrating,
NotificationMigrated,
NotificationFailingOver,
NotificationFailedOver,
NotificationSMigrating,
NotificationSMigrated,
}
// NotificationHook is called before and after notification processing
// PreHook can modify the notification and return false to skip processing
// PostHook is called after successful processing
type NotificationHook interface {
PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool)
PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error)
}
// MovingOperationKey provides a unique key for tracking MOVING operations
// that combines sequence ID with connection identifier to handle duplicate
// sequence IDs across multiple connections to the same node.
type MovingOperationKey struct {
SeqID int64 // Sequence ID from MOVING notification
ConnID uint64 // Unique connection identifier
}
// String returns a string representation of the key for debugging
func (k MovingOperationKey) String() string {
return fmt.Sprintf("seq:%d-conn:%d", k.SeqID, k.ConnID)
}
// Manager provides a simplified upgrade functionality with hooks and atomic state.
type Manager struct {
client interfaces.ClientInterface
config *Config
options interfaces.OptionsInterface
pool pool.Pooler
// MOVING operation tracking - using sync.Map for better concurrent performance
activeMovingOps sync.Map // map[MovingOperationKey]*MovingOperation
// SMIGRATED notification deduplication - tracks processed SeqIDs
// Multiple connections may receive the same SMIGRATED notification
processedSMigratedSeqIDs sync.Map // map[int64]bool
// Atomic state tracking - no locks needed for state queries
activeOperationCount atomic.Int64 // Number of active operations
closed atomic.Bool // Manager closed state
// Notification hooks for extensibility
hooks []NotificationHook
hooksMu sync.RWMutex // Protects hooks slice
poolHooksRef *PoolHook
// Cluster state reload callback for SMIGRATED notifications
clusterStateReloadCallback ClusterStateReloadCallback
}
// MovingOperation tracks an active MOVING operation.
type MovingOperation struct {
SeqID int64
NewEndpoint string
StartTime time.Time
Deadline time.Time
}
// ClusterStateReloadCallback is a callback function that triggers cluster state reload.
// This is used by node clients to notify their parent ClusterClient about SMIGRATED notifications.
// The hostPort parameter indicates the destination node (e.g., "127.0.0.1:6379").
// The slotRanges parameter contains the migrated slots (e.g., ["1234", "5000-6000"]).
// Currently, implementations typically reload the entire cluster state, but in the future
// this could be optimized to reload only the specific slots.
type ClusterStateReloadCallback func(ctx context.Context, hostPort string, slotRanges []string)
// NewManager creates a new simplified manager.
func NewManager(client interfaces.ClientInterface, pool pool.Pooler, config *Config) (*Manager, error) {
if client == nil {
return nil, ErrInvalidClient
}
hm := &Manager{
client: client,
pool: pool,
options: client.GetOptions(),
config: config.Clone(),
hooks: make([]NotificationHook, 0),
}
// Set up push notification handling
if err := hm.setupPushNotifications(); err != nil {
return nil, err
}
return hm, nil
}
// GetPoolHook creates a pool hook with a custom dialer.
func (hm *Manager) InitPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) {
poolHook := hm.createPoolHook(baseDialer)
hm.pool.AddPoolHook(poolHook)
}
// setupPushNotifications sets up push notification handling by registering with the client's processor.
func (hm *Manager) setupPushNotifications() error {
processor := hm.client.GetPushProcessor()
if processor == nil {
return ErrInvalidClient // Client doesn't support push notifications
}
// Create our notification handler
handler := &NotificationHandler{manager: hm, operationsManager: hm}
// Register handlers for all upgrade notifications with the client's processor
for _, notificationType := range maintenanceNotificationTypes {
if err := processor.RegisterHandler(notificationType, handler, true); err != nil {
return errors.New(logs.FailedToRegisterHandler(notificationType, err))
}
}
return nil
}
// TrackMovingOperationWithConnID starts a new MOVING operation with a specific connection ID.
func (hm *Manager) TrackMovingOperationWithConnID(ctx context.Context, newEndpoint string, deadline time.Time, seqID int64, connID uint64) error {
// Create composite key
key := MovingOperationKey{
SeqID: seqID,
ConnID: connID,
}
// Create MOVING operation record
movingOp := &MovingOperation{
SeqID: seqID,
NewEndpoint: newEndpoint,
StartTime: time.Now(),
Deadline: deadline,
}
// Use LoadOrStore for atomic check-and-set operation
if _, loaded := hm.activeMovingOps.LoadOrStore(key, movingOp); loaded {
// Duplicate MOVING notification, ignore
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.DuplicateMovingOperation(connID, newEndpoint, seqID))
}
return nil
}
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.TrackingMovingOperation(connID, newEndpoint, seqID))
}
// Increment active operation count atomically
hm.activeOperationCount.Add(1)
return nil
}
// UntrackOperationWithConnID completes a MOVING operation with a specific connection ID.
func (hm *Manager) UntrackOperationWithConnID(seqID int64, connID uint64) {
// Create composite key
key := MovingOperationKey{
SeqID: seqID,
ConnID: connID,
}
// Remove from active operations atomically
if _, loaded := hm.activeMovingOps.LoadAndDelete(key); loaded {
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.UntrackingMovingOperation(connID, seqID))
}
// Decrement active operation count only if operation existed
hm.activeOperationCount.Add(-1)
} else {
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.OperationNotTracked(connID, seqID))
}
}
}
// GetActiveMovingOperations returns active operations with composite keys.
// WARNING: This method creates a new map and copies all operations on every call.
// Use sparingly, especially in hot paths or high-frequency logging.
func (hm *Manager) GetActiveMovingOperations() map[MovingOperationKey]*MovingOperation {
result := make(map[MovingOperationKey]*MovingOperation)
// Iterate over sync.Map to build result
hm.activeMovingOps.Range(func(key, value interface{}) bool {
k := key.(MovingOperationKey)
op := value.(*MovingOperation)
// Create a copy to avoid sharing references
result[k] = &MovingOperation{
SeqID: op.SeqID,
NewEndpoint: op.NewEndpoint,
StartTime: op.StartTime,
Deadline: op.Deadline,
}
return true // Continue iteration
})
return result
}
// IsHandoffInProgress returns true if any handoff is in progress.
// Uses atomic counter for lock-free operation.
func (hm *Manager) IsHandoffInProgress() bool {
return hm.activeOperationCount.Load() > 0
}
// GetActiveOperationCount returns the number of active operations.
// Uses atomic counter for lock-free operation.
func (hm *Manager) GetActiveOperationCount() int64 {
return hm.activeOperationCount.Load()
}
// MarkSMigratedSeqIDProcessed attempts to mark a SMIGRATED SeqID as processed.
// Returns true if this is the first time processing this SeqID (should process),
// false if it was already processed (should skip).
// This prevents duplicate processing when multiple connections receive the same notification.
func (hm *Manager) MarkSMigratedSeqIDProcessed(seqID int64) bool {
_, alreadyProcessed := hm.processedSMigratedSeqIDs.LoadOrStore(seqID, true)
return !alreadyProcessed // Return true if NOT already processed
}
// Close closes the manager.
func (hm *Manager) Close() error {
// Use atomic operation for thread-safe close check
if !hm.closed.CompareAndSwap(false, true) {
return nil // Already closed
}
// Shutdown the pool hook if it exists
if hm.poolHooksRef != nil {
// Use a timeout to prevent hanging indefinitely
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := hm.poolHooksRef.Shutdown(shutdownCtx)
if err != nil {
// was not able to close pool hook, keep closed state false
hm.closed.Store(false)
return err
}
// Remove the pool hook from the pool
if hm.pool != nil {
hm.pool.RemovePoolHook(hm.poolHooksRef)
}
}
// Clear all active operations
hm.activeMovingOps.Range(func(key, value interface{}) bool {
hm.activeMovingOps.Delete(key)
return true
})
// Reset counter
hm.activeOperationCount.Store(0)
return nil
}
// GetState returns current state using atomic counter for lock-free operation.
func (hm *Manager) GetState() State {
if hm.activeOperationCount.Load() > 0 {
return StateMoving
}
return StateIdle
}
// processPreHooks calls all pre-hooks and returns the modified notification and whether to continue processing.
func (hm *Manager) processPreHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) {
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
currentNotification := notification
for _, hook := range hm.hooks {
modifiedNotification, shouldContinue := hook.PreHook(ctx, notificationCtx, notificationType, currentNotification)
if !shouldContinue {
return modifiedNotification, false
}
currentNotification = modifiedNotification
}
return currentNotification, true
}
// processPostHooks calls all post-hooks with the processing result.
func (hm *Manager) processPostHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) {
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
for _, hook := range hm.hooks {
hook.PostHook(ctx, notificationCtx, notificationType, notification, result)
}
}
// createPoolHook creates a pool hook with this manager already set.
func (hm *Manager) createPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) *PoolHook {
if hm.poolHooksRef != nil {
return hm.poolHooksRef
}
// Get pool size from client options for better worker defaults
poolSize := 0
if hm.options != nil {
poolSize = hm.options.GetPoolSize()
}
hm.poolHooksRef = NewPoolHookWithPoolSize(baseDialer, hm.options.GetNetwork(), hm.config, hm, poolSize)
hm.poolHooksRef.SetPool(hm.pool)
return hm.poolHooksRef
}
func (hm *Manager) AddNotificationHook(notificationHook NotificationHook) {
hm.hooksMu.Lock()
defer hm.hooksMu.Unlock()
hm.hooks = append(hm.hooks, notificationHook)
}
// SetClusterStateReloadCallback sets the callback function that will be called when a SMIGRATED notification is received.
// This allows node clients to notify their parent ClusterClient to reload cluster state.
func (hm *Manager) SetClusterStateReloadCallback(callback ClusterStateReloadCallback) {
hm.clusterStateReloadCallback = callback
}
// TriggerClusterStateReload calls the cluster state reload callback if it's set.
// This is called when a SMIGRATED notification is received.
func (hm *Manager) TriggerClusterStateReload(ctx context.Context, hostPort string, slotRanges []string) {
if hm.clusterStateReloadCallback != nil {
hm.clusterStateReloadCallback(ctx, hostPort, slotRanges)
}
}
+182
View File
@@ -0,0 +1,182 @@
package maintnotifications
import (
"context"
"net"
"sync"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
)
// OperationsManagerInterface defines the interface for completing handoff operations
type OperationsManagerInterface interface {
TrackMovingOperationWithConnID(ctx context.Context, newEndpoint string, deadline time.Time, seqID int64, connID uint64) error
UntrackOperationWithConnID(seqID int64, connID uint64)
}
// HandoffRequest represents a request to handoff a connection to a new endpoint
type HandoffRequest struct {
Conn *pool.Conn
ConnID uint64 // Unique connection identifier
Endpoint string
SeqID int64
Pool pool.Pooler // Pool to remove connection from on failure
}
// PoolHook implements pool.PoolHook for Redis-specific connection handling
// with maintenance notifications support.
type PoolHook struct {
// Base dialer for creating connections to new endpoints during handoffs
// args are network and address
baseDialer func(context.Context, string, string) (net.Conn, error)
// Network type (e.g., "tcp", "unix")
network string
// Worker manager for background handoff processing
workerManager *handoffWorkerManager
// Configuration for the maintenance notifications
config *Config
// Operations manager interface for operation completion tracking
operationsManager OperationsManagerInterface
// Pool interface for removing connections on handoff failure
pool pool.Pooler
}
// NewPoolHook creates a new pool hook
func NewPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error), network string, config *Config, operationsManager OperationsManagerInterface) *PoolHook {
return NewPoolHookWithPoolSize(baseDialer, network, config, operationsManager, 0)
}
// NewPoolHookWithPoolSize creates a new pool hook with pool size for better worker defaults
func NewPoolHookWithPoolSize(baseDialer func(context.Context, string, string) (net.Conn, error), network string, config *Config, operationsManager OperationsManagerInterface, poolSize int) *PoolHook {
// Apply defaults if config is nil or has zero values
if config == nil {
config = config.ApplyDefaultsWithPoolSize(poolSize)
}
ph := &PoolHook{
// baseDialer is used to create connections to new endpoints during handoffs
baseDialer: baseDialer,
network: network,
config: config,
operationsManager: operationsManager,
}
// Create worker manager
ph.workerManager = newHandoffWorkerManager(config, ph)
return ph
}
// SetPool sets the pool interface for removing connections on handoff failure
func (ph *PoolHook) SetPool(pooler pool.Pooler) {
ph.pool = pooler
}
// GetCurrentWorkers returns the current number of active workers (for testing)
func (ph *PoolHook) GetCurrentWorkers() int {
return ph.workerManager.getCurrentWorkers()
}
// IsHandoffPending returns true if the given connection has a pending handoff
func (ph *PoolHook) IsHandoffPending(conn *pool.Conn) bool {
return ph.workerManager.isHandoffPending(conn)
}
// GetPendingMap returns the pending map for testing purposes
func (ph *PoolHook) GetPendingMap() *sync.Map {
return ph.workerManager.getPendingMap()
}
// GetMaxWorkers returns the max workers for testing purposes
func (ph *PoolHook) GetMaxWorkers() int {
return ph.workerManager.getMaxWorkers()
}
// GetHandoffQueue returns the handoff queue for testing purposes
func (ph *PoolHook) GetHandoffQueue() chan HandoffRequest {
return ph.workerManager.getHandoffQueue()
}
// GetCircuitBreakerStats returns circuit breaker statistics for monitoring
func (ph *PoolHook) GetCircuitBreakerStats() []CircuitBreakerStats {
return ph.workerManager.getCircuitBreakerStats()
}
// ResetCircuitBreakers resets all circuit breakers (useful for testing)
func (ph *PoolHook) ResetCircuitBreakers() {
ph.workerManager.resetCircuitBreakers()
}
// OnGet is called when a connection is retrieved from the pool
func (ph *PoolHook) OnGet(_ context.Context, conn *pool.Conn, _ bool) (accept bool, err error) {
// Check if connection is marked for handoff
// This prevents using connections that have received MOVING notifications
if conn.ShouldHandoff() {
return false, ErrConnectionMarkedForHandoffWithState
}
// Check if connection is usable (not in UNUSABLE or CLOSED state)
// This ensures we don't return connections that are currently being handed off or re-authenticated.
if !conn.IsUsable() {
return false, ErrConnectionMarkedForHandoff
}
return true, nil
}
// OnPut is called when a connection is returned to the pool
func (ph *PoolHook) OnPut(ctx context.Context, conn *pool.Conn) (shouldPool bool, shouldRemove bool, err error) {
// first check if we should handoff for faster rejection
if !conn.ShouldHandoff() {
// Default behavior (no handoff): pool the connection
return true, false, nil
}
// check pending handoff to not queue the same connection twice
if ph.workerManager.isHandoffPending(conn) {
// Default behavior (pending handoff): pool the connection
return true, false, nil
}
if err := ph.workerManager.queueHandoff(conn); err != nil {
// Failed to queue handoff, remove the connection
internal.Logger.Printf(ctx, logs.FailedToQueueHandoff(conn.GetID(), err))
// Don't pool, remove connection, no error to caller
return false, true, nil
}
// Check if handoff was already processed by a worker before we can mark it as queued
if !conn.ShouldHandoff() {
// Handoff was already processed - this is normal and the connection should be pooled
return true, false, nil
}
if err := conn.MarkQueuedForHandoff(); err != nil {
// If marking fails, check if handoff was processed in the meantime
if !conn.ShouldHandoff() {
// Handoff was processed - this is normal, pool the connection
return true, false, nil
}
// Other error - remove the connection
return false, true, nil
}
internal.Logger.Printf(ctx, logs.MarkedForHandoff(conn.GetID()))
return true, false, nil
}
func (ph *PoolHook) OnRemove(_ context.Context, _ *pool.Conn, _ error) {
// Not used
}
// Shutdown gracefully shuts down the processor, waiting for workers to complete
func (ph *PoolHook) Shutdown(ctx context.Context) error {
return ph.workerManager.shutdownWorkers(ctx)
}
@@ -0,0 +1,524 @@
package maintnotifications
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/push"
)
// NotificationHandler handles push notifications for the simplified manager.
type NotificationHandler struct {
manager *Manager
operationsManager OperationsManagerInterface
}
// HandlePushNotification processes push notifications with hook support.
func (snh *NotificationHandler) HandlePushNotification(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) == 0 {
internal.Logger.Printf(ctx, logs.InvalidNotificationFormat(notification))
return ErrInvalidNotification
}
notificationType, ok := notification[0].(string)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidNotificationTypeFormat(notification[0]))
return ErrInvalidNotification
}
// Process pre-hooks - they can modify the notification or skip processing
modifiedNotification, shouldContinue := snh.manager.processPreHooks(ctx, handlerCtx, notificationType, notification)
if !shouldContinue {
return nil // Hooks decided to skip processing
}
var err error
switch notificationType {
case NotificationMoving:
err = snh.handleMoving(ctx, handlerCtx, modifiedNotification)
case NotificationMigrating:
err = snh.handleMigrating(ctx, handlerCtx, modifiedNotification)
case NotificationMigrated:
err = snh.handleMigrated(ctx, handlerCtx, modifiedNotification)
case NotificationFailingOver:
err = snh.handleFailingOver(ctx, handlerCtx, modifiedNotification)
case NotificationFailedOver:
err = snh.handleFailedOver(ctx, handlerCtx, modifiedNotification)
case NotificationSMigrating:
err = snh.handleSMigrating(ctx, handlerCtx, modifiedNotification)
case NotificationSMigrated:
err = snh.handleSMigrated(ctx, handlerCtx, modifiedNotification)
default:
// Ignore other notification types (e.g., pub/sub messages)
err = nil
}
// Record maintenance notification metric
if maintenanceCallback := pool.GetMetricMaintenanceNotificationCallback(); maintenanceCallback != nil {
if conn, ok := handlerCtx.Conn.(*pool.Conn); ok {
maintenanceCallback(ctx, conn, notificationType)
}
}
// Process post-hooks with the result
snh.manager.processPostHooks(ctx, handlerCtx, notificationType, modifiedNotification, err)
return err
}
// handleMoving processes MOVING notifications.
// MOVING indicates that a connection should be handed off to a new endpoint.
// This is a per-connection notification that triggers connection handoff.
// Expected format: ["MOVING", seqNum, timeS, endpoint]
func (snh *NotificationHandler) handleMoving(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 3 {
internal.Logger.Printf(ctx, logs.InvalidNotification("MOVING", notification))
return ErrInvalidNotification
}
seqID, ok := notification[1].(int64)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidSeqIDInMovingNotification(notification[1]))
return ErrInvalidNotification
}
// Extract timeS
timeS, ok := notification[2].(int64)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidTimeSInMovingNotification(notification[2]))
return ErrInvalidNotification
}
newEndpoint := ""
if len(notification) > 3 {
// Extract new endpoint
newEndpoint, ok = notification[3].(string)
if !ok {
stringified := fmt.Sprintf("%v", notification[3])
// this could be <nil> which is valid
if notification[3] == nil || stringified == internal.RedisNull {
newEndpoint = ""
} else {
internal.Logger.Printf(ctx, logs.InvalidNewEndpointInMovingNotification(notification[3]))
return ErrInvalidNotification
}
}
}
// Get the connection that received this notification
conn := handlerCtx.Conn
if conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MOVING"))
return ErrInvalidNotification
}
// Type assert to get the underlying pool connection
var poolConn *pool.Conn
if pc, ok := conn.(*pool.Conn); ok {
poolConn = pc
} else {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MOVING", conn, handlerCtx))
return ErrInvalidNotification
}
// If the connection is closed or not pooled, we can ignore the notification
// this connection won't be remembered by the pool and will be garbage collected
// Keep pubsub connections around since they are not pooled but are long-lived
// and should be allowed to handoff (the pubsub instance will reconnect and change
// the underlying *pool.Conn)
if (poolConn.IsClosed() || !poolConn.IsPooled()) && !poolConn.IsPubSub() {
return nil
}
deadline := time.Now().Add(time.Duration(timeS) * time.Second)
// If newEndpoint is empty, we should schedule a handoff to the current endpoint in timeS/2 seconds
if newEndpoint == "" || newEndpoint == internal.RedisNull {
if internal.LogLevel.DebugOrAbove() {
internal.Logger.Printf(ctx, logs.SchedulingHandoffToCurrentEndpoint(poolConn.GetID(), float64(timeS)/2))
}
// same as current endpoint
newEndpoint = snh.manager.options.GetAddr()
// delay the handoff for timeS/2 seconds to the same endpoint
// do this in a goroutine to avoid blocking the notification handler
// NOTE: This timer is started while parsing the notification, so the connection is not marked for handoff
// and there should be no possibility of a race condition or double handoff.
time.AfterFunc(time.Duration(timeS/2)*time.Second, func() {
if poolConn == nil || poolConn.IsClosed() {
return
}
if err := snh.markConnForHandoff(poolConn, newEndpoint, seqID, deadline); err != nil {
// Log error but don't fail the goroutine - use background context since original may be cancelled
internal.Logger.Printf(context.Background(), logs.FailedToMarkForHandoff(poolConn.GetID(), err))
return
}
// Queue the handoff immediately if the connection is idle in the pool.
// If the connection is in use (StateInUse), it will be queued when returned to the pool via OnPut.
// This handles the case where the connection is idle and might never be retrieved again.
if poolConn.GetStateMachine().GetState() == pool.StateIdle {
if snh.manager.poolHooksRef != nil && snh.manager.poolHooksRef.workerManager != nil {
if err := snh.manager.poolHooksRef.workerManager.queueHandoff(poolConn); err != nil {
internal.Logger.Printf(context.Background(), logs.FailedToQueueHandoff(poolConn.GetID(), err))
} else {
// Mark the connection as queued for handoff to prevent it from being retrieved
// This transitions the connection to StateUnusable
if err := poolConn.MarkQueuedForHandoff(); err != nil {
internal.Logger.Printf(context.Background(), logs.FailedToMarkForHandoff(poolConn.GetID(), err))
} else {
internal.Logger.Printf(context.Background(), logs.MarkedForHandoff(poolConn.GetID()))
}
}
}
}
// If connection is StateInUse, the handoff will be queued when it's returned to the pool
})
return nil
}
return snh.markConnForHandoff(poolConn, newEndpoint, seqID, deadline)
}
func (snh *NotificationHandler) markConnForHandoff(conn *pool.Conn, newEndpoint string, seqID int64, deadline time.Time) error {
if err := conn.MarkForHandoff(newEndpoint, seqID); err != nil {
internal.Logger.Printf(context.Background(), logs.FailedToMarkForHandoff(conn.GetID(), err))
// Connection is already marked for handoff, which is acceptable
// This can happen if multiple MOVING notifications are received for the same connection
return nil
}
// Optionally track in m
if snh.operationsManager != nil {
connID := conn.GetID()
// Track the operation (ignore errors since this is optional)
_ = snh.operationsManager.TrackMovingOperationWithConnID(context.Background(), newEndpoint, deadline, seqID, connID)
} else {
return errors.New(logs.ManagerNotInitialized())
}
return nil
}
// handleMigrating processes MIGRATING notifications.
// MIGRATING indicates that a connection migration is starting.
// This is a per-connection notification that applies relaxed timeouts.
// Expected format: ["MIGRATING", ...]
func (snh *NotificationHandler) handleMigrating(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 2 {
internal.Logger.Printf(ctx, logs.InvalidNotification("MIGRATING", notification))
return ErrInvalidNotification
}
if handlerCtx.Conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MIGRATING"))
return ErrInvalidNotification
}
conn, ok := handlerCtx.Conn.(*pool.Conn)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MIGRATING", handlerCtx.Conn, handlerCtx))
return ErrInvalidNotification
}
// Apply relaxed timeout to this specific connection
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(ctx, logs.RelaxedTimeoutDueToNotification(conn.GetID(), "MIGRATING", snh.manager.config.RelaxedTimeout))
}
conn.SetRelaxedTimeout(snh.manager.config.RelaxedTimeout, snh.manager.config.RelaxedTimeout)
// Record relaxed timeout metric
if relaxedTimeoutCallback := pool.GetMetricConnectionRelaxedTimeoutCallback(); relaxedTimeoutCallback != nil {
relaxedTimeoutCallback(ctx, 1, conn, PoolNameMain, "MIGRATING")
}
return nil
}
// handleMigrated processes MIGRATED notifications.
// MIGRATED indicates that a connection migration has completed.
// This is a per-connection notification that clears relaxed timeouts.
// Expected format: ["MIGRATED", ...]
func (snh *NotificationHandler) handleMigrated(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 2 {
internal.Logger.Printf(ctx, logs.InvalidNotification("MIGRATED", notification))
return ErrInvalidNotification
}
if handlerCtx.Conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MIGRATED"))
return ErrInvalidNotification
}
conn, ok := handlerCtx.Conn.(*pool.Conn)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MIGRATED", handlerCtx.Conn, handlerCtx))
return ErrInvalidNotification
}
// Clear relaxed timeout for this specific connection
if internal.LogLevel.InfoOrAbove() {
connID := conn.GetID()
internal.Logger.Printf(ctx, logs.UnrelaxedTimeout(connID))
}
conn.ClearRelaxedTimeout()
return nil
}
// handleFailingOver processes FAILING_OVER notifications.
// FAILING_OVER indicates that a failover is starting.
// This is a per-connection notification that applies relaxed timeouts.
// Expected format: ["FAILING_OVER", ...]
func (snh *NotificationHandler) handleFailingOver(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 2 {
internal.Logger.Printf(ctx, logs.InvalidNotification("FAILING_OVER", notification))
return ErrInvalidNotification
}
if handlerCtx.Conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("FAILING_OVER"))
return ErrInvalidNotification
}
conn, ok := handlerCtx.Conn.(*pool.Conn)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("FAILING_OVER", handlerCtx.Conn, handlerCtx))
return ErrInvalidNotification
}
// Apply relaxed timeout to this specific connection
if internal.LogLevel.InfoOrAbove() {
connID := conn.GetID()
internal.Logger.Printf(ctx, logs.RelaxedTimeoutDueToNotification(connID, "FAILING_OVER", snh.manager.config.RelaxedTimeout))
}
conn.SetRelaxedTimeout(snh.manager.config.RelaxedTimeout, snh.manager.config.RelaxedTimeout)
// Record relaxed timeout metric
if relaxedTimeoutCallback := pool.GetMetricConnectionRelaxedTimeoutCallback(); relaxedTimeoutCallback != nil {
relaxedTimeoutCallback(ctx, 1, conn, PoolNameMain, "FAILING_OVER")
}
return nil
}
// handleFailedOver processes FAILED_OVER notifications.
// FAILED_OVER indicates that a failover has completed.
// This is a per-connection notification that clears relaxed timeouts.
// Expected format: ["FAILED_OVER", ...]
func (snh *NotificationHandler) handleFailedOver(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 2 {
internal.Logger.Printf(ctx, logs.InvalidNotification("FAILED_OVER", notification))
return ErrInvalidNotification
}
if handlerCtx.Conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("FAILED_OVER"))
return ErrInvalidNotification
}
conn, ok := handlerCtx.Conn.(*pool.Conn)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("FAILED_OVER", handlerCtx.Conn, handlerCtx))
return ErrInvalidNotification
}
// Clear relaxed timeout for this specific connection
if internal.LogLevel.InfoOrAbove() {
connID := conn.GetID()
internal.Logger.Printf(ctx, logs.UnrelaxedTimeout(connID))
}
conn.ClearRelaxedTimeout()
return nil
}
// handleSMigrating processes SMIGRATING notifications.
// SMIGRATING indicates that a cluster slot is in the process of migrating to a different node.
// This is a per-connection notification that applies relaxed timeouts during slot migration.
// Expected format: ["SMIGRATING", SeqID, slot/range1-range2, ...]
func (snh *NotificationHandler) handleSMigrating(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
if len(notification) < 3 {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATING", notification))
return ErrInvalidNotification
}
// Validate SeqID (position 1)
if _, ok := notification[1].(int64); !ok {
internal.Logger.Printf(ctx, logs.InvalidSeqIDInSMigratingNotification(notification[1]))
return ErrInvalidNotification
}
if handlerCtx.Conn == nil {
internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("SMIGRATING"))
return ErrInvalidNotification
}
conn, ok := handlerCtx.Conn.(*pool.Conn)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("SMIGRATING", handlerCtx.Conn, handlerCtx))
return ErrInvalidNotification
}
// Apply relaxed timeout to this specific connection
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(ctx, logs.RelaxedTimeoutDueToNotification(conn.GetID(), "SMIGRATING", snh.manager.config.RelaxedTimeout))
}
conn.SetRelaxedTimeout(snh.manager.config.RelaxedTimeout, snh.manager.config.RelaxedTimeout)
return nil
}
// handleSMigrated processes SMIGRATED notifications.
// SMIGRATED indicates that a cluster slot has finished migrating to a different node.
// This is a cluster-level notification that triggers cluster state reload.
//
// Expected RESP3 format:
//
// >3
// +SMIGRATED
// :SeqID
// *<num_entries> <- array of triplet arrays
// *3 <- each triplet is a 3-element array
// +<source> <- node from which slots are migrating FROM
// +<destination> <- node to which slots are migrating TO
// +<slots> <- comma-separated slots and/or ranges (e.g., "123,789-1000")
//
// A source and target endpoint may appear in multiple triplets.
// The notification is only processed if the connection's NodeAddress matches one of the source endpoints.
//
// Note: Multiple connections may receive the same notification, so we deduplicate by SeqID before triggering reload.
// but we still process the notification on each connection to clear the relaxed timeout.
// In the case when the connection is from MOVED/ASK, the connection's original endpoint is not set,
// so we will not be able to match the source endpoint. In such case, we will trigger the reload callback with the first target endpoint.
func (snh *NotificationHandler) handleSMigrated(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error {
// Expected: ["SMIGRATED", SeqID, [[source, target, slots], ...]]
// Minimum 3 elements: SMIGRATED, SeqID, and the array of triplets
if len(notification) < 3 {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED", notification))
return ErrInvalidNotification
}
// Extract SeqID (position 1)
seqID, ok := notification[1].(int64)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidSeqIDInSMigratedNotification(notification[1]))
return ErrInvalidNotification
}
// Extract the array of triplets (position 2)
triplets, ok := notification[2].([]interface{})
if !ok {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (triplets array)", notification[2]))
return ErrInvalidNotification
}
if len(triplets) == 0 {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (empty triplets)", notification))
return ErrInvalidNotification
}
// Get the connection's endpoints to check if this notification is relevant
// We check against both nodeAddress (from CLUSTER SLOTS) and addr (after resolution)
// since we cannot be certain which format the notification source will use
var connectionNodeAddress string
var connectionAddr string
if snh.manager.options != nil {
connectionNodeAddress = snh.manager.options.GetNodeAddress()
connectionAddr = snh.manager.options.GetAddr()
}
// Helper function to check if source matches either of our endpoints
// notification source can be either the node address or the addr after resolution
sourceMatchesConnection := func(source string) bool {
if source == connectionNodeAddress {
return true
}
if source == connectionAddr {
return true
}
return false
}
// Parse triplets and check if any source matches our connection's endpoints
var matchingTriplets []struct {
source string
target string
slots string
}
var allSlotRanges []string
for _, tripletInterface := range triplets {
// Each triplet should be a 3-element array: [source, target, slots]
triplet, ok := tripletInterface.([]interface{})
if !ok || len(triplet) != 3 {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (triplet format)", tripletInterface))
continue
}
// Extract source endpoint
source, ok := triplet[0].(string)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (source)", triplet[0]))
continue
}
// Extract target endpoint
target, ok := triplet[1].(string)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (target)", triplet[1]))
continue
}
// Extract slots
slots, ok := triplet[2].(string)
if !ok {
internal.Logger.Printf(ctx, logs.InvalidNotification("SMIGRATED (slots)", triplet[2]))
continue
}
// Check if this triplet's source matches our connection's endpoints
if sourceMatchesConnection(source) {
matchingTriplets = append(matchingTriplets, struct {
source string
target string
slots string
}{source, target, slots})
slotRanges := strings.Split(slots, ",")
allSlotRanges = append(allSlotRanges, slotRanges...)
}
}
var connID uint64
// Reset relaxed timeout for this specific connection
if handlerCtx.Conn != nil {
conn, ok := handlerCtx.Conn.(*pool.Conn)
if ok {
if internal.LogLevel.InfoOrAbove() {
connID = conn.GetID()
internal.Logger.Printf(ctx, logs.UnrelaxedTimeout(connID))
}
conn.ClearRelaxedTimeout()
}
}
// If no matching triplets, this notification is not relevant to this connection
if len(matchingTriplets) == 0 {
return nil
}
// Deduplicate by SeqID - multiple connections may receive the same notification
// Only trigger cluster state reload once per seqID
if snh.manager.MarkSMigratedSeqIDProcessed(seqID) {
// Use the first matching triplet
target := matchingTriplets[0].target
slotsForLog := allSlotRanges
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(ctx, logs.TriggeringClusterStateReload(seqID, target, slotsForLog))
}
// Trigger cluster state reload via callback
snh.manager.TriggerClusterStateReload(ctx, target, slotsForLog)
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package maintnotifications
// State represents the current state of a maintenance operation
type State int
const (
// StateIdle indicates no upgrade is in progress
StateIdle State = iota
// StateHandoff indicates a connection handoff is in progress
StateMoving
)
// String returns a string representation of the state.
func (s State) String() string {
switch s {
case StateIdle:
return "idle"
case StateMoving:
return "moving"
default:
return "unknown"
}
}
+822
View File
@@ -0,0 +1,822 @@
package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"maps"
"net"
"net/url"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/internal/util"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/redis/go-redis/v9/push"
)
// poolIDCounter is a global auto-increment counter for generating unique pool IDs.
var poolIDCounter atomic.Uint64
// generateUniqueID generates a short unique identifier for pool names using auto-increment.
// This makes it easier to identify and track pools in order of creation.
func generateUniqueID() string {
id := poolIDCounter.Add(1)
return strconv.FormatUint(id, 10)
}
// Limiter is the interface of a rate limiter or a circuit breaker.
type Limiter interface {
// Allow returns nil if operation is allowed or an error otherwise.
// If operation is allowed client must ReportResult of the operation
// whether it is a success or a failure.
Allow() error
// ReportResult reports the result of the previously allowed operation.
// nil indicates a success, non-nil error usually indicates a failure.
ReportResult(result error)
}
// Options keeps the settings to set up redis connection.
type Options struct {
// Network type, either tcp or unix.
//
// default: is tcp.
Network string
// Addr is the address formated as host:port
Addr string
// NodeAddress is the address of the Redis node as reported by the server.
// For cluster clients, this is the exact endpoint string returned by CLUSTER SLOTS
// before any resolution or transformation (e.g., loopback replacement).
// For standalone clients, this defaults to Addr.
//
// This is used to match the source endpoint in maintenance notifications
// (e.g. SMIGRATED).
//
// Use Client.NodeAddress() to access this value.
NodeAddress string
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
ClientName string
// Dialer creates new network connection and has priority over
// Network and Addr options.
Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
// Hook that is called when new connection is established.
OnConnect func(ctx context.Context, cn *Conn) error
// Protocol 2 or 3. Use the version to negotiate RESP version with redis-server.
//
// default: 3.
Protocol int
// Username is used to authenticate the current connection
// with one of the connections defined in the ACL list when connecting
// to a Redis 6.0 instance, or greater, that is using the Redis ACL system.
Username string
// Password is an optional password. Must match the password specified in the
// `requirepass` server configuration option (if connecting to a Redis 5.0 instance, or lower),
// or the User Password when connecting to a Redis 6.0 instance, or greater,
// that is using the Redis ACL system.
Password string
// CredentialsProvider allows the username and password to be updated
// before reconnecting. It should return the current username and password.
CredentialsProvider func() (username string, password string)
// CredentialsProviderContext is an enhanced parameter of CredentialsProvider,
// done to maintain API compatibility. In the future,
// there might be a merge between CredentialsProviderContext and CredentialsProvider.
// There will be a conflict between them; if CredentialsProviderContext exists, we will ignore CredentialsProvider.
CredentialsProviderContext func(ctx context.Context) (username string, password string, err error)
// StreamingCredentialsProvider is used to retrieve the credentials
// for the connection from an external source. Those credentials may change
// during the connection lifetime. This is useful for managed identity
// scenarios where the credentials are retrieved from an external source.
//
// Currently, this is a placeholder for the future implementation.
StreamingCredentialsProvider auth.StreamingCredentialsProvider
// DB is the database to be selected after connecting to the server.
DB int
// MaxRetries is the maximum number of retries before giving up.
// -1 (not 0) disables retries.
//
// default: 3 retries
MaxRetries int
// MinRetryBackoff is the minimum backoff between each retry.
// -1 disables backoff.
//
// default: 8 milliseconds
MinRetryBackoff time.Duration
// MaxRetryBackoff is the maximum backoff between each retry.
// -1 disables backoff.
// default: 512 milliseconds;
MaxRetryBackoff time.Duration
// DialTimeout for establishing new connections.
//
// default: 5 seconds
DialTimeout time.Duration
// DialerRetries is the maximum number of retry attempts when dialing fails.
//
// default: 5
DialerRetries int
// DialerRetryTimeout is the backoff duration between retry attempts.
//
// 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:
//
// - `-1` - no timeout (block indefinitely).
// - `-2` - disables SetReadDeadline calls completely.
//
// default: 3 seconds
ReadTimeout time.Duration
// WriteTimeout for socket writes. If reached, commands will fail
// with a timeout instead of blocking. Supported values:
//
// - `-1` - no timeout (block indefinitely).
// - `-2` - disables SetWriteDeadline calls completely.
//
// default: 3 seconds
WriteTimeout time.Duration
// ContextTimeoutEnabled controls whether the client respects context timeouts and deadlines.
// See https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts
ContextTimeoutEnabled bool
// ReadBufferSize is the size of the bufio.Reader buffer for each connection.
// Larger buffers can improve performance for commands that return large responses.
// Smaller buffers can improve memory usage for larger pools.
//
// default: 32KiB (32768 bytes)
ReadBufferSize int
// WriteBufferSize is the size of the bufio.Writer buffer for each connection.
// Larger buffers can improve performance for large pipelines and commands with many arguments.
// Smaller buffers can improve memory usage for larger pools.
//
// default: 32KiB (32768 bytes)
WriteBufferSize int
// PoolFIFO type of connection pool.
//
// - true for FIFO pool
// - false for LIFO pool.
//
// Note that FIFO has slightly higher overhead compared to LIFO,
// but it helps closing idle connections faster reducing the pool size.
// default: false
PoolFIFO bool
// PoolSize is the base number of socket connections.
// Default is 10 connections per every available CPU as reported by runtime.GOMAXPROCS.
// If there is not enough connections in the pool, new connections will be allocated in excess of PoolSize,
// you can limit it through MaxActiveConns
//
// default: 10 * runtime.GOMAXPROCS(0)
PoolSize int
// MaxConcurrentDials is the maximum number of concurrent connection creation goroutines.
// If <= 0, defaults to PoolSize. If > PoolSize, it will be capped at PoolSize.
MaxConcurrentDials int
// PoolTimeout is the amount of time client waits for connection if all connections
// are busy before returning an error.
//
// default: ReadTimeout + 1 second
PoolTimeout time.Duration
// MinIdleConns is the minimum number of idle connections which is useful when establishing
// new connection is slow. The idle connections are not closed by default.
//
// default: 0
MinIdleConns int
// MaxIdleConns is the maximum number of idle connections.
// The idle connections are not closed by default.
//
// default: 0
MaxIdleConns int
// MaxActiveConns is the maximum number of connections allocated by the pool at a given time.
// When zero, there is no limit on the number of connections in the pool.
// If the pool is full, the next call to Get() will block until a connection is released.
//
// default: 0
MaxActiveConns int
// ConnMaxIdleTime is the maximum amount of time a connection may be idle.
// Should be less than server's timeout.
//
// Expired connections may be closed lazily before reuse.
// If d <= 0, connections are not closed due to a connection's idle time.
// -1 disables idle timeout check.
//
// default: 30 minutes
ConnMaxIdleTime time.Duration
// ConnMaxLifetime is the maximum amount of time a connection may be reused.
//
// Expired connections may be closed lazily before reuse.
// If <= 0, connections are not closed due to a connection's age.
//
// default: 0
ConnMaxLifetime time.Duration
// ConnMaxLifetimeJitter is the absolute jitter duration applied to ConnMaxLifetime
// to prevent all connections from expiring simultaneously.
//
// The jitter is applied as a random offset in the range [-jitter, +jitter].
// For example, if ConnMaxLifetime is 1 hour and ConnMaxLifetimeJitter is 6 minutes,
// connections will expire between 54 minutes and 66 minutes.
//
// If <= 0, no jitter is applied.
// If > ConnMaxLifetime, it will be capped at ConnMaxLifetime.
//
// default: 0
ConnMaxLifetimeJitter time.Duration
// TLSConfig to use. When set, TLS will be negotiated.
TLSConfig *tls.Config
// Limiter interface used to implement circuit breaker or rate limiter.
Limiter Limiter
// readOnly enables read only queries on slave/follower nodes.
readOnly bool
// DisableIndentity - Disable set-lib on connect.
//
// default: false
//
// Deprecated: Use DisableIdentity instead.
DisableIndentity bool
// DisableIdentity is used to disable CLIENT SETINFO command on connect.
//
// default: false
DisableIdentity bool
// Add suffix to client name. Default is empty.
// IdentitySuffix - add suffix to client name.
IdentitySuffix string
// UnstableResp3 enables Unstable mode for Redis Search module with RESP3.
// When unstable mode is enabled, the client will use RESP3 protocol and only be able to use RawResult
UnstableResp3 bool
// Push notifications are always enabled for RESP3 connections (Protocol: 3)
// and are not available for RESP2 connections. No configuration option is needed.
// PushNotificationProcessor is the processor for handling push notifications.
// If nil, a default processor will be created for RESP3 connections.
PushNotificationProcessor push.NotificationProcessor
// FailingTimeoutSeconds is the timeout in seconds for marking a cluster node as failing.
// When a node is marked as failing, it will be avoided for this duration.
// Default is 15 seconds.
FailingTimeoutSeconds int
// MaintNotificationsConfig provides custom configuration for maintnotifications.
// When MaintNotificationsConfig.Mode is not "disabled", the client will handle
// cluster upgrade notifications gracefully and manage connection/pool state
// transitions seamlessly. Requires Protocol: 3 (RESP3) for push notifications.
// If nil, maintnotifications are in "auto" mode and will be enabled if the server supports it.
MaintNotificationsConfig *maintnotifications.Config
}
func (opt *Options) init() {
if opt.Addr == "" {
opt.Addr = "localhost:6379"
}
if opt.Network == "" {
if strings.HasPrefix(opt.Addr, "/") {
opt.Network = "unix"
} else {
opt.Network = "tcp"
}
}
// For standalone clients, default NodeAddress to Addr if not set.
// This ensures maintenance notifications (SMIGRATED, etc.) can match
// the connection's endpoint even for non-cluster clients.
if opt.NodeAddress == "" {
opt.NodeAddress = opt.Addr
}
if opt.Protocol < 2 {
opt.Protocol = 3
}
if opt.DialTimeout == 0 {
opt.DialTimeout = 5 * time.Second
}
if opt.DialerRetries == 0 {
opt.DialerRetries = 5
}
if opt.DialerRetryTimeout == 0 {
opt.DialerRetryTimeout = 100 * time.Millisecond
}
if opt.Dialer == nil {
opt.Dialer = NewDialer(opt)
}
if opt.PoolSize == 0 {
opt.PoolSize = 10 * runtime.GOMAXPROCS(0)
}
if opt.MaxConcurrentDials <= 0 {
opt.MaxConcurrentDials = opt.PoolSize
} else if opt.MaxConcurrentDials > opt.PoolSize {
opt.MaxConcurrentDials = opt.PoolSize
}
if opt.ReadBufferSize == 0 {
opt.ReadBufferSize = proto.DefaultBufferSize
}
if opt.WriteBufferSize == 0 {
opt.WriteBufferSize = proto.DefaultBufferSize
}
switch opt.ReadTimeout {
case -2:
opt.ReadTimeout = -1
case -1:
opt.ReadTimeout = 0
case 0:
opt.ReadTimeout = 3 * time.Second
}
switch opt.WriteTimeout {
case -2:
opt.WriteTimeout = -1
case -1:
opt.WriteTimeout = 0
case 0:
opt.WriteTimeout = opt.ReadTimeout
}
if opt.PoolTimeout == 0 {
if opt.ReadTimeout > 0 {
opt.PoolTimeout = opt.ReadTimeout + time.Second
} else {
opt.PoolTimeout = 30 * time.Second
}
}
if opt.ConnMaxIdleTime == 0 {
opt.ConnMaxIdleTime = 30 * time.Minute
}
opt.ConnMaxLifetimeJitter = min(opt.ConnMaxLifetimeJitter, opt.ConnMaxLifetime)
switch opt.MaxRetries {
case -1:
opt.MaxRetries = 0
case 0:
opt.MaxRetries = 3
}
switch opt.MinRetryBackoff {
case -1:
opt.MinRetryBackoff = 0
case 0:
opt.MinRetryBackoff = 8 * time.Millisecond
}
switch opt.MaxRetryBackoff {
case -1:
opt.MaxRetryBackoff = 0
case 0:
opt.MaxRetryBackoff = 512 * time.Millisecond
}
if opt.FailingTimeoutSeconds == 0 {
opt.FailingTimeoutSeconds = 15
}
opt.MaintNotificationsConfig = opt.MaintNotificationsConfig.ApplyDefaultsWithPoolConfig(opt.PoolSize, opt.MaxActiveConns)
// auto-detect endpoint type if not specified
endpointType := opt.MaintNotificationsConfig.EndpointType
if endpointType == "" || endpointType == maintnotifications.EndpointTypeAuto {
// Auto-detect endpoint type if not specified
endpointType = maintnotifications.DetectEndpointType(opt.Addr, opt.TLSConfig != nil)
}
opt.MaintNotificationsConfig.EndpointType = endpointType
}
func (opt *Options) clone() *Options {
clone := *opt
// Deep clone MaintNotificationsConfig to avoid sharing between clients
if opt.MaintNotificationsConfig != nil {
configClone := *opt.MaintNotificationsConfig
clone.MaintNotificationsConfig = &configClone
}
return &clone
}
// NewDialer returns a function that will be used as the default dialer
// when none is specified in Options.Dialer.
func (opt *Options) NewDialer() func(context.Context, string, string) (net.Conn, error) {
return NewDialer(opt)
}
// NewDialer returns a function that will be used as the default dialer
// when none is specified in Options.Dialer.
func NewDialer(opt *Options) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{
Timeout: opt.DialTimeout,
KeepAlive: 5 * time.Minute,
}
if opt.TLSConfig == nil {
return netDialer.DialContext(ctx, network, addr)
}
return tls.DialWithDialer(netDialer, network, addr, opt.TLSConfig)
}
}
// ParseURL parses a URL into Options that can be used to connect to Redis.
// Scheme is required.
// There are two connection types: by tcp socket and by unix socket.
// Tcp connection:
//
// redis://<user>:<password>@<host>:<port>/<db_number>
//
// Unix connection:
//
// unix://<user>:<password>@</path/to/redis.sock>?db=<db_number>
//
// Most Option fields can be set using query parameters, with the following restrictions:
// - field names are mapped using snake-case conversion: to set MaxRetries, use max_retries
// - only scalar type fields are supported (bool, int, time.Duration)
// - for time.Duration fields, values must be a valid input for time.ParseDuration();
// additionally a plain integer as value (i.e. without unit) is interpreted as seconds
// - to disable a duration field, use value less than or equal to 0; to use the default
// value, leave the value blank or remove the parameter
// - only the last value is interpreted if a parameter is given multiple times
// - fields "network", "addr", "username" and "password" can only be set using other
// URL attributes (scheme, host, userinfo, resp.), query parameters using these
// names will be treated as unknown parameters
// - unknown parameter names will result in an error
// - use "skip_verify=true" to ignore TLS certificate validation
//
// Examples:
//
// redis://user:password@localhost:6789/3?dial_timeout=3&db=1&read_timeout=6s&max_retries=2
// is equivalent to:
// &Options{
// Network: "tcp",
// Addr: "localhost:6789",
// DB: 1, // path "/3" was overridden by "&db=1"
// DialTimeout: 3 * time.Second, // no time unit = seconds
// ReadTimeout: 6 * time.Second,
// MaxRetries: 2,
// }
func ParseURL(redisURL string) (*Options, error) {
u, err := url.Parse(redisURL)
if err != nil {
return nil, err
}
switch u.Scheme {
case "redis", "rediss":
return setupTCPConn(u)
case "unix":
return setupUnixConn(u)
default:
return nil, fmt.Errorf("redis: invalid URL scheme: %s", u.Scheme)
}
}
func setupTCPConn(u *url.URL) (*Options, error) {
o := &Options{Network: "tcp"}
o.Username, o.Password = getUserPassword(u)
h, p := getHostPortWithDefaults(u)
o.Addr = net.JoinHostPort(h, p)
f := strings.FieldsFunc(u.Path, func(r rune) bool {
return r == '/'
})
switch len(f) {
case 0:
o.DB = 0
case 1:
var err error
if o.DB, err = strconv.Atoi(f[0]); err != nil {
return nil, fmt.Errorf("redis: invalid database number: %q", f[0])
}
default:
return nil, fmt.Errorf("redis: invalid URL path: %s", u.Path)
}
if u.Scheme == "rediss" {
o.TLSConfig = &tls.Config{
ServerName: h,
MinVersion: tls.VersionTLS12,
}
}
return setupConnParams(u, o)
}
// getHostPortWithDefaults is a helper function that splits the url into
// a host and a port. If the host is missing, it defaults to localhost
// and if the port is missing, it defaults to 6379.
func getHostPortWithDefaults(u *url.URL) (string, string) {
host, port, err := net.SplitHostPort(u.Host)
if err != nil {
host = u.Host
}
if host == "" {
host = "localhost"
}
if port == "" {
port = "6379"
}
return host, port
}
func setupUnixConn(u *url.URL) (*Options, error) {
o := &Options{
Network: "unix",
}
if strings.TrimSpace(u.Path) == "" { // path is required with unix connection
return nil, errors.New("redis: empty unix socket path")
}
o.Addr = u.Path
o.Username, o.Password = getUserPassword(u)
return setupConnParams(u, o)
}
type queryOptions struct {
q url.Values
err error
}
func (o *queryOptions) has(name string) bool {
return len(o.q[name]) > 0
}
func (o *queryOptions) string(name string) string {
vs := o.q[name]
if len(vs) == 0 {
return ""
}
delete(o.q, name) // enable detection of unknown parameters
return vs[len(vs)-1]
}
func (o *queryOptions) strings(name string) []string {
vs := o.q[name]
delete(o.q, name)
return vs
}
func (o *queryOptions) int(name string) int {
s := o.string(name)
if s == "" {
return 0
}
i, err := strconv.Atoi(s)
if err == nil {
return i
}
if o.err == nil {
o.err = fmt.Errorf("redis: invalid %s number: %s", name, err)
}
return 0
}
func (o *queryOptions) duration(name string) time.Duration {
s := o.string(name)
if s == "" {
return 0
}
// try plain number first
if i, err := strconv.Atoi(s); err == nil {
if i <= 0 {
// disable timeouts
return -1
}
return time.Duration(i) * time.Second
}
dur, err := time.ParseDuration(s)
if err == nil {
return dur
}
if o.err == nil {
o.err = fmt.Errorf("redis: invalid %s duration: %w", name, err)
}
return 0
}
func (o *queryOptions) bool(name string) bool {
switch s := o.string(name); s {
case "true", "1":
return true
case "false", "0", "":
return false
default:
if o.err == nil {
o.err = fmt.Errorf("redis: invalid %s boolean: expected true/false/1/0 or an empty string, got %q", name, s)
}
return false
}
}
func (o *queryOptions) remaining() []string {
if len(o.q) == 0 {
return nil
}
keys := slices.Collect(maps.Keys(o.q))
slices.Sort(keys)
return keys
}
// setupConnParams converts query parameters in u to option value in o.
func setupConnParams(u *url.URL, o *Options) (*Options, error) {
q := queryOptions{q: u.Query()}
// compat: a future major release may use q.int("db")
if tmp := q.string("db"); tmp != "" {
db, err := strconv.Atoi(tmp)
if err != nil {
return nil, fmt.Errorf("redis: invalid database number: %w", err)
}
o.DB = db
}
o.Protocol = q.int("protocol")
o.ClientName = q.string("client_name")
o.MaxRetries = q.int("max_retries")
o.MinRetryBackoff = q.duration("min_retry_backoff")
o.MaxRetryBackoff = q.duration("max_retry_backoff")
o.DialTimeout = q.duration("dial_timeout")
o.ReadTimeout = q.duration("read_timeout")
o.WriteTimeout = q.duration("write_timeout")
o.PoolFIFO = q.bool("pool_fifo")
o.PoolSize = q.int("pool_size")
o.PoolTimeout = q.duration("pool_timeout")
o.MinIdleConns = q.int("min_idle_conns")
o.MaxIdleConns = q.int("max_idle_conns")
o.MaxActiveConns = q.int("max_active_conns")
o.MaxConcurrentDials = q.int("max_concurrent_dials")
if q.has("conn_max_idle_time") {
o.ConnMaxIdleTime = q.duration("conn_max_idle_time")
} else {
o.ConnMaxIdleTime = q.duration("idle_timeout")
}
if q.has("conn_max_lifetime") {
o.ConnMaxLifetime = q.duration("conn_max_lifetime")
} else {
o.ConnMaxLifetime = q.duration("max_conn_age")
}
if q.has("conn_max_lifetime_jitter") {
o.ConnMaxLifetimeJitter = min(q.duration("conn_max_lifetime_jitter"), o.ConnMaxLifetime)
}
if q.err != nil {
return nil, q.err
}
if o.TLSConfig != nil && q.has("skip_verify") {
o.TLSConfig.InsecureSkipVerify = q.bool("skip_verify")
}
// any parameters left?
if r := q.remaining(); len(r) > 0 {
return nil, fmt.Errorf("redis: unexpected option: %s", strings.Join(r, ", "))
}
return o, nil
}
func getUserPassword(u *url.URL) (string, string) {
var user, password string
if u.User != nil {
user = u.User.Username()
if p, ok := u.User.Password(); ok {
password = p
}
}
return user, password
}
func newConnPool(
opt *Options,
dialer func(ctx context.Context, network, addr string) (net.Conn, error),
poolName string,
) (*pool.ConnPool, error) {
poolSize, err := util.SafeIntToInt32(opt.PoolSize, "PoolSize")
if err != nil {
return nil, err
}
minIdleConns, err := util.SafeIntToInt32(opt.MinIdleConns, "MinIdleConns")
if err != nil {
return nil, err
}
maxIdleConns, err := util.SafeIntToInt32(opt.MaxIdleConns, "MaxIdleConns")
if err != nil {
return nil, err
}
maxActiveConns, err := util.SafeIntToInt32(opt.MaxActiveConns, "MaxActiveConns")
if err != nil {
return nil, err
}
return pool.NewConnPool(&pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
return dialer(ctx, opt.Network, opt.Addr)
},
PoolFIFO: opt.PoolFIFO,
PoolSize: poolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
MinIdleConns: minIdleConns,
MaxIdleConns: maxIdleConns,
MaxActiveConns: maxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
ReadBufferSize: opt.ReadBufferSize,
WriteBufferSize: opt.WriteBufferSize,
PushNotificationsEnabled: opt.Protocol == 3,
Name: poolName,
}), nil
}
func newPubSubPool(
opt *Options,
dialer func(ctx context.Context, network, addr string) (net.Conn, error),
poolName string,
) (*pool.PubSubPool, error) {
poolSize, err := util.SafeIntToInt32(opt.PoolSize, "PoolSize")
if err != nil {
return nil, err
}
minIdleConns, err := util.SafeIntToInt32(opt.MinIdleConns, "MinIdleConns")
if err != nil {
return nil, err
}
maxIdleConns, err := util.SafeIntToInt32(opt.MaxIdleConns, "MaxIdleConns")
if err != nil {
return nil, err
}
maxActiveConns, err := util.SafeIntToInt32(opt.MaxActiveConns, "MaxActiveConns")
if err != nil {
return nil, err
}
return pool.NewPubSubPool(&pool.Options{
PoolFIFO: opt.PoolFIFO,
PoolSize: poolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
MinIdleConns: minIdleConns,
MaxIdleConns: maxIdleConns,
MaxActiveConns: maxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
ReadBufferSize: 32 * 1024,
WriteBufferSize: 32 * 1024,
PushNotificationsEnabled: opt.Protocol == 3,
Name: poolName,
}, dialer), nil
}
+2488
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
package redis
import (
"context"
"sync"
"sync/atomic"
)
func (c *ClusterClient) DBSize(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "dbsize")
_ = c.withProcessHook(ctx, cmd, func(ctx context.Context, _ Cmder) error {
var size int64
err := c.ForEachMaster(ctx, func(ctx context.Context, master *Client) error {
n, err := master.DBSize(ctx).Result()
if err != nil {
return err
}
atomic.AddInt64(&size, n)
return nil
})
if err != nil {
cmd.SetErr(err)
} else {
cmd.val = size
}
return nil
})
return cmd
}
func (c *ClusterClient) ScriptLoad(ctx context.Context, script string) *StringCmd {
cmd := NewStringCmd(ctx, "script", "load", script)
_ = c.withProcessHook(ctx, cmd, func(ctx context.Context, _ Cmder) error {
var mu sync.Mutex
err := c.ForEachShard(ctx, func(ctx context.Context, shard *Client) error {
val, err := shard.ScriptLoad(ctx, script).Result()
if err != nil {
return err
}
mu.Lock()
if cmd.Val() == "" {
cmd.val = val
}
mu.Unlock()
return nil
})
if err != nil {
cmd.SetErr(err)
}
return nil
})
return cmd
}
func (c *ClusterClient) ScriptFlush(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "script", "flush")
_ = c.withProcessHook(ctx, cmd, func(ctx context.Context, _ Cmder) error {
err := c.ForEachShard(ctx, func(ctx context.Context, shard *Client) error {
return shard.ScriptFlush(ctx).Err()
})
if err != nil {
cmd.SetErr(err)
}
return nil
})
return cmd
}
func (c *ClusterClient) ScriptExists(ctx context.Context, hashes ...string) *BoolSliceCmd {
args := make([]interface{}, 2+len(hashes))
args[0] = "script"
args[1] = "exists"
for i, hash := range hashes {
args[2+i] = hash
}
cmd := NewBoolSliceCmd(ctx, args...)
result := make([]bool, len(hashes))
for i := range result {
result[i] = true
}
_ = c.withProcessHook(ctx, cmd, func(ctx context.Context, _ Cmder) error {
var mu sync.Mutex
err := c.ForEachShard(ctx, func(ctx context.Context, shard *Client) error {
val, err := shard.ScriptExists(ctx, hashes...).Result()
if err != nil {
return err
}
mu.Lock()
for i, v := range val {
result[i] = result[i] && v
}
mu.Unlock()
return nil
})
if err != nil {
cmd.SetErr(err)
} else {
cmd.val = result
}
return nil
})
return cmd
}
+992
View File
@@ -0,0 +1,992 @@
package redis
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"time"
"github.com/redis/go-redis/v9/internal/hashtag"
"github.com/redis/go-redis/v9/internal/routing"
)
var (
errInvalidCmdPointer = errors.New("redis: invalid command pointer")
errNoCmdsToAggregate = errors.New("redis: no commands to aggregate")
errNoResToAggregate = errors.New("redis: no results to aggregate")
errInvalidCursorCmdArgsCount = errors.New("redis: FT.CURSOR command requires at least 3 arguments")
errInvalidCursorIdType = errors.New("redis: invalid cursor ID type")
)
// slotResult represents the result of executing a command on a specific slot
type slotResult struct {
cmd Cmder
keys []string
err error
}
// routeAndRun routes a command to the appropriate cluster nodes and executes it
func (c *ClusterClient) routeAndRun(ctx context.Context, cmd Cmder, node *clusterNode) error {
var policy *routing.CommandPolicy
if c.cmdInfoResolver != nil {
policy = c.cmdInfoResolver.GetCommandPolicy(ctx, cmd)
}
// Set stepCount from cmdInfo if not already set
if cmd.stepCount() == 0 {
if cmdInfo := c.cmdInfo(ctx, cmd.Name()); cmdInfo != nil && cmdInfo.StepCount > 0 {
cmd.SetStepCount(cmdInfo.StepCount)
}
}
if policy == nil {
return c.executeDefault(ctx, cmd, policy, node)
}
switch policy.Request {
case routing.ReqAllNodes:
return c.executeOnAllNodes(ctx, cmd, policy)
case routing.ReqAllShards:
return c.executeOnAllShards(ctx, cmd, policy)
case routing.ReqMultiShard:
return c.executeMultiShard(ctx, cmd, policy)
case routing.ReqSpecial:
return c.executeSpecialCommand(ctx, cmd, policy, node)
default:
return c.executeDefault(ctx, cmd, policy, node)
}
}
// executeDefault handles standard command routing based on keys
func (c *ClusterClient) executeDefault(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy, node *clusterNode) error {
if policy != nil && !c.hasKeys(cmd) {
if c.readOnlyEnabled() && policy.IsReadOnly() {
return c.executeOnArbitraryNode(ctx, cmd)
}
}
return node.Client.Process(ctx, cmd)
}
// executeOnArbitraryNode routes command to an arbitrary node
func (c *ClusterClient) executeOnArbitraryNode(ctx context.Context, cmd Cmder) error {
node := c.pickArbitraryNode(ctx)
if node == nil {
return errClusterNoNodes
}
return node.Client.Process(ctx, cmd)
}
// executeOnAllNodes executes command on all nodes (masters and replicas)
func (c *ClusterClient) executeOnAllNodes(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy) error {
state, err := c.state.Get(ctx)
if err != nil {
return err
}
nodes := append(state.Masters, state.Slaves...)
if len(nodes) == 0 {
return errClusterNoNodes
}
return c.executeParallel(ctx, cmd, nodes, policy)
}
// executeOnAllShards executes command on all master shards
func (c *ClusterClient) executeOnAllShards(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy) error {
state, err := c.state.Get(ctx)
if err != nil {
return err
}
if len(state.Masters) == 0 {
return errClusterNoNodes
}
return c.executeParallel(ctx, cmd, state.Masters, policy)
}
// executeMultiShard handles commands that operate on multiple keys across shards
func (c *ClusterClient) executeMultiShard(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy) error {
args := cmd.Args()
firstKeyPos := int(cmdFirstKeyPos(cmd))
stepCount := int(cmd.stepCount())
if stepCount == 0 {
stepCount = 1 // Default to 1 if not set
}
if firstKeyPos == 0 || firstKeyPos >= len(args) {
return fmt.Errorf("redis: multi-shard command %s has no key arguments", cmd.Name())
}
// Group keys by slot
slotMap := make(map[int][]string)
keyOrder := make([]string, 0)
for i := firstKeyPos; i < len(args); i += stepCount {
key, ok := args[i].(string)
if !ok {
return fmt.Errorf("redis: non-string key at position %d: %v", i, args[i])
}
slot := hashtag.Slot(key)
slotMap[slot] = append(slotMap[slot], key)
for j := 1; j < stepCount; j++ {
if i+j >= len(args) {
break
}
slotMap[slot] = append(slotMap[slot], args[i+j].(string))
}
keyOrder = append(keyOrder, key)
}
return c.executeMultiSlot(ctx, cmd, slotMap, keyOrder, policy)
}
// executeMultiSlot executes commands across multiple slots concurrently
func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap map[int][]string, keyOrder []string, policy *routing.CommandPolicy) error {
results := make(chan slotResult, len(slotMap))
var wg sync.WaitGroup
// Execute on each slot concurrently
for slot, keys := range slotMap {
wg.Add(1)
go func(slot int, keys []string) {
defer wg.Done()
node, err := c.cmdNodeWithShardPicker(ctx, cmd.Name(), slot, c.opt.ShardPicker)
if err != nil {
results <- slotResult{nil, keys, err}
return
}
// Create a command for this specific slot's keys
subCmd := c.createSlotSpecificCommand(ctx, cmd, keys)
err = node.Client.Process(ctx, subCmd)
results <- slotResult{subCmd, keys, err}
}(slot, keys)
}
go func() {
wg.Wait()
close(results)
}()
return c.aggregateMultiSlotResults(ctx, cmd, results, keyOrder, policy)
}
// createSlotSpecificCommand creates a new command for a specific slot's keys
func (c *ClusterClient) createSlotSpecificCommand(ctx context.Context, originalCmd Cmder, keys []string) Cmder {
originalArgs := originalCmd.Args()
firstKeyPos := int(cmdFirstKeyPos(originalCmd))
// Build new args with only the specified keys
newArgs := make([]interface{}, 0, firstKeyPos+len(keys))
// Copy command name and arguments before the keys
newArgs = append(newArgs, originalArgs[:firstKeyPos]...)
// Add the slot-specific keys
for _, key := range keys {
newArgs = append(newArgs, key)
}
// Create a new command of the same type using the helper function
return createCommandByType(ctx, originalCmd.GetCmdType(), newArgs...)
}
// createCommandByType creates a new command of the specified type with the given arguments
func createCommandByType(ctx context.Context, cmdType CmdType, args ...interface{}) Cmder {
switch cmdType {
case CmdTypeString:
return NewStringCmd(ctx, args...)
case CmdTypeInt:
return NewIntCmd(ctx, args...)
case CmdTypeBool:
return NewBoolCmd(ctx, args...)
case CmdTypeFloat:
return NewFloatCmd(ctx, args...)
case CmdTypeStringSlice:
return NewStringSliceCmd(ctx, args...)
case CmdTypeIntSlice:
return NewIntSliceCmd(ctx, args...)
case CmdTypeFloatSlice:
return NewFloatSliceCmd(ctx, args...)
case CmdTypeBoolSlice:
return NewBoolSliceCmd(ctx, args...)
case CmdTypeStatus:
return NewStatusCmd(ctx, args...)
case CmdTypeTime:
return NewTimeCmd(ctx, args...)
case CmdTypeMapStringString:
return NewMapStringStringCmd(ctx, args...)
case CmdTypeMapStringInt:
return NewMapStringIntCmd(ctx, args...)
case CmdTypeMapStringInterface:
return NewMapStringInterfaceCmd(ctx, args...)
case CmdTypeMapStringInterfaceSlice:
return NewMapStringInterfaceSliceCmd(ctx, args...)
case CmdTypeSlice:
return NewSliceCmd(ctx, args...)
case CmdTypeStringStructMap:
return NewStringStructMapCmd(ctx, args...)
case CmdTypeXMessageSlice:
return NewXMessageSliceCmd(ctx, args...)
case CmdTypeXStreamSlice:
return NewXStreamSliceCmd(ctx, args...)
case CmdTypeXPending:
return NewXPendingCmd(ctx, args...)
case CmdTypeXPendingExt:
return NewXPendingExtCmd(ctx, args...)
case CmdTypeXAutoClaim:
return NewXAutoClaimCmd(ctx, args...)
case CmdTypeXAutoClaimJustID:
return NewXAutoClaimJustIDCmd(ctx, args...)
case CmdTypeXInfoStreamFull:
return NewXInfoStreamFullCmd(ctx, args...)
case CmdTypeZSlice:
return NewZSliceCmd(ctx, args...)
case CmdTypeZWithKey:
return NewZWithKeyCmd(ctx, args...)
case CmdTypeClusterSlots:
return NewClusterSlotsCmd(ctx, args...)
case CmdTypeGeoPos:
return NewGeoPosCmd(ctx, args...)
case CmdTypeCommandsInfo:
return NewCommandsInfoCmd(ctx, args...)
case CmdTypeSlowLog:
return NewSlowLogCmd(ctx, args...)
case CmdTypeKeyValues:
return NewKeyValuesCmd(ctx, args...)
case CmdTypeZSliceWithKey:
return NewZSliceWithKeyCmd(ctx, args...)
case CmdTypeFunctionList:
return NewFunctionListCmd(ctx, args...)
case CmdTypeFunctionStats:
return NewFunctionStatsCmd(ctx, args...)
case CmdTypeKeyFlags:
return NewKeyFlagsCmd(ctx, args...)
case CmdTypeDuration:
return NewDurationCmd(ctx, time.Millisecond, args...)
}
return NewCmd(ctx, args...)
}
// executeSpecialCommand handles commands with special routing requirements
func (c *ClusterClient) executeSpecialCommand(ctx context.Context, cmd Cmder, policy *routing.CommandPolicy, node *clusterNode) error {
switch cmd.Name() {
case "ft.cursor":
return c.executeCursorCommand(ctx, cmd)
default:
return c.executeDefault(ctx, cmd, policy, node)
}
}
// executeCursorCommand handles FT.CURSOR commands with sticky routing
func (c *ClusterClient) executeCursorCommand(ctx context.Context, cmd Cmder) error {
args := cmd.Args()
if len(args) < 4 {
return errInvalidCursorCmdArgsCount
}
cursorID, ok := args[3].(string)
if !ok {
return errInvalidCursorIdType
}
// Route based on cursor ID to maintain stickiness
slot := hashtag.Slot(cursorID)
node, err := c.cmdNodeWithShardPicker(ctx, cmd.Name(), slot, c.opt.ShardPicker)
if err != nil {
return err
}
return node.Client.Process(ctx, cmd)
}
// executeParallel executes a command on multiple nodes concurrently
func (c *ClusterClient) executeParallel(ctx context.Context, cmd Cmder, nodes []*clusterNode, policy *routing.CommandPolicy) error {
if len(nodes) == 0 {
return errClusterNoNodes
}
if len(nodes) == 1 {
return nodes[0].Client.Process(ctx, cmd)
}
type nodeResult struct {
cmd Cmder
err error
}
results := make(chan nodeResult, len(nodes))
var wg sync.WaitGroup
for _, node := range nodes {
wg.Add(1)
go func(n *clusterNode) {
defer wg.Done()
cmdCopy := cmd.Clone()
err := n.Client.Process(ctx, cmdCopy)
results <- nodeResult{cmdCopy, err}
}(node)
}
go func() {
wg.Wait()
close(results)
}()
// Collect results and check for errors
cmds := make([]Cmder, 0, len(nodes))
var firstErr error
for result := range results {
if result.err != nil && firstErr == nil {
firstErr = result.err
}
cmds = append(cmds, result.cmd)
}
// If there was an error and no policy specified, fail fast
if firstErr != nil && (policy == nil || policy.Response == routing.RespDefaultKeyless) {
cmd.SetErr(firstErr)
return firstErr
}
return c.aggregateResponses(cmd, cmds, policy)
}
// aggregateMultiSlotResults aggregates results from multi-slot execution
func (c *ClusterClient) aggregateMultiSlotResults(ctx context.Context, cmd Cmder, results <-chan slotResult, keyOrder []string, policy *routing.CommandPolicy) error {
keyedResults := make(map[string]routing.AggregatorResErr)
var firstErr error
for result := range results {
if result.err != nil && firstErr == nil {
firstErr = result.err
}
if result.cmd != nil && result.err == nil {
value, err := ExtractCommandValue(result.cmd)
// Check if the result is a slice (e.g., from MGET)
if sliceValue, ok := value.([]interface{}); ok {
// Map each element to its corresponding key
for i, key := range result.keys {
if i < len(sliceValue) {
keyedResults[key] = routing.AggregatorResErr{Result: sliceValue[i], Err: err}
} else {
keyedResults[key] = routing.AggregatorResErr{Result: nil, Err: err}
}
}
} else {
// For non-slice results, map the entire result to each key
for _, key := range result.keys {
keyedResults[key] = routing.AggregatorResErr{Result: value, Err: err}
}
}
}
// TODO: return multiple errors by order when we will implement multiple errors returning
if result.err != nil {
firstErr = result.err
}
}
return c.aggregateKeyedValues(cmd, keyedResults, keyOrder, policy)
}
// aggregateKeyedValues aggregates individual key-value pairs while preserving key order
func (c *ClusterClient) aggregateKeyedValues(cmd Cmder, keyedResults map[string]routing.AggregatorResErr, keyOrder []string, policy *routing.CommandPolicy) error {
if len(keyedResults) == 0 {
return errNoResToAggregate
}
aggregator := c.createAggregator(policy, cmd, true)
// Set key order for keyed aggregators
var keyedAgg *routing.DefaultKeyedAggregator
var isKeyedAgg bool
var err error
if keyedAgg, isKeyedAgg = aggregator.(*routing.DefaultKeyedAggregator); isKeyedAgg {
err = keyedAgg.BatchAddWithKeyOrder(keyedResults, keyOrder)
} else {
err = aggregator.BatchAdd(keyedResults)
}
if err != nil {
return err
}
return c.finishAggregation(cmd, aggregator)
}
// aggregateResponses aggregates multiple shard responses
func (c *ClusterClient) aggregateResponses(cmd Cmder, cmds []Cmder, policy *routing.CommandPolicy) error {
if len(cmds) == 0 {
return errNoCmdsToAggregate
}
if len(cmds) == 1 {
shardCmd := cmds[0]
if err := shardCmd.Err(); err != nil {
cmd.SetErr(err)
return err
}
value, _ := ExtractCommandValue(shardCmd)
return c.setCommandValue(cmd, value)
}
aggregator := c.createAggregator(policy, cmd, false)
batchWithErrs := []routing.AggregatorResErr{}
// Add all results to aggregator
for _, shardCmd := range cmds {
value, err := ExtractCommandValue(shardCmd)
batchWithErrs = append(batchWithErrs, routing.AggregatorResErr{
Result: value,
Err: err,
})
}
err := aggregator.BatchSlice(batchWithErrs)
if err != nil {
return err
}
return c.finishAggregation(cmd, aggregator)
}
// createAggregator creates the appropriate response aggregator
func (c *ClusterClient) createAggregator(policy *routing.CommandPolicy, cmd Cmder, isKeyed bool) routing.ResponseAggregator {
if policy != nil {
return routing.NewResponseAggregator(policy.Response, cmd.Name())
}
if !isKeyed {
firstKeyPos := cmdFirstKeyPos(cmd)
isKeyed = firstKeyPos > 0
}
return routing.NewDefaultAggregator(isKeyed)
}
// finishAggregation completes the aggregation process and sets the result
func (c *ClusterClient) finishAggregation(cmd Cmder, aggregator routing.ResponseAggregator) error {
finalValue, finalErr := aggregator.Result()
if finalErr != nil {
cmd.SetErr(finalErr)
return finalErr
}
return c.setCommandValue(cmd, finalValue)
}
// pickArbitraryNode selects a master or slave shard using the configured ShardPicker
func (c *ClusterClient) pickArbitraryNode(ctx context.Context) *clusterNode {
state, err := c.state.Get(ctx)
if err != nil || len(state.Masters) == 0 {
return nil
}
allNodes := append(state.Masters, state.Slaves...)
idx := c.opt.ShardPicker.Next(len(allNodes))
return allNodes[idx]
}
// hasKeys checks if a command operates on keys
func (c *ClusterClient) hasKeys(cmd Cmder) bool {
firstKeyPos := cmdFirstKeyPos(cmd)
return firstKeyPos > 0
}
func (c *ClusterClient) readOnlyEnabled() bool {
return c.opt.ReadOnly
}
// setCommandValue sets the aggregated value on a command using the enum-based approach
func (c *ClusterClient) setCommandValue(cmd Cmder, value interface{}) error {
// If value is nil, it might mean ExtractCommandValue couldn't extract the value
// but the command might have executed successfully. In this case, don't set an error.
if value == nil {
// ExtractCommandValue returned nil - this means the command type is not supported
// in the aggregation flow. This is a programming error, not a runtime error.
if cmd.Err() != nil {
// Command already has an error, preserve it
return cmd.Err()
}
// Command executed successfully but we can't extract/set the aggregated value
// This indicates the command type needs to be added to ExtractCommandValue
return fmt.Errorf("redis: cannot aggregate command %s: unsupported command type %d",
cmd.Name(), cmd.GetCmdType())
}
switch cmd.GetCmdType() {
case CmdTypeGeneric:
if c, ok := cmd.(*Cmd); ok {
c.SetVal(value)
}
case CmdTypeString:
if c, ok := cmd.(*StringCmd); ok {
if v, ok := value.(string); ok {
c.SetVal(v)
}
}
case CmdTypeInt:
if c, ok := cmd.(*IntCmd); ok {
if v, ok := value.(int64); ok {
c.SetVal(v)
} else if v, ok := value.(float64); ok {
c.SetVal(int64(v))
}
}
case CmdTypeBool:
if c, ok := cmd.(*BoolCmd); ok {
if v, ok := value.(bool); ok {
c.SetVal(v)
}
}
case CmdTypeFloat:
if c, ok := cmd.(*FloatCmd); ok {
if v, ok := value.(float64); ok {
c.SetVal(v)
}
}
case CmdTypeStringSlice:
if c, ok := cmd.(*StringSliceCmd); ok {
if v, ok := value.([]string); ok {
c.SetVal(v)
}
}
case CmdTypeIntSlice:
if c, ok := cmd.(*IntSliceCmd); ok {
if v, ok := value.([]int64); ok {
c.SetVal(v)
} else if v, ok := value.([]float64); ok {
els := len(v)
intSlc := make([]int, els)
for i := range v {
intSlc[i] = int(v[i])
}
}
}
case CmdTypeFloatSlice:
if c, ok := cmd.(*FloatSliceCmd); ok {
if v, ok := value.([]float64); ok {
c.SetVal(v)
}
}
case CmdTypeBoolSlice:
if c, ok := cmd.(*BoolSliceCmd); ok {
if v, ok := value.([]bool); ok {
c.SetVal(v)
}
}
case CmdTypeMapStringString:
if c, ok := cmd.(*MapStringStringCmd); ok {
if v, ok := value.(map[string]string); ok {
c.SetVal(v)
}
}
case CmdTypeMapStringInt:
if c, ok := cmd.(*MapStringIntCmd); ok {
if v, ok := value.(map[string]int64); ok {
c.SetVal(v)
}
}
case CmdTypeMapStringInterface:
if c, ok := cmd.(*MapStringInterfaceCmd); ok {
if v, ok := value.(map[string]interface{}); ok {
c.SetVal(v)
}
}
case CmdTypeSlice:
if c, ok := cmd.(*SliceCmd); ok {
if v, ok := value.([]interface{}); ok {
c.SetVal(v)
}
}
case CmdTypeStatus:
if c, ok := cmd.(*StatusCmd); ok {
if v, ok := value.(string); ok {
c.SetVal(v)
}
}
case CmdTypeDuration:
if c, ok := cmd.(*DurationCmd); ok {
if v, ok := value.(time.Duration); ok {
c.SetVal(v)
}
}
case CmdTypeTime:
if c, ok := cmd.(*TimeCmd); ok {
if v, ok := value.(time.Time); ok {
c.SetVal(v)
}
}
case CmdTypeKeyValueSlice:
if c, ok := cmd.(*KeyValueSliceCmd); ok {
if v, ok := value.([]KeyValue); ok {
c.SetVal(v)
}
}
case CmdTypeStringStructMap:
if c, ok := cmd.(*StringStructMapCmd); ok {
if v, ok := value.(map[string]struct{}); ok {
c.SetVal(v)
}
}
case CmdTypeXMessageSlice:
if c, ok := cmd.(*XMessageSliceCmd); ok {
if v, ok := value.([]XMessage); ok {
c.SetVal(v)
}
}
case CmdTypeXStreamSlice:
if c, ok := cmd.(*XStreamSliceCmd); ok {
if v, ok := value.([]XStream); ok {
c.SetVal(v)
}
}
case CmdTypeXPending:
if c, ok := cmd.(*XPendingCmd); ok {
if v, ok := value.(*XPending); ok {
c.SetVal(v)
}
}
case CmdTypeXPendingExt:
if c, ok := cmd.(*XPendingExtCmd); ok {
if v, ok := value.([]XPendingExt); ok {
c.SetVal(v)
}
}
case CmdTypeXAutoClaim:
if c, ok := cmd.(*XAutoClaimCmd); ok {
if v, ok := value.(CmdTypeXAutoClaimValue); ok {
c.SetVal(v.messages, v.start)
}
}
case CmdTypeXAutoClaimJustID:
if c, ok := cmd.(*XAutoClaimJustIDCmd); ok {
if v, ok := value.(CmdTypeXAutoClaimJustIDValue); ok {
c.SetVal(v.ids, v.start)
}
}
case CmdTypeXInfoConsumers:
if c, ok := cmd.(*XInfoConsumersCmd); ok {
if v, ok := value.([]XInfoConsumer); ok {
c.SetVal(v)
}
}
case CmdTypeXInfoGroups:
if c, ok := cmd.(*XInfoGroupsCmd); ok {
if v, ok := value.([]XInfoGroup); ok {
c.SetVal(v)
}
}
case CmdTypeXInfoStream:
if c, ok := cmd.(*XInfoStreamCmd); ok {
if v, ok := value.(*XInfoStream); ok {
c.SetVal(v)
}
}
case CmdTypeXInfoStreamFull:
if c, ok := cmd.(*XInfoStreamFullCmd); ok {
if v, ok := value.(*XInfoStreamFull); ok {
c.SetVal(v)
}
}
case CmdTypeZSlice:
if c, ok := cmd.(*ZSliceCmd); ok {
if v, ok := value.([]Z); ok {
c.SetVal(v)
}
}
case CmdTypeZWithKey:
if c, ok := cmd.(*ZWithKeyCmd); ok {
if v, ok := value.(*ZWithKey); ok {
c.SetVal(v)
}
}
case CmdTypeScan:
if c, ok := cmd.(*ScanCmd); ok {
if v, ok := value.(CmdTypeScanValue); ok {
c.SetVal(v.keys, v.cursor)
}
}
case CmdTypeClusterSlots:
if c, ok := cmd.(*ClusterSlotsCmd); ok {
if v, ok := value.([]ClusterSlot); ok {
c.SetVal(v)
}
}
case CmdTypeGeoLocation:
if c, ok := cmd.(*GeoLocationCmd); ok {
if v, ok := value.([]GeoLocation); ok {
c.SetVal(v)
}
}
case CmdTypeGeoSearchLocation:
if c, ok := cmd.(*GeoSearchLocationCmd); ok {
if v, ok := value.([]GeoLocation); ok {
c.SetVal(v)
}
}
case CmdTypeGeoPos:
if c, ok := cmd.(*GeoPosCmd); ok {
if v, ok := value.([]*GeoPos); ok {
c.SetVal(v)
}
}
case CmdTypeCommandsInfo:
if c, ok := cmd.(*CommandsInfoCmd); ok {
if v, ok := value.(map[string]*CommandInfo); ok {
c.SetVal(v)
}
}
case CmdTypeSlowLog:
if c, ok := cmd.(*SlowLogCmd); ok {
if v, ok := value.([]SlowLog); ok {
c.SetVal(v)
}
}
case CmdTypeMapStringStringSlice:
if c, ok := cmd.(*MapStringStringSliceCmd); ok {
if v, ok := value.([]map[string]string); ok {
c.SetVal(v)
}
}
case CmdTypeMapMapStringInterface:
if c, ok := cmd.(*MapMapStringInterfaceCmd); ok {
if v, ok := value.(map[string]interface{}); ok {
c.SetVal(v)
}
}
case CmdTypeMapStringInterfaceSlice:
if c, ok := cmd.(*MapStringInterfaceSliceCmd); ok {
if v, ok := value.([]map[string]interface{}); ok {
c.SetVal(v)
}
}
case CmdTypeKeyValues:
if c, ok := cmd.(*KeyValuesCmd); ok {
// KeyValuesCmd needs a key string and values slice
if v, ok := value.(CmdTypeKeyValuesValue); ok {
c.SetVal(v.key, v.values)
}
}
case CmdTypeZSliceWithKey:
if c, ok := cmd.(*ZSliceWithKeyCmd); ok {
// ZSliceWithKeyCmd needs a key string and Z slice
if v, ok := value.(CmdTypeZSliceWithKeyValue); ok {
c.SetVal(v.key, v.zSlice)
}
}
case CmdTypeFunctionList:
if c, ok := cmd.(*FunctionListCmd); ok {
if v, ok := value.([]Library); ok {
c.SetVal(v)
}
}
case CmdTypeFunctionStats:
if c, ok := cmd.(*FunctionStatsCmd); ok {
if v, ok := value.(FunctionStats); ok {
c.SetVal(v)
}
}
case CmdTypeLCS:
if c, ok := cmd.(*LCSCmd); ok {
if v, ok := value.(*LCSMatch); ok {
c.SetVal(v)
}
}
case CmdTypeKeyFlags:
if c, ok := cmd.(*KeyFlagsCmd); ok {
if v, ok := value.([]KeyFlags); ok {
c.SetVal(v)
}
}
case CmdTypeClusterLinks:
if c, ok := cmd.(*ClusterLinksCmd); ok {
if v, ok := value.([]ClusterLink); ok {
c.SetVal(v)
}
}
case CmdTypeClusterShards:
if c, ok := cmd.(*ClusterShardsCmd); ok {
if v, ok := value.([]ClusterShard); ok {
c.SetVal(v)
}
}
case CmdTypeRankWithScore:
if c, ok := cmd.(*RankWithScoreCmd); ok {
if v, ok := value.(RankScore); ok {
c.SetVal(v)
}
}
case CmdTypeClientInfo:
if c, ok := cmd.(*ClientInfoCmd); ok {
if v, ok := value.(*ClientInfo); ok {
c.SetVal(v)
}
}
case CmdTypeACLLog:
if c, ok := cmd.(*ACLLogCmd); ok {
if v, ok := value.([]*ACLLogEntry); ok {
c.SetVal(v)
}
}
case CmdTypeInfo:
if c, ok := cmd.(*InfoCmd); ok {
if v, ok := value.(map[string]map[string]string); ok {
c.SetVal(v)
}
}
case CmdTypeMonitor:
// MonitorCmd doesn't have SetVal method
// Skip setting value for MonitorCmd
case CmdTypeJSON:
if c, ok := cmd.(*JSONCmd); ok {
if v, ok := value.(string); ok {
c.SetVal(v)
}
}
case CmdTypeJSONSlice:
if c, ok := cmd.(*JSONSliceCmd); ok {
if v, ok := value.([]interface{}); ok {
c.SetVal(v)
}
}
case CmdTypeIntPointerSlice:
if c, ok := cmd.(*IntPointerSliceCmd); ok {
if v, ok := value.([]*int64); ok {
c.SetVal(v)
}
}
case CmdTypeScanDump:
if c, ok := cmd.(*ScanDumpCmd); ok {
if v, ok := value.(ScanDump); ok {
c.SetVal(v)
}
}
case CmdTypeBFInfo:
if c, ok := cmd.(*BFInfoCmd); ok {
if v, ok := value.(BFInfo); ok {
c.SetVal(v)
}
}
case CmdTypeCFInfo:
if c, ok := cmd.(*CFInfoCmd); ok {
if v, ok := value.(CFInfo); ok {
c.SetVal(v)
}
}
case CmdTypeCMSInfo:
if c, ok := cmd.(*CMSInfoCmd); ok {
if v, ok := value.(CMSInfo); ok {
c.SetVal(v)
}
}
case CmdTypeTopKInfo:
if c, ok := cmd.(*TopKInfoCmd); ok {
if v, ok := value.(TopKInfo); ok {
c.SetVal(v)
}
}
case CmdTypeTDigestInfo:
if c, ok := cmd.(*TDigestInfoCmd); ok {
if v, ok := value.(TDigestInfo); ok {
c.SetVal(v)
}
}
case CmdTypeFTSynDump:
if c, ok := cmd.(*FTSynDumpCmd); ok {
if v, ok := value.([]FTSynDumpResult); ok {
c.SetVal(v)
}
}
case CmdTypeAggregate:
if c, ok := cmd.(*AggregateCmd); ok {
if v, ok := value.(*FTAggregateResult); ok {
c.SetVal(v)
}
}
case CmdTypeFTInfo:
if c, ok := cmd.(*FTInfoCmd); ok {
if v, ok := value.(FTInfoResult); ok {
c.SetVal(v)
}
}
case CmdTypeFTSpellCheck:
if c, ok := cmd.(*FTSpellCheckCmd); ok {
if v, ok := value.([]SpellCheckResult); ok {
c.SetVal(v)
}
}
case CmdTypeFTSearch:
if c, ok := cmd.(*FTSearchCmd); ok {
if v, ok := value.(FTSearchResult); ok {
c.SetVal(v)
}
}
case CmdTypeTSTimestampValue:
if c, ok := cmd.(*TSTimestampValueCmd); ok {
if v, ok := value.(TSTimestampValue); ok {
c.SetVal(v)
}
}
case CmdTypeTSTimestampValueSlice:
if c, ok := cmd.(*TSTimestampValueSliceCmd); ok {
if v, ok := value.([]TSTimestampValue); ok {
c.SetVal(v)
}
}
default:
// Fallback to reflection for unknown types
return c.setCommandValueReflection(cmd, value)
}
return nil
}
// setCommandValueReflection is a fallback function that uses reflection
func (c *ClusterClient) setCommandValueReflection(cmd Cmder, value interface{}) error {
cmdValue := reflect.ValueOf(cmd)
if cmdValue.Kind() != reflect.Ptr || cmdValue.IsNil() {
return errInvalidCmdPointer
}
setValMethod := cmdValue.MethodByName("SetVal")
if !setValMethod.IsValid() {
return fmt.Errorf("redis: command %T does not have SetVal method", cmd)
}
args := []reflect.Value{reflect.ValueOf(value)}
switch cmd.(type) {
case *XAutoClaimCmd, *XAutoClaimJustIDCmd:
args = append(args, reflect.ValueOf(""))
case *ScanCmd:
args = append(args, reflect.ValueOf(uint64(0)))
case *KeyValuesCmd, *ZSliceWithKeyCmd:
if key, ok := value.(string); ok {
args = []reflect.Value{reflect.ValueOf(key)}
if _, ok := cmd.(*ZSliceWithKeyCmd); ok {
args = append(args, reflect.ValueOf([]Z{}))
} else {
args = append(args, reflect.ValueOf([]string{}))
}
}
}
defer func() {
if r := recover(); r != nil {
cmd.SetErr(fmt.Errorf("redis: failed to set command value: %v", r))
}
}()
setValMethod.Call(args)
return nil
}
+235
View File
@@ -0,0 +1,235 @@
package redis
import (
"context"
"net"
"time"
"github.com/redis/go-redis/v9/internal/otel"
"github.com/redis/go-redis/v9/internal/pool"
)
// ConnInfo provides information about a Redis connection for metrics.
type ConnInfo interface {
RemoteAddr() net.Addr
PoolName() string
}
type Pooler interface {
PoolStats() *pool.Stats
}
type PubSubPooler interface {
Stats() *pool.PubSubStats
}
// OTelRecorder is the interface for recording OpenTelemetry metrics.
type OTelRecorder interface {
// RecordOperationDuration records the total operation duration (including all retries)
RecordOperationDuration(ctx context.Context, duration time.Duration, cmd Cmder, attempts int, err error, cn ConnInfo, dbIndex int)
// RecordPipelineOperationDuration records the total pipeline/transaction duration.
// operationName should be "PIPELINE" for regular pipelines or "MULTI" for transactions.
RecordPipelineOperationDuration(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn ConnInfo, dbIndex int)
// RecordConnectionCreateTime records the time it took to create a new connection
RecordConnectionCreateTime(ctx context.Context, duration time.Duration, cn ConnInfo)
// RecordConnectionRelaxedTimeout records when connection timeout is relaxed/unrelaxed
// delta: +1 for relaxed, -1 for unrelaxed
// poolName: name of the connection pool (e.g., "main", "pubsub")
// notificationType: the notification type that triggered the timeout relaxation (e.g., "MOVING", "HANDOFF")
RecordConnectionRelaxedTimeout(ctx context.Context, delta int, cn ConnInfo, poolName, notificationType string)
// RecordConnectionHandoff records when a connection is handed off to another node
// poolName: name of the connection pool (e.g., "main", "pubsub")
RecordConnectionHandoff(ctx context.Context, cn ConnInfo, poolName string)
// RecordError records client errors (ASK, MOVED, handshake failures, etc.)
// errorType: type of error (e.g., "ASK", "MOVED", "HANDSHAKE_FAILED")
// statusCode: Redis response status code if available (e.g., "MOVED", "ASK")
// isInternal: whether this is an internal error
// retryAttempts: number of retry attempts made
RecordError(ctx context.Context, errorType string, cn ConnInfo, statusCode string, isInternal bool, retryAttempts int)
// RecordMaintenanceNotification records when a maintenance notification is received
// notificationType: the type of notification (e.g., "MOVING", "MIGRATING", etc.)
RecordMaintenanceNotification(ctx context.Context, cn ConnInfo, notificationType string)
// RecordConnectionWaitTime records the time spent waiting for a connection from the pool
RecordConnectionWaitTime(ctx context.Context, duration time.Duration, cn ConnInfo)
// RecordConnectionClosed records when a connection is closed
// reason: reason for closing (e.g., "idle", "max_lifetime", "error", "pool_closed")
// err: the error that caused the close (nil for non-error closures)
RecordConnectionClosed(ctx context.Context, cn ConnInfo, reason string, err error)
// RecordPubSubMessage records a Pub/Sub message
// direction: "sent" or "received"
// channel: channel name (may be hidden for cardinality reduction)
// sharded: true for sharded pub/sub (SPUBLISH/SSUBSCRIBE)
RecordPubSubMessage(ctx context.Context, cn ConnInfo, direction, channel string, sharded bool)
// RecordStreamLag records the lag for stream consumer group processing
// lag: time difference between message creation and consumption
// streamName: name of the stream (may be hidden for cardinality reduction)
// consumerGroup: name of the consumer group
// consumerName: name of the consumer
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.
// poolName: unique identifier for the pool (e.g., "main_abc123")
RegisterPool(poolName string, pool Pooler)
// UnregisterPool is called when a client is closed to remove its pool from the registry.
UnregisterPool(pool Pooler)
// RegisterPubSubPool is called when a new client is created with a PubSub pool.
// poolName: unique identifier for the pool (e.g., "main_abc123_pubsub")
RegisterPubSubPool(poolName string, pool PubSubPooler)
// UnregisterPubSubPool is called when a PubSub client is closed to remove its pool.
UnregisterPubSubPool(pool PubSubPooler)
}
// SetOTelRecorder sets the global OpenTelemetry recorder.
func SetOTelRecorder(r OTelRecorder) {
if r == nil {
otel.SetGlobalRecorder(nil)
return
}
otel.SetGlobalRecorder(&otelRecorderAdapter{r})
}
type otelRecorderAdapter struct {
recorder OTelRecorder
}
// toConnInfo converts *pool.Conn to ConnInfo interface properly.
// This ensures that a nil *pool.Conn becomes a true nil interface,
// not a non-nil interface containing a nil pointer.
func toConnInfo(cn *pool.Conn) ConnInfo {
if cn == nil {
return nil
}
return cn
}
func (a *otelRecorderAdapter) RecordOperationDuration(ctx context.Context, duration time.Duration, cmd otel.Cmder, attempts int, err error, cn *pool.Conn, dbIndex int) {
// Convert internal Cmder to public Cmder
if publicCmd, ok := cmd.(Cmder); ok {
a.recorder.RecordOperationDuration(ctx, duration, publicCmd, attempts, err, toConnInfo(cn), dbIndex)
}
}
func (a *otelRecorderAdapter) RecordPipelineOperationDuration(ctx context.Context, duration time.Duration, operationName string, cmdCount int, attempts int, err error, cn *pool.Conn, dbIndex int) {
a.recorder.RecordPipelineOperationDuration(ctx, duration, operationName, cmdCount, attempts, err, toConnInfo(cn), dbIndex)
}
func (a *otelRecorderAdapter) RecordConnectionCreateTime(ctx context.Context, duration time.Duration, cn *pool.Conn) {
a.recorder.RecordConnectionCreateTime(ctx, duration, toConnInfo(cn))
}
func (a *otelRecorderAdapter) RecordConnectionRelaxedTimeout(ctx context.Context, delta int, cn *pool.Conn, poolName, notificationType string) {
a.recorder.RecordConnectionRelaxedTimeout(ctx, delta, toConnInfo(cn), poolName, notificationType)
}
func (a *otelRecorderAdapter) RecordConnectionHandoff(ctx context.Context, cn *pool.Conn, poolName string) {
a.recorder.RecordConnectionHandoff(ctx, toConnInfo(cn), poolName)
}
func (a *otelRecorderAdapter) RecordError(ctx context.Context, errorType string, cn *pool.Conn, statusCode string, isInternal bool, retryAttempts int) {
a.recorder.RecordError(ctx, errorType, toConnInfo(cn), statusCode, isInternal, retryAttempts)
}
func (a *otelRecorderAdapter) RecordMaintenanceNotification(ctx context.Context, cn *pool.Conn, notificationType string) {
a.recorder.RecordMaintenanceNotification(ctx, toConnInfo(cn), notificationType)
}
func (a *otelRecorderAdapter) RecordConnectionWaitTime(ctx context.Context, duration time.Duration, cn *pool.Conn) {
a.recorder.RecordConnectionWaitTime(ctx, duration, toConnInfo(cn))
}
func (a *otelRecorderAdapter) RecordConnectionClosed(ctx context.Context, cn *pool.Conn, reason string, err error) {
a.recorder.RecordConnectionClosed(ctx, toConnInfo(cn), reason, err)
}
func (a *otelRecorderAdapter) RecordPubSubMessage(ctx context.Context, cn *pool.Conn, direction, channel string, sharded bool) {
a.recorder.RecordPubSubMessage(ctx, toConnInfo(cn), direction, channel, sharded)
}
func (a *otelRecorderAdapter) RecordStreamLag(ctx context.Context, lag time.Duration, cn *pool.Conn, streamName, consumerGroup, consumerName string) {
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})
}
}
func (a *otelRecorderAdapter) UnregisterPool(p pool.Pooler) {
if registrar, ok := a.recorder.(OTelPoolRegistrar); ok {
registrar.UnregisterPool(&poolerAdapter{p})
}
}
func (a *otelRecorderAdapter) RegisterPubSubPool(poolName string, p otel.PubSubPooler) {
if registrar, ok := a.recorder.(OTelPoolRegistrar); ok {
registrar.RegisterPubSubPool(poolName, &pubSubPoolerAdapter{p})
}
}
func (a *otelRecorderAdapter) UnregisterPubSubPool(p otel.PubSubPooler) {
if registrar, ok := a.recorder.(OTelPoolRegistrar); ok {
registrar.UnregisterPubSubPool(&pubSubPoolerAdapter{p})
}
}
type poolerAdapter struct {
p pool.Pooler
}
func (a *poolerAdapter) PoolStats() *pool.Stats {
return a.p.Stats()
}
type pubSubPoolerAdapter struct {
p otel.PubSubPooler
}
func (a *pubSubPoolerAdapter) Stats() *pool.PubSubStats {
return a.p.Stats()
}
+136
View File
@@ -0,0 +1,136 @@
package redis
import (
"context"
"errors"
)
type pipelineExecer func(context.Context, []Cmder) error
// Pipeliner is a mechanism to realise Redis Pipeline technique.
//
// Pipelining is a technique to extremely speed up processing by packing
// operations to batches, send them at once to Redis and read a replies in a
// single step.
// See https://redis.io/topics/pipelining
//
// Pay attention, that Pipeline is not a transaction, so you can get unexpected
// results in case of big pipelines and small read/write timeouts.
// Redis client has retransmission logic in case of timeouts, pipeline
// can be retransmitted and commands can be executed more then once.
// To avoid this: it is good idea to use reasonable bigger read/write timeouts
// depends of your batch size and/or use TxPipeline.
type Pipeliner interface {
StatefulCmdable
// Len obtains the number of commands in the pipeline that have not yet been executed.
Len() int
// Do is an API for executing any command.
// If a certain Redis command is not yet supported, you can use Do to execute it.
Do(ctx context.Context, args ...interface{}) *Cmd
// Process queues the cmd for later execution.
Process(ctx context.Context, cmd Cmder) error
// BatchProcess adds multiple commands to be executed into the pipeline buffer.
BatchProcess(ctx context.Context, cmd ...Cmder) error
// Discard discards all commands in the pipeline buffer that have not yet been executed.
Discard()
// Exec sends all the commands buffered in the pipeline to the redis server.
Exec(ctx context.Context) ([]Cmder, error)
// Cmds returns the list of queued commands.
Cmds() []Cmder
}
var _ Pipeliner = (*Pipeline)(nil)
// Pipeline implements pipelining as described in
// 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
statefulCmdable
exec pipelineExecer
cmds []Cmder
}
func (c *Pipeline) init() {
c.cmdable = c.Process
c.statefulCmdable = c.Process
}
// Len returns the number of queued commands.
func (c *Pipeline) Len() int {
return len(c.cmds)
}
// Do queues the custom command for later execution.
func (c *Pipeline) Do(ctx context.Context, args ...interface{}) *Cmd {
cmd := NewCmd(ctx, args...)
if len(args) == 0 {
cmd.SetErr(errors.New("redis: please enter the command to be executed"))
return cmd
}
_ = c.Process(ctx, cmd)
return cmd
}
// Process queues the cmd for later execution.
func (c *Pipeline) Process(ctx context.Context, cmd Cmder) error {
return c.BatchProcess(ctx, cmd)
}
// BatchProcess queues multiple cmds for later execution.
func (c *Pipeline) BatchProcess(ctx context.Context, cmd ...Cmder) error {
c.cmds = append(c.cmds, cmd...)
return nil
}
// Discard resets the pipeline and discards queued commands.
func (c *Pipeline) Discard() {
c.cmds = c.cmds[:0]
}
// Exec executes all previously queued commands using one
// client-server roundtrip.
//
// Exec always returns list of commands and error of the first failed
// command if any.
func (c *Pipeline) Exec(ctx context.Context) ([]Cmder, error) {
if len(c.cmds) == 0 {
return nil, nil
}
cmds := c.cmds
c.cmds = nil
return cmds, c.exec(ctx, cmds)
}
func (c *Pipeline) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
if err := fn(c); err != nil {
return nil, err
}
return c.Exec(ctx)
}
func (c *Pipeline) Pipeline() Pipeliner {
return c
}
func (c *Pipeline) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipelined(ctx, fn)
}
func (c *Pipeline) TxPipeline() Pipeliner {
return c
}
func (c *Pipeline) Cmds() []Cmder {
return c.cmds
}
File diff suppressed because it is too large Load Diff
+817
View File
@@ -0,0 +1,817 @@
package redis
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/otel"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/push"
)
// PubSub implements Pub/Sub commands as described in
// 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
// to the channels in case of network errors.
type PubSub struct {
opt *Options
newConn func(ctx context.Context, addr string, channels []string) (*pool.Conn, error)
closeConn func(*pool.Conn) error
mu sync.Mutex
cn *pool.Conn
channels map[string]struct{}
patterns map[string]struct{}
schannels map[string]struct{}
closed bool
exit chan struct{}
cmd *Cmd
chOnce sync.Once
msgCh *channel
allCh *channel
// Push notification processor for handling generic push notifications
pushProcessor push.NotificationProcessor
// Cleanup callback for maintenanceNotifications upgrade tracking
onClose func()
}
func (c *PubSub) init() {
c.exit = make(chan struct{})
}
func (c *PubSub) String() string {
c.mu.Lock()
defer c.mu.Unlock()
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, ", "))
}
func (c *PubSub) connWithLock(ctx context.Context) (*pool.Conn, error) {
c.mu.Lock()
cn, err := c.conn(ctx, nil)
c.mu.Unlock()
return cn, err
}
func (c *PubSub) conn(ctx context.Context, newChannels []string) (*pool.Conn, error) {
if c.closed {
return nil, pool.ErrClosed
}
if c.cn != nil {
return c.cn, nil
}
if c.opt.Addr == "" {
// TODO(maintenanceNotifications):
// this is probably cluster client
// c.newConn will ignore the addr argument
// will be changed when we have maintenanceNotifications upgrades for cluster clients
c.opt.Addr = internal.RedisNull
}
channels := slices.Collect(maps.Keys(c.channels))
channels = append(channels, newChannels...)
cn, err := c.newConn(ctx, c.opt.Addr, channels)
if err != nil {
return nil, err
}
if err := c.resubscribe(ctx, cn); err != nil {
_ = c.closeConn(cn)
return nil, err
}
c.cn = cn
return cn, nil
}
func (c *PubSub) writeCmd(ctx context.Context, cn *pool.Conn, cmd Cmder) error {
return cn.WithWriter(ctx, c.opt.WriteTimeout, func(wr *proto.Writer) error {
return writeCmd(wr, cmd)
})
}
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", slices.Collect(maps.Keys(c.channels)))
}
if len(c.patterns) > 0 {
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", slices.Collect(maps.Keys(c.schannels)))
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (c *PubSub) _subscribe(
ctx context.Context, cn *pool.Conn, redisCmd string, channels []string,
) error {
args := make([]interface{}, 0, 1+len(channels))
args = append(args, redisCmd)
for _, channel := range channels {
args = append(args, channel)
}
cmd := NewSliceCmd(ctx, args...)
return c.writeCmd(ctx, cn, cmd)
}
func (c *PubSub) releaseConnWithLock(
ctx context.Context,
cn *pool.Conn,
err error,
allowTimeout bool,
) {
c.mu.Lock()
c.releaseConn(ctx, cn, err, allowTimeout)
c.mu.Unlock()
}
func (c *PubSub) releaseConn(ctx context.Context, cn *pool.Conn, err error, allowTimeout bool) {
if c.cn != cn {
return
}
if !cn.IsUsable() || cn.ShouldHandoff() {
c.reconnect(ctx, fmt.Errorf("pubsub: connection is not usable"))
}
if isBadConn(err, allowTimeout, c.opt.Addr) {
c.reconnect(ctx, err)
}
}
func (c *PubSub) reconnect(ctx context.Context, reason error) {
if c.cn != nil && c.cn.ShouldHandoff() {
newEndpoint := c.cn.GetHandoffEndpoint()
// If new endpoint is NULL, use the original address
if newEndpoint == internal.RedisNull {
newEndpoint = c.opt.Addr
}
if newEndpoint != "" {
// Update the address in the options
oldAddr := c.cn.RemoteAddr().String()
c.opt.Addr = newEndpoint
internal.Logger.Printf(ctx, "pubsub: reconnecting to new endpoint %s (was %s)", newEndpoint, oldAddr)
}
}
_ = c.closeTheCn(reason)
_, _ = c.conn(ctx, nil)
}
func (c *PubSub) closeTheCn(reason error) error {
if c.cn == nil {
return nil
}
err := c.closeConn(c.cn)
c.cn = nil
return err
}
func (c *PubSub) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return pool.ErrClosed
}
c.closed = true
close(c.exit)
// Call cleanup callback if set
if c.onClose != nil {
c.onClose()
}
return c.closeTheCn(pool.ErrClosed)
}
// Subscribe the client to the specified channels. It returns
// empty subscription if there are no channels.
func (c *PubSub) Subscribe(ctx context.Context, channels ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
err := c.subscribe(ctx, "subscribe", channels...)
if c.channels == nil {
c.channels = make(map[string]struct{})
}
for _, s := range channels {
c.channels[s] = struct{}{}
}
return err
}
// PSubscribe the client to the given patterns. It returns
// empty subscription if there are no patterns.
func (c *PubSub) PSubscribe(ctx context.Context, patterns ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
err := c.subscribe(ctx, "psubscribe", patterns...)
if c.patterns == nil {
c.patterns = make(map[string]struct{})
}
for _, s := range patterns {
c.patterns[s] = struct{}{}
}
return err
}
// SSubscribe Subscribes the client to the specified shard channels.
func (c *PubSub) SSubscribe(ctx context.Context, channels ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
err := c.subscribe(ctx, "ssubscribe", channels...)
if c.schannels == nil {
c.schannels = make(map[string]struct{})
}
for _, s := range channels {
c.schannels[s] = struct{}{}
}
return err
}
// Unsubscribe the client from the given channels, or from all of
// them if none is given.
func (c *PubSub) Unsubscribe(ctx context.Context, channels ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
if len(channels) > 0 {
for _, channel := range channels {
delete(c.channels, channel)
}
} else {
// Unsubscribe from all channels.
clear(c.channels)
}
err := c.subscribe(ctx, "unsubscribe", channels...)
return err
}
// PUnsubscribe the client from the given patterns, or from all of
// them if none is given.
func (c *PubSub) PUnsubscribe(ctx context.Context, patterns ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
if len(patterns) > 0 {
for _, pattern := range patterns {
delete(c.patterns, pattern)
}
} else {
// Unsubscribe from all patterns.
clear(c.patterns)
}
err := c.subscribe(ctx, "punsubscribe", patterns...)
return err
}
// SUnsubscribe unsubscribes the client from the given shard channels,
// or from all of them if none is given.
func (c *PubSub) SUnsubscribe(ctx context.Context, channels ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
if len(channels) > 0 {
for _, channel := range channels {
delete(c.schannels, channel)
}
} else {
// Unsubscribe from all channels.
clear(c.schannels)
}
err := c.subscribe(ctx, "sunsubscribe", channels...)
return err
}
func (c *PubSub) subscribe(ctx context.Context, redisCmd string, channels ...string) error {
cn, err := c.conn(ctx, channels)
if err != nil {
return err
}
err = c._subscribe(ctx, cn, redisCmd, channels)
c.releaseConn(ctx, cn, err, false)
return err
}
func (c *PubSub) Ping(ctx context.Context, payload ...string) error {
args := []interface{}{"ping"}
if len(payload) == 1 {
args = append(args, payload[0])
}
cmd := NewCmd(ctx, args...)
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
}
// 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".
Kind string
// Channel name we have subscribed to.
Channel string
// Number of channels we are currently subscribed to.
Count int
}
func (m *Subscription) String() string {
return fmt.Sprintf("%s: %s", m.Kind, m.Channel)
}
// Message received as result of a PUBLISH command issued by another client.
type Message struct {
Channel string
Pattern string
Payload string
PayloadSlice []string
}
func (m *Message) String() string {
return fmt.Sprintf("Message<%s: %s>", m.Channel, m.Payload)
}
// Pong received as result of a PING command issued by another client.
type Pong struct {
Payload string
}
func (p *Pong) String() string {
if p.Payload != "" {
return fmt.Sprintf("Pong<%s>", p.Payload)
}
return "Pong"
}
func (c *PubSub) newMessage(ctx context.Context, cn *pool.Conn, reply interface{}) (interface{}, error) {
switch reply := reply.(type) {
case string:
return &Pong{
Payload: reply,
}, nil
case []interface{}:
switch kind := reply[0].(string); kind {
case "subscribe", "unsubscribe", "psubscribe", "punsubscribe", "ssubscribe", "sunsubscribe":
// Can be nil in case of "unsubscribe".
channel, _ := reply[1].(string)
return &Subscription{
Kind: kind,
Channel: channel,
Count: int(reply[2].(int64)),
}, nil
case "message", "smessage":
channel := reply[1].(string)
sharded := kind == "smessage"
switch payload := reply[2].(type) {
case string:
msg := &Message{
Channel: channel,
Payload: payload,
}
// Record PubSub message received
otel.RecordPubSubMessage(ctx, cn, "received", channel, sharded)
return msg, nil
case []interface{}:
ss := make([]string, len(payload))
for i, s := range payload {
ss[i] = s.(string)
}
msg := &Message{
Channel: channel,
PayloadSlice: ss,
}
// Record PubSub message received
otel.RecordPubSubMessage(ctx, cn, "received", channel, sharded)
return msg, nil
default:
return nil, fmt.Errorf("redis: unsupported pubsub message payload: %T", payload)
}
case "pmessage":
channel := reply[2].(string)
msg := &Message{
Pattern: reply[1].(string),
Channel: channel,
Payload: reply[3].(string),
}
// Record PubSub message received (pattern message, not sharded)
otel.RecordPubSubMessage(ctx, cn, "received", channel, false)
return msg, nil
case "pong":
return &Pong{
Payload: reply[1].(string),
}, nil
default:
return nil, fmt.Errorf("redis: unsupported pubsub message: %q", kind)
}
default:
return nil, fmt.Errorf("redis: unsupported pubsub message: %#v", reply)
}
}
// ReceiveTimeout acts like Receive but returns an error if message
// is not received in time. This is low-level API and in most cases
// Channel should be used instead.
func (c *PubSub) ReceiveTimeout(ctx context.Context, timeout time.Duration) (interface{}, error) {
if c.cmd == nil {
c.cmd = NewCmd(ctx)
}
// Don't hold the lock to allow subscriptions and pings.
cn, err := c.connWithLock(ctx)
if err != nil {
return nil, err
}
err = cn.WithReader(ctx, timeout, func(rd *proto.Reader) error {
// To be sure there are no buffered push notifications, we process them before reading the reply
if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
// Log the error but don't fail the command execution
// Push notification processing errors shouldn't break normal Redis operations
internal.Logger.Printf(ctx, "push: conn[%d] error processing pending notifications before reading reply: %v", cn.GetID(), err)
}
return c.cmd.readReply(rd)
})
c.releaseConnWithLock(ctx, cn, err, timeout > 0)
if err != nil {
return nil, err
}
return c.newMessage(ctx, cn, c.cmd.Val())
}
// Receive returns a message as a Subscription, Message, Pong or error.
// See PubSub example for details. This is low-level API and in most cases
// Channel should be used instead.
// Receive returns a message as a Subscription, Message, Pong, or an error.
// See PubSub example for details. This is a low-level API and in most cases
// Channel should be used instead.
// This method blocks until a message is received or an error occurs.
// It may return early with an error if the context is canceled, the connection fails,
// or other internal errors occur.
func (c *PubSub) Receive(ctx context.Context) (interface{}, error) {
return c.ReceiveTimeout(ctx, 0)
}
// ReceiveMessage returns a Message or error ignoring Subscription and Pong
// messages. This is low-level API and in most cases Channel should be used
// instead.
func (c *PubSub) ReceiveMessage(ctx context.Context) (*Message, error) {
for {
msg, err := c.Receive(ctx)
if err != nil {
return nil, err
}
switch msg := msg.(type) {
case *Subscription:
// Ignore.
case *Pong:
// Ignore.
case *Message:
return msg, nil
default:
err := fmt.Errorf("redis: unknown message: %T", msg)
return nil, err
}
}
}
func (c *PubSub) getContext() context.Context {
if c.cmd != nil {
return c.cmd.ctx
}
return context.Background()
}
//------------------------------------------------------------------------------
// Channel returns a Go channel for concurrently receiving messages.
// The channel is closed together with the PubSub. If the Go channel
// is blocked full for 1 minute the message is dropped.
// Receive* APIs can not be used after channel is created.
//
// go-redis periodically sends ping messages to test connection health
// and re-subscribes if ping can not received for 1 minute.
func (c *PubSub) Channel(opts ...ChannelOption) <-chan *Message {
c.chOnce.Do(func() {
c.msgCh = newChannel(c, opts...)
c.msgCh.initMsgChan()
})
if c.msgCh == nil {
err := fmt.Errorf("redis: Channel can't be called after ChannelWithSubscriptions")
panic(err)
}
return c.msgCh.msgCh
}
// ChannelSize is like Channel, but creates a Go channel
// with specified buffer size.
//
// Deprecated: use Channel(WithChannelSize(size)), remove in v9.
func (c *PubSub) ChannelSize(size int) <-chan *Message {
return c.Channel(WithChannelSize(size))
}
// ChannelWithSubscriptions is like Channel, but message type can be either
// *Subscription or *Message. Subscription messages can be used to detect
// reconnections.
//
// ChannelWithSubscriptions can not be used together with Channel or ChannelSize.
func (c *PubSub) ChannelWithSubscriptions(opts ...ChannelOption) <-chan interface{} {
c.chOnce.Do(func() {
c.allCh = newChannel(c, opts...)
c.allCh.initAllChan()
})
if c.allCh == nil {
err := fmt.Errorf("redis: ChannelWithSubscriptions can't be called after Channel")
panic(err)
}
return c.allCh.allCh
}
func (c *PubSub) processPendingPushNotificationWithReader(ctx context.Context, cn *pool.Conn, rd *proto.Reader) error {
// Only process push notifications for RESP3 connections with a processor
if c.opt.Protocol != 3 || c.pushProcessor == nil {
return nil
}
// Create handler context with client, connection pool, and connection information
handlerCtx := c.pushNotificationHandlerContext(cn)
return c.pushProcessor.ProcessPendingNotifications(ctx, handlerCtx, rd)
}
func (c *PubSub) pushNotificationHandlerContext(cn *pool.Conn) push.NotificationHandlerContext {
// PubSub doesn't have a client or connection pool, so we pass nil for those
// PubSub connections are blocking
return push.NotificationHandlerContext{
PubSub: c,
Conn: cn,
IsBlocking: true,
}
}
type ChannelOption func(c *channel)
// WithChannelSize specifies the Go chan size that is used to buffer incoming messages.
//
// The default is 100 messages.
func WithChannelSize(size int) ChannelOption {
return func(c *channel) {
c.chanSize = size
}
}
// WithChannelHealthCheckInterval specifies the health check interval.
// PubSub will ping Redis Server if it does not receive any messages within the interval.
// To disable health check, use zero interval.
//
// The default is 3 seconds.
func WithChannelHealthCheckInterval(d time.Duration) ChannelOption {
return func(c *channel) {
c.checkInterval = d
}
}
// WithChannelSendTimeout specifies the channel send timeout after which
// the message is dropped.
//
// The default is 60 seconds.
func WithChannelSendTimeout(d time.Duration) ChannelOption {
return func(c *channel) {
c.chanSendTimeout = d
}
}
type channel struct {
pubSub *PubSub
msgCh chan *Message
allCh chan interface{}
ping chan struct{}
chanSize int
chanSendTimeout time.Duration
checkInterval time.Duration
}
func newChannel(pubSub *PubSub, opts ...ChannelOption) *channel {
c := &channel{
pubSub: pubSub,
chanSize: 100,
chanSendTimeout: time.Minute,
checkInterval: 3 * time.Second,
}
for _, opt := range opts {
opt(c)
}
if c.checkInterval > 0 {
c.initHealthCheck()
}
return c
}
func (c *channel) initHealthCheck() {
ctx := context.TODO()
c.ping = make(chan struct{}, 1)
go func() {
timer := time.NewTimer(time.Minute)
timer.Stop()
for {
timer.Reset(c.checkInterval)
select {
case <-c.ping:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
if pingErr := c.pubSub.Ping(ctx); pingErr != nil {
c.pubSub.mu.Lock()
c.pubSub.reconnect(ctx, pingErr)
c.pubSub.mu.Unlock()
}
case <-c.pubSub.exit:
return
}
}
}()
}
// initMsgChan must be in sync with initAllChan.
func (c *channel) initMsgChan() {
ctx := context.TODO()
c.msgCh = make(chan *Message, c.chanSize)
go func() {
timer := time.NewTimer(time.Minute)
timer.Stop()
var errCount int
for {
msg, err := c.pubSub.Receive(ctx)
if err != nil {
if err == pool.ErrClosed {
close(c.msgCh)
return
}
if errCount > 0 {
time.Sleep(100 * time.Millisecond)
}
errCount++
continue
}
errCount = 0
// Any message is as good as a ping.
select {
case c.ping <- struct{}{}:
default:
}
switch msg := msg.(type) {
case *Subscription:
// Ignore.
case *Pong:
// Ignore.
case *Message:
timer.Reset(c.chanSendTimeout)
select {
case c.msgCh <- msg:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
internal.Logger.Printf(
ctx, "redis: %v channel is full for %s (message is dropped)",
c, c.chanSendTimeout)
}
default:
internal.Logger.Printf(ctx, "redis: unknown message type: %T", msg)
}
}
}()
}
// initAllChan must be in sync with initMsgChan.
func (c *channel) initAllChan() {
ctx := context.TODO()
c.allCh = make(chan interface{}, c.chanSize)
go func() {
timer := time.NewTimer(time.Minute)
timer.Stop()
var errCount int
for {
msg, err := c.pubSub.Receive(ctx)
if err != nil {
if err == pool.ErrClosed {
close(c.allCh)
return
}
if errCount > 0 {
time.Sleep(100 * time.Millisecond)
}
errCount++
continue
}
errCount = 0
// Any message is as good as a ping.
select {
case c.ping <- struct{}{}:
default:
}
switch msg := msg.(type) {
case *Pong:
// Ignore.
case *Subscription, *Message:
timer.Reset(c.chanSendTimeout)
select {
case c.allCh <- msg:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
internal.Logger.Printf(
ctx, "redis: %v channel is full for %s (message is dropped)",
c, c.chanSendTimeout)
}
default:
internal.Logger.Printf(ctx, "redis: unknown message type: %T", msg)
}
}
}()
}
+88
View File
@@ -0,0 +1,88 @@
package redis
import (
"context"
"github.com/redis/go-redis/v9/internal/otel"
)
type PubSubCmdable interface {
Publish(ctx context.Context, channel string, message interface{}) *IntCmd
SPublish(ctx context.Context, channel string, message interface{}) *IntCmd
PubSubChannels(ctx context.Context, pattern string) *StringSliceCmd
PubSubNumSub(ctx context.Context, channels ...string) *MapStringIntCmd
PubSubNumPat(ctx context.Context) *IntCmd
PubSubShardChannels(ctx context.Context, pattern string) *StringSliceCmd
PubSubShardNumSub(ctx context.Context, channels ...string) *MapStringIntCmd
}
// Publish posts the message to the channel.
func (c cmdable) Publish(ctx context.Context, channel string, message interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "publish", channel, message)
_ = c(ctx, cmd)
// Record PubSub message sent (if command succeeded)
if cmd.Err() == nil {
otel.RecordPubSubMessage(ctx, nil, "sent", channel, false)
}
return cmd
}
func (c cmdable) SPublish(ctx context.Context, channel string, message interface{}) *IntCmd {
cmd := NewIntCmd(ctx, "spublish", channel, message)
_ = c(ctx, cmd)
// Record PubSub message sent (if command succeeded)
if cmd.Err() == nil {
otel.RecordPubSubMessage(ctx, nil, "sent", channel, true)
}
return cmd
}
func (c cmdable) PubSubChannels(ctx context.Context, pattern string) *StringSliceCmd {
args := []interface{}{"pubsub", "channels"}
if pattern != "*" {
args = append(args, pattern)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PubSubNumSub(ctx context.Context, channels ...string) *MapStringIntCmd {
args := make([]interface{}, 2+len(channels))
args[0] = "pubsub"
args[1] = "numsub"
for i, channel := range channels {
args[2+i] = channel
}
cmd := NewMapStringIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PubSubShardChannels(ctx context.Context, pattern string) *StringSliceCmd {
args := []interface{}{"pubsub", "shardchannels"}
if pattern != "*" {
args = append(args, pattern)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PubSubShardNumSub(ctx context.Context, channels ...string) *MapStringIntCmd {
args := make([]interface{}, 2+len(channels))
args[0] = "pubsub"
args[1] = "shardnumsub"
for i, channel := range channels {
args[2+i] = channel
}
cmd := NewMapStringIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) PubSubNumPat(ctx context.Context) *IntCmd {
cmd := NewIntCmd(ctx, "pubsub", "numpat")
_ = c(ctx, cmd)
return cmd
}
+176
View File
@@ -0,0 +1,176 @@
package push
import (
"errors"
"fmt"
)
// Push notification error definitions
// This file contains all error types and messages used by the push notification system
// Error reason constants
const (
// HandlerReasons
ReasonHandlerNil = "handler cannot be nil"
ReasonHandlerExists = "cannot overwrite existing handler"
ReasonHandlerProtected = "handler is protected"
// ProcessorReasons
ReasonPushNotificationsDisabled = "push notifications are disabled"
)
// ProcessorType represents the type of processor involved in the error
// defined as a custom type for better readability and easier maintenance
type ProcessorType string
const (
// ProcessorTypes
ProcessorTypeProcessor = ProcessorType("processor")
ProcessorTypeVoidProcessor = ProcessorType("void_processor")
ProcessorTypeCustom = ProcessorType("custom")
)
// ProcessorOperation represents the operation being performed by the processor
// defined as a custom type for better readability and easier maintenance
type ProcessorOperation string
const (
// ProcessorOperations
ProcessorOperationProcess = ProcessorOperation("process")
ProcessorOperationRegister = ProcessorOperation("register")
ProcessorOperationUnregister = ProcessorOperation("unregister")
ProcessorOperationUnknown = ProcessorOperation("unknown")
)
// Common error variables for reuse
var (
// ErrHandlerNil is returned when attempting to register a nil handler
ErrHandlerNil = errors.New(ReasonHandlerNil)
)
// Registry errors
// ErrHandlerExists creates an error for when attempting to overwrite an existing handler
func ErrHandlerExists(pushNotificationName string) error {
return NewHandlerError(ProcessorOperationRegister, pushNotificationName, ReasonHandlerExists, nil)
}
// ErrProtectedHandler creates an error for when attempting to unregister a protected handler
func ErrProtectedHandler(pushNotificationName string) error {
return NewHandlerError(ProcessorOperationUnregister, pushNotificationName, ReasonHandlerProtected, nil)
}
// VoidProcessor errors
// ErrVoidProcessorRegister creates an error for when attempting to register a handler on void processor
func ErrVoidProcessorRegister(pushNotificationName string) error {
return NewProcessorError(ProcessorTypeVoidProcessor, ProcessorOperationRegister, pushNotificationName, ReasonPushNotificationsDisabled, nil)
}
// ErrVoidProcessorUnregister creates an error for when attempting to unregister a handler on void processor
func ErrVoidProcessorUnregister(pushNotificationName string) error {
return NewProcessorError(ProcessorTypeVoidProcessor, ProcessorOperationUnregister, pushNotificationName, ReasonPushNotificationsDisabled, nil)
}
// Error type definitions for advanced error handling
// HandlerError represents errors related to handler operations
type HandlerError struct {
Operation ProcessorOperation
PushNotificationName string
Reason string
Err error
}
func (e *HandlerError) Error() string {
if e.Err != nil {
return fmt.Sprintf("handler %s failed for '%s': %s (%v)", e.Operation, e.PushNotificationName, e.Reason, e.Err)
}
return fmt.Sprintf("handler %s failed for '%s': %s", e.Operation, e.PushNotificationName, e.Reason)
}
func (e *HandlerError) Unwrap() error {
return e.Err
}
// NewHandlerError creates a new HandlerError
func NewHandlerError(operation ProcessorOperation, pushNotificationName, reason string, err error) *HandlerError {
return &HandlerError{
Operation: operation,
PushNotificationName: pushNotificationName,
Reason: reason,
Err: err,
}
}
// ProcessorError represents errors related to processor operations
type ProcessorError struct {
ProcessorType ProcessorType // "processor", "void_processor"
Operation ProcessorOperation // "process", "register", "unregister"
PushNotificationName string // Name of the push notification involved
Reason string
Err error
}
func (e *ProcessorError) Error() string {
notifInfo := ""
if e.PushNotificationName != "" {
notifInfo = fmt.Sprintf(" for '%s'", e.PushNotificationName)
}
if e.Err != nil {
return fmt.Sprintf("%s %s failed%s: %s (%v)", e.ProcessorType, e.Operation, notifInfo, e.Reason, e.Err)
}
return fmt.Sprintf("%s %s failed%s: %s", e.ProcessorType, e.Operation, notifInfo, e.Reason)
}
func (e *ProcessorError) Unwrap() error {
return e.Err
}
// NewProcessorError creates a new ProcessorError
func NewProcessorError(processorType ProcessorType, operation ProcessorOperation, pushNotificationName, reason string, err error) *ProcessorError {
return &ProcessorError{
ProcessorType: processorType,
Operation: operation,
PushNotificationName: pushNotificationName,
Reason: reason,
Err: err,
}
}
// Helper functions for common error scenarios
// IsHandlerNilError checks if an error is due to a nil handler
func IsHandlerNilError(err error) bool {
return errors.Is(err, ErrHandlerNil)
}
// IsHandlerExistsError checks if an error is due to attempting to overwrite an existing handler.
// This function works correctly even when the error is wrapped.
func IsHandlerExistsError(err error) bool {
var handlerErr *HandlerError
if errors.As(err, &handlerErr) {
return handlerErr.Operation == ProcessorOperationRegister && handlerErr.Reason == ReasonHandlerExists
}
return false
}
// IsProtectedHandlerError checks if an error is due to attempting to unregister a protected handler.
// This function works correctly even when the error is wrapped.
func IsProtectedHandlerError(err error) bool {
var handlerErr *HandlerError
if errors.As(err, &handlerErr) {
return handlerErr.Operation == ProcessorOperationUnregister && handlerErr.Reason == ReasonHandlerProtected
}
return false
}
// IsVoidProcessorError checks if an error is due to void processor operations.
// This function works correctly even when the error is wrapped.
func IsVoidProcessorError(err error) bool {
var procErr *ProcessorError
if errors.As(err, &procErr) {
return procErr.ProcessorType == ProcessorTypeVoidProcessor && procErr.Reason == ReasonPushNotificationsDisabled
}
return false
}
+14
View File
@@ -0,0 +1,14 @@
package push
import (
"context"
)
// NotificationHandler defines the interface for push notification handlers.
type NotificationHandler interface {
// HandlePushNotification processes a push notification with context information.
// The handlerCtx provides information about the client, connection pool, and connection
// on which the notification was received, allowing handlers to make informed decisions.
// Returns an error if the notification could not be handled.
HandlePushNotification(ctx context.Context, handlerCtx NotificationHandlerContext, notification []interface{}) error
}
+44
View File
@@ -0,0 +1,44 @@
package push
// No imports needed for this file
// NotificationHandlerContext provides context information about where a push notification was received.
// This struct allows handlers to make informed decisions based on the source of the notification
// with strongly typed access to different client types using concrete types.
type NotificationHandlerContext struct {
// Client is the Redis client instance that received the notification.
// It is interface to both allow for future expansion and to avoid
// circular dependencies. The developer is responsible for type assertion.
// It can be one of the following types:
// - *redis.baseClient
// - *redis.Client
// - *redis.ClusterClient
// - *redis.Conn
Client interface{}
// ConnPool is the connection pool from which the connection was obtained.
// It is interface to both allow for future expansion and to avoid
// circular dependencies. The developer is responsible for type assertion.
// It can be one of the following types:
// - *pool.ConnPool
// - *pool.SingleConnPool
// - *pool.StickyConnPool
ConnPool interface{}
// PubSub is the PubSub instance that received the notification.
// It is interface to both allow for future expansion and to avoid
// circular dependencies. The developer is responsible for type assertion.
// It can be one of the following types:
// - *redis.PubSub
PubSub interface{}
// Conn is the specific connection on which the notification was received.
// It is interface to both allow for future expansion and to avoid
// circular dependencies. The developer is responsible for type assertion.
// It can be one of the following types:
// - *pool.Conn
Conn interface{}
// IsBlocking indicates if the notification was received on a blocking connection.
IsBlocking bool
}
+203
View File
@@ -0,0 +1,203 @@
package push
import (
"context"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/proto"
)
// NotificationProcessor defines the interface for push notification processors.
type NotificationProcessor interface {
// GetHandler returns the handler for a specific push notification name.
GetHandler(pushNotificationName string) NotificationHandler
// ProcessPendingNotifications checks for and processes any pending push notifications.
// To be used when it is known that there are notifications on the socket.
// It will try to read from the socket and if it is empty - it may block.
ProcessPendingNotifications(ctx context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error
// RegisterHandler registers a handler for a specific push notification name.
RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error
// UnregisterHandler removes a handler for a specific push notification name.
UnregisterHandler(pushNotificationName string) error
}
// Processor handles push notifications with a registry of handlers
type Processor struct {
registry *Registry
}
// NewProcessor creates a new push notification processor
func NewProcessor() *Processor {
return &Processor{
registry: NewRegistry(),
}
}
// GetHandler returns the handler for a specific push notification name
func (p *Processor) GetHandler(pushNotificationName string) NotificationHandler {
return p.registry.GetHandler(pushNotificationName)
}
// RegisterHandler registers a handler for a specific push notification name
func (p *Processor) RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error {
return p.registry.RegisterHandler(pushNotificationName, handler, protected)
}
// UnregisterHandler removes a handler for a specific push notification name
func (p *Processor) UnregisterHandler(pushNotificationName string) error {
return p.registry.UnregisterHandler(pushNotificationName)
}
// ProcessPendingNotifications checks for and processes any pending push notifications
// This method should be called by the client in WithReader before reading the reply
// It will try to read from the socket and if it is empty - it may block.
func (p *Processor) ProcessPendingNotifications(ctx context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error {
if rd == nil {
return nil
}
for {
// Check if there's data available to read
replyType, err := rd.PeekReplyType()
if err != nil {
// No more data available or error reading
// if timeout, it will be handled by the caller
break
}
// Only process push notifications (arrays starting with >)
if replyType != proto.RespPush {
break
}
// see if we should skip this notification
notificationName, err := rd.PeekPushNotificationName()
if err != nil {
break
}
if willHandleNotificationInClient(notificationName) {
break
}
// Read the push notification
reply, err := rd.ReadReply()
if err != nil {
internal.Logger.Printf(ctx, "push: error reading push notification: %v", err)
break
}
// Convert to slice of interfaces
notification, ok := reply.([]interface{})
if !ok {
break
}
// Handle the notification directly
if len(notification) > 0 {
// Extract the notification type (first element)
if notificationType, ok := notification[0].(string); ok {
// Get the handler for this notification type
if handler := p.registry.GetHandler(notificationType); handler != nil {
// Handle the notification
err := handler.HandlePushNotification(ctx, handlerCtx, notification)
if err != nil {
internal.Logger.Printf(ctx, "push: error handling push notification: %v", err)
}
}
}
}
}
return nil
}
// VoidProcessor discards all push notifications without processing them
type VoidProcessor struct{}
// NewVoidProcessor creates a new void push notification processor
func NewVoidProcessor() *VoidProcessor {
return &VoidProcessor{}
}
// GetHandler returns nil for void processor since it doesn't maintain handlers
func (v *VoidProcessor) GetHandler(_ string) NotificationHandler {
return nil
}
// RegisterHandler returns an error for void processor since it doesn't maintain handlers
func (v *VoidProcessor) RegisterHandler(pushNotificationName string, _ NotificationHandler, _ bool) error {
return ErrVoidProcessorRegister(pushNotificationName)
}
// UnregisterHandler returns an error for void processor since it doesn't maintain handlers
func (v *VoidProcessor) UnregisterHandler(pushNotificationName string) error {
return ErrVoidProcessorUnregister(pushNotificationName)
}
// ProcessPendingNotifications for VoidProcessor does nothing since push notifications
// are only available in RESP3 and this processor is used for RESP2 connections.
// This avoids unnecessary buffer scanning overhead.
// It does however read and discard all push notifications from the buffer to avoid
// them being interpreted as a reply.
// This method should be called by the client in WithReader before reading the reply
// to be sure there are no buffered push notifications.
// It will try to read from the socket and if it is empty - it may block.
func (v *VoidProcessor) ProcessPendingNotifications(_ context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error {
// read and discard all push notifications
if rd == nil {
return nil
}
for {
// Check if there's data available to read
replyType, err := rd.PeekReplyType()
if err != nil {
// No more data available or error reading
// if timeout, it will be handled by the caller
break
}
// Only process push notifications (arrays starting with >)
if replyType != proto.RespPush {
break
}
// see if we should skip this notification
notificationName, err := rd.PeekPushNotificationName()
if err != nil {
break
}
if willHandleNotificationInClient(notificationName) {
break
}
// Read the push notification
_, err = rd.ReadReply()
if err != nil {
internal.Logger.Printf(context.Background(), "push: error reading push notification: %v", err)
return nil
}
}
return nil
}
// willHandleNotificationInClient checks if a notification type should be ignored by the push notification
// processor and handled by other specialized systems instead (pub/sub, streams, keyspace, etc.).
func willHandleNotificationInClient(notificationType string) bool {
switch notificationType {
// Pub/Sub notifications - handled by pub/sub system
case "message", // Regular pub/sub message
"pmessage", // Pattern pub/sub message
"subscribe", // Subscription confirmation
"unsubscribe", // Unsubscription confirmation
"psubscribe", // Pattern subscription confirmation
"punsubscribe", // Pattern unsubscription confirmation
"smessage", // Sharded pub/sub message (Redis 7.0+)
"ssubscribe", // Sharded subscription confirmation
"sunsubscribe": // Sharded unsubscription confirmation
return true
default:
return false
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package push provides push notifications for Redis.
// This is an EXPERIMENTAL API for handling push notifications from Redis.
// It is not yet stable and may change in the future.
// Although this is in a public package, in its current form public use is not advised.
// Pending push notifications should be processed before executing any readReply from the connection
// as per RESP3 specification push notifications can be sent at any time.
package push
+61
View File
@@ -0,0 +1,61 @@
package push
import (
"sync"
)
// Registry manages push notification handlers
type Registry struct {
mu sync.RWMutex
handlers map[string]NotificationHandler
protected map[string]bool
}
// NewRegistry creates a new push notification registry
func NewRegistry() *Registry {
return &Registry{
handlers: make(map[string]NotificationHandler),
protected: make(map[string]bool),
}
}
// RegisterHandler registers a handler for a specific push notification name
func (r *Registry) RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error {
if handler == nil {
return ErrHandlerNil
}
r.mu.Lock()
defer r.mu.Unlock()
// Check if handler already exists
if _, exists := r.protected[pushNotificationName]; exists {
return ErrHandlerExists(pushNotificationName)
}
r.handlers[pushNotificationName] = handler
r.protected[pushNotificationName] = protected
return nil
}
// GetHandler returns the handler for a specific push notification name
func (r *Registry) GetHandler(pushNotificationName string) NotificationHandler {
r.mu.RLock()
defer r.mu.RUnlock()
return r.handlers[pushNotificationName]
}
// UnregisterHandler removes a handler for a specific push notification name
func (r *Registry) UnregisterHandler(pushNotificationName string) error {
r.mu.Lock()
defer r.mu.Unlock()
// Check if handler is protected
if protected, exists := r.protected[pushNotificationName]; exists && protected {
return ErrProtectedHandler(pushNotificationName)
}
delete(r.handlers, pushNotificationName)
delete(r.protected, pushNotificationName)
return nil
}

Some files were not shown because too many files have changed in this diff Show More