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)
}
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)
}
package redis
import (
"context"
)
// note: the APIs is experimental and may be subject to change.
//
// ArrayCmdable defines the interface for Redis Array data structure commands
// available in Redis 8.8.0+.
//
// Redis array supports index range [0, math.MaxUint64-1), so index parameters use uint64.
type ArrayCmdable interface {
ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd
ARGet(ctx context.Context, key string, index uint64) *StringCmd
ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd
ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd
ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd
ARInsert(ctx context.Context, key string, values ...string) *UintCmd
ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd
ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd
ARLen(ctx context.Context, key string) *UintCmd
ARCount(ctx context.Context, key string) *UintCmd
ARNext(ctx context.Context, key string) *UintCmd
ARSeek(ctx context.Context, key string, index uint64) *IntCmd
ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd
ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd
ARScan(ctx context.Context, key string, start, end uint64, args *ARScanArgs) *AREntrySliceCmd
AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd
AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd
AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd
AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd
AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd
AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd
AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd
AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd
ARGrep(ctx context.Context, key string, start, end string, args *ARGrepArgs) *UintSliceCmd
ARGrepWithValues(ctx context.Context, key string, start, end string, args *ARGrepArgs) *AREntrySliceCmd
ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd
ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd
}
// AREntry represents an index-value pair for ARMSET.
type AREntry struct {
Index uint64
Value string
}
// ARRange represents a start-end range for ARDELRANGE.
type ARRange struct {
Start uint64
End uint64
}
// ARScanArgs contains optional arguments for ARSCAN.
type ARScanArgs struct {
Limit uint64
}
// ARGrepPredicateType defines the type of predicate for ARGREP.
type ARGrepPredicateType string
const (
ARGrepExact ARGrepPredicateType = "EXACT"
ARGrepMatch ARGrepPredicateType = "MATCH"
ARGrepGlob ARGrepPredicateType = "GLOB"
ARGrepRegex ARGrepPredicateType = "RE"
)
// ARGrepPredicate represents a search predicate for ARGREP.
type ARGrepPredicate struct {
Type ARGrepPredicateType
Value string
}
// ARGrepArgs contains optional arguments for ARGREP.
// Redis ARGREP defaults to OR when multiple predicates are given.
// Set CombineAnd to true to combine predicates with AND instead.
type ARGrepArgs struct {
Predicates []ARGrepPredicate
CombineAnd bool
Limit uint64
NoCase bool
}
// ARSet sets one or more contiguous values starting at an index in an array.
// Returns the number of new slots that were set (previously empty).
func (c cmdable) ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd {
args := make([]any, 3, 3+len(values))
args[0] = "arset"
args[1] = key
args[2] = index
for _, v := range values {
args = append(args, v)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARGet gets the value at an index in an array.
// Returns redis.Nil if the key or index does not exist.
func (c cmdable) ARGet(ctx context.Context, key string, index uint64) *StringCmd {
cmd := NewStringCmd(ctx, "arget", key, index)
_ = c(ctx, cmd)
return cmd
}
// ARGetRange gets values in a range of indexes.
// Returns values in the range, with nil for unset indexes.
func (c cmdable) ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd {
cmd := NewSliceCmd(ctx, "argetrange", key, start, end)
_ = c(ctx, cmd)
return cmd
}
// ARMGet gets values at multiple indexes in an array.
// Returns values at the specified indexes, with nil for unset indexes.
func (c cmdable) ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd {
args := make([]any, 2+len(indexes))
args[0] = "armget"
args[1] = key
for i, idx := range indexes {
args[2+i] = idx
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARMSet sets multiple index-value pairs in an array.
// Returns the number of new slots that were set (previously empty).
func (c cmdable) ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd {
args := make([]any, 2, 2+2*len(members))
args[0] = "armset"
args[1] = key
for _, m := range members {
args = append(args, m.Index, m.Value)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARInsert inserts one or more values at consecutive indexes.
// Returns the last index where a value was inserted.
func (c cmdable) ARInsert(ctx context.Context, key string, values ...string) *UintCmd {
args := make([]any, 2, 2+len(values))
args[0] = "arinsert"
args[1] = key
for _, v := range values {
args = append(args, v)
}
cmd := NewUintCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARDel deletes elements at the specified indexes in an array.
// Returns the number of elements deleted.
func (c cmdable) ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd {
args := make([]any, 2+len(indexes))
args[0] = "ardel"
args[1] = key
for i, idx := range indexes {
args[2+i] = idx
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARDelRange deletes elements in one or more ranges.
// Returns the number of elements deleted.
func (c cmdable) ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd {
args := make([]any, 2, 2+2*len(ranges))
args[0] = "ardelrange"
args[1] = key
for _, r := range ranges {
args = append(args, r.Start, r.End)
}
cmd := NewUintCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARLen returns the length of an array (max index + 1).
// Returns 0 if the key does not exist.
func (c cmdable) ARLen(ctx context.Context, key string) *UintCmd {
cmd := NewUintCmd(ctx, "arlen", key)
_ = c(ctx, cmd)
return cmd
}
// ARCount returns the number of non-empty elements in an array.
// Returns 0 if the key does not exist.
func (c cmdable) ARCount(ctx context.Context, key string) *UintCmd {
cmd := NewUintCmd(ctx, "arcount", key)
_ = c(ctx, cmd)
return cmd
}
// ARNext returns the next index ARINSERT would use.
// Returns 0 for missing keys or when no insert happened yet.
// Returns nil when the insertion cursor is exhausted / would overflow.
func (c cmdable) ARNext(ctx context.Context, key string) *UintCmd {
cmd := NewUintCmd(ctx, "arnext", key)
_ = c(ctx, cmd)
return cmd
}
// ARSeek sets the ARINSERT / ARRING cursor to a specific index.
// Returns 1 if the cursor was set, 0 if the key does not exist.
func (c cmdable) ARSeek(ctx context.Context, key string, index uint64) *IntCmd {
cmd := NewIntCmd(ctx, "arseek", key, index)
_ = c(ctx, cmd)
return cmd
}
// ARInfo returns metadata about an array.
func (c cmdable) ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd {
cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key)
_ = c(ctx, cmd)
return cmd
}
// ARInfoFull returns detailed metadata about an array including slice statistics.
func (c cmdable) ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd {
cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key, "full")
_ = c(ctx, cmd)
return cmd
}
// ARScan iterates existing elements in a range, returning index-value pairs.
func (c cmdable) ARScan(ctx context.Context, key string, start, end uint64, scanArgs *ARScanArgs) *AREntrySliceCmd {
args := make([]any, 4, 6)
args[0], args[1], args[2], args[3] = "arscan", key, start, end
if scanArgs != nil && scanArgs.Limit > 0 {
args = append(args, "limit", scanArgs.Limit)
}
cmd := NewAREntrySliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// AROpSum returns the sum of numeric elements in a range.
func (c cmdable) AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd {
cmd := NewStringCmd(ctx, "arop", key, start, end, "SUM")
_ = c(ctx, cmd)
return cmd
}
// AROpMin returns the minimum numeric element in a range.
func (c cmdable) AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd {
cmd := NewStringCmd(ctx, "arop", key, start, end, "MIN")
_ = c(ctx, cmd)
return cmd
}
// AROpMax returns the maximum numeric element in a range.
func (c cmdable) AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd {
cmd := NewStringCmd(ctx, "arop", key, start, end, "MAX")
_ = c(ctx, cmd)
return cmd
}
// AROpAnd returns the bitwise AND of integer elements in a range.
func (c cmdable) AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd {
cmd := NewIntCmd(ctx, "arop", key, start, end, "AND")
_ = c(ctx, cmd)
return cmd
}
// AROpOr returns the bitwise OR of integer elements in a range.
func (c cmdable) AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd {
cmd := NewIntCmd(ctx, "arop", key, start, end, "OR")
_ = c(ctx, cmd)
return cmd
}
// AROpXor returns the bitwise XOR of integer elements in a range.
func (c cmdable) AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd {
cmd := NewIntCmd(ctx, "arop", key, start, end, "XOR")
_ = c(ctx, cmd)
return cmd
}
// AROpMatch returns the count of elements matching a target string in a range.
func (c cmdable) AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd {
cmd := NewIntCmd(ctx, "arop", key, start, end, "MATCH", value)
_ = c(ctx, cmd)
return cmd
}
// AROpUsed returns the count of non-empty slots in a range.
func (c cmdable) AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd {
cmd := NewIntCmd(ctx, "arop", key, start, end, "USED")
_ = c(ctx, cmd)
return cmd
}
// ARGrep searches array elements in a range using textual predicates.
// Returns matching indexes only. Use ARGrepWithValues to also get the values.
func (c cmdable) ARGrep(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *UintSliceCmd {
args := make([]any, 4, 4+grepArgs.Len())
args[0], args[1], args[2], args[3] = "argrep", key, start, end
args = grepArgs.Append(args)
cmd := NewUintSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARGrepWithValues searches array elements in a range using textual predicates.
// Returns matching indexes and their values as index-value pairs.
func (c cmdable) ARGrepWithValues(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *AREntrySliceCmd {
args := make([]any, 4, 5+grepArgs.Len())
args[0], args[1], args[2], args[3] = "argrep", key, start, end
args = grepArgs.Append(args)
args = append(args, "withvalues")
cmd := NewAREntrySliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (args *ARGrepArgs) Len() int {
if args == nil {
return 0
}
n := 2 * len(args.Predicates)
if args.CombineAnd {
n++
}
if args.Limit > 0 {
n += 2
}
if args.NoCase {
n++
}
return n
}
func (args *ARGrepArgs) Append(a []any) []any {
if args == nil {
return a
}
for _, p := range args.Predicates {
a = append(a, string(p.Type), p.Value)
}
if args.CombineAnd {
a = append(a, "and")
}
if args.Limit > 0 {
a = append(a, "limit", args.Limit)
}
if args.NoCase {
a = append(a, "nocase")
}
return a
}
// ARRing inserts values into a ring buffer of specified size, wrapping and truncating as needed.
// Returns the last index where a value was inserted.
func (c cmdable) ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd {
args := make([]any, 3, 3+len(values))
args[0] = "arring"
args[1] = key
args[2] = size
for _, v := range values {
args = append(args, v)
}
cmd := NewUintCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ARLastItems returns the most recently inserted elements.
// When rev is true, returns items in reverse order.
func (c cmdable) ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd {
args := make([]any, 3, 4)
args[0], args[1], args[2] = "arlastitems", key, count
if rev {
args = append(args, "rev")
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// 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,
}
}
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)
package redis
import (
"context"
"encoding"
"errors"
"fmt"
"io"
"runtime"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sys/cpu"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
)
// AutoPipelineOptions configures the autopipelining behavior.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
type AutoPipelineOptions struct {
// MaxBatchSize is the target batch size: the accumulator stops waiting for
// more commands once the shard queue reaches it, so a batch flushes promptly
// instead of lingering. It is a soft threshold, not a hard cap — under heavy
// concurrent enqueue (or while a flush waits on the concurrency semaphore) the
// queue can grow past it and execute as a single larger pipeline, which is
// safe and simply yields a deeper pipeline.
// Default: 200 (the blocking face's no-options preset,
// DefaultBlockingAutoPipelineOptions, uses 300).
MaxBatchSize int
// MaxBatchBytes caps a batch by APPROXIMATE payload volume: the
// accumulator stops waiting once the queued commands' argument bytes reach
// it, so many large values flush as several bounded writes instead of one
// huge burst (300 x 64KiB is ~19MB written down one connection before any
// reply is read — enough to stall a constrained link past its write
// deadline). Like MaxBatchSize it is a soft threshold, not a hard cap.
// The estimate sizes string, []byte, *string and BinaryMarshaler arguments
// by their encoded length (other argument kinds by a small fixed size) plus
// a small per-argument overhead.
//
// Default: 128 KiB. Only 0 selects the default; a negative value is
// rejected by Validate rather than silently coerced (the autopipeliner
// getters return that error). There is no "unbounded" setting — pass a
// deliberately large value instead. This is a
// full-duplex safety guardrail, not a throughput knob: with FullDuplex,
// the reader cannot start draining replies until the writer finishes
// flushing the WHOLE batch (see autopipeline_fullduplex.go), so a batch
// with ≥2 large-payload commands can deadlock both directions — Redis
// blocks writing an early large reply while the client is still blocked
// writing the rest of the batch, resolved only by WriteTimeout, and
// recovery may then replay a command Redis already executed (cursor/codex
// on #4002). The cap bounds this for large-REQUEST commands (big ECHO,
// large SET values); it does NOT bound expected REPLY size, so a batch of
// plain GETs against large values is still exposed regardless of this
// setting — closing that gap needs the reader to drain incrementally, not
// a byte cap. For ordinary small commands this default rarely binds:
// MaxBatchSize's 200-command cap already keeps a typical batch under
// ~15 KiB, far below this threshold.
MaxBatchBytes int
// MaxConcurrentBatches is the maximum number of pipeline batches that may
// execute concurrently.
//
// Default: 1, which gives a single ordered command stream — batches execute
// serially in submit order, so even a windowed caller (submit many, read
// later) sees strict ordering, while still reaching high throughput via deep
// pipelines (~3M ops/sec locally).
//
// Setting this above 1 runs batches in parallel for maximum throughput, but
// commands then have NO guaranteed execution order. Because that trades away
// ordering, it is only allowed together with Unordered: true — otherwise the
// configuration is rejected (see Validate). This makes the trade-off
// explicit: you cannot accidentally lose ordering by raising concurrency.
MaxConcurrentBatches int
// Unordered must be set to true to allow MaxConcurrentBatches > 1. It is the
// caller's explicit acknowledgement that parallel batch execution gives up
// command ordering in exchange for throughput. With the default (false),
// MaxConcurrentBatches is forced to 1 (an ordered stream) and any value > 1
// is a configuration error.
Unordered bool
// FullDuplex enables the ordered full-duplex dispatch path: one held
// pipeline-pool connection with a writer+reader goroutine pair streaming the
// ordered command stream, instead of the half-duplex one-batch-per-round-trip
// flusher. Its win is a latency-bound (WAN) link under many concurrent
// goroutines: ~1 RTT latency and pipe-saturated throughput on a single
// connection. On a fast link (loopback) prefer half-duplex — with no RTT to
// overlap, full-duplex only adds coordination overhead.
//
// Honored on the ordered (Unordered:false, MaxConcurrentBatches<=1),
// single-shard face of a standalone *Client that has a pipeline pool — BOTH the
// deferred (AsyncAutoPipeline) and the blocking (AutoPipeline) face. A SINGLE
// blocking caller gains nothing: it has one command in flight, so there is
// nothing to overlap, and it still pays the held connection and goroutine
// overhead; the win needs MANY concurrent blocking callers, whose commands then
// overlap on the shared pipe exactly as on the async face (~1 RTT each instead
// of batch phase-locking). On a ClusterClient it runs natively per node: the
// engine keeps one FD child per master (each on that node's node.Client, which
// has a pipeline pool by default) and routes each command to the child that
// owns its slot; MOVED/ASK redirects are followed through the redirect-aware
// cluster path (topology reload included). It falls back to half-duplex only
// when PipelinePoolSize<0 removes the node pipeline pools. Validate rejects the
// contradictory standalone combos (FullDuplex with Unordered or MaxConcurrentBatches>1).
//
// Ordering caveat: blocking and connection-hostile commands (BLPOP, WAIT,
// XREAD BLOCK, SUBSCRIBE, MULTI, ...) are diverted to a separate pooled
// connection so they cannot stall the shared pipe. Managed HIMPORT
// (PREPARE/SET/DISCARD/DISCARDALL) is diverted too, but only on the full-duplex
// path: a fieldset is connection-session state that the FD writer does not
// replay and the FD reader does not track, so it runs through the normal Process
// path — which injects the registered PREPARE and keeps the registry current —
// instead of failing with "no such fieldset". A reply that is a retryable Redis
// error (LOADING/READONLY/…) is likewise re-run through Process, off the FD
// reader, so the reader keeps completing later replies. A MOVED/ASK redirect is
// NOT replayed on the standalone full-duplex path — a standalone Client cannot
// route it — so the redirect surfaces to the caller as the command's error,
// exactly as it does for a plain standalone command (a cluster-aware FD path
// could route it instead; that is a follow-up). Per-caller ordering therefore
// does NOT hold across a diverted command: it may settle AFTER a command
// submitted later on the same goroutine.
// That reorders only a caller holding TWO causally-dependent commands in flight
// WITHOUT awaiting the first (e.g. Set(k) then Get(k) both fired on the async
// face before reading Set's result); awaiting a result before issuing a
// dependent one preserves order, and the blocking face waits per command by
// construction, so its per-goroutine ordering is unaffected. NoRetry commands
// are never diverted. Half-duplex diverts identically; blocking commands were
// never part of the ordered stream.
//
// Observability: process hooks (redisotel spans/metrics, custom AddHook
// ProcessHooks) DO fire on the full-duplex path — each command runs the hook
// chain individually (withProcessHook, not the batch ProcessPipelineHook), the
// span bracketing its real write→reply latency; with none registered the hosting
// is skipped entirely (the fast path). Presence is checked per command at submit
// time, so a hook registered via AddHook is observed only by commands submitted
// after it (one already in flight is not retroactively spanned). DialHook and
// pool stats work as usual. Caveat: the write is already queued on the shared
// stream when the hook host starts, so a hook that SHORT-CIRCUITS (returns
// without calling next) does NOT cancel execution — the command still runs on
// the wire and only the hook's returned error reaches the caller, unlike the
// half-duplex path where next() gates the write. A hook that relies on
// short-circuiting to BLOCK a command (a policy/ACL/kill-switch hook, or a
// mock/cache that must not touch the server) therefore does NOT prevent the
// server write under FullDuplex — run such hooks on a plain client or the
// half-duplex autopipeline. A hook that calls next and only OBSERVES the
// command (reads its result after next, optionally rewriting the returned
// error) is unaffected. But because the write is already queued when the host
// starts, a hook MUST NOT mutate the command — e.g. cmd.Args() — set its
// result, or READ its result (cmd.Err(), cmd.Val(), cmd.String(), ...)
// BEFORE calling next: the write is already queued and the reader may be
// completing the command concurrently. On the hook-host goroutine the result
// accessors do not block (it is the batch's executor; blocking there would
// self-deadlock — see await), so a pre-next read is the not-yet-executed view
// racing the reader's write, not a wait for the result. Read results only
// after next returns. Mutate-before-next hooks must run on a plain client or
// the half-duplex autopipeline, where next() gates the write.
//
// A ProcessHook MUST NOT synchronously call Close (Client.Close or
// AutoPipeliner.Close) from inside the hook: the hook runs on the full-duplex
// hook-host goroutine and Close waits for that goroutine to finish, so a
// synchronous Close from the hook deadlocks until the close backstop (~30s).
// Trigger Close from a separate goroutine if a hook must initiate it.
//
// TODO(fullduplex): offer opt-in write-gating for blocking hooks — a per-client
// or per-command flag that waits for the hook to call next before enqueuing the
// command onto fd.ch, so a policy hook can veto the write, at the cost of the
// ~1-RTT concurrency for gated commands (observability-only hooks keep the fast
// path). Until then the short-circuit-does-not-block semantics above are
// intentional, not a bug.
//
// Limiter: Options.Limiter is admitted (Allow/ReportResult) once PER WRITTEN
// BATCH — the chunk flushed to the connection in one write, the full-duplex
// analogue of a pipeline exec or a half-duplex flush — not per command and
// not per session. A deny fails every command of that chunk with the
// Limiter's own error, verbatim (examinable via errors.Is); the session and
// its connection stay alive, and the next chunk pays Allow again, so an open
// breaker fail-fasts and service resumes as soon as it closes. Because
// admission happens at write time, a deny surfaces as queue latency on the
// awaited result, not as a submit-time error — and one deny covers a whole
// chunk, exactly as one Allow covers a whole pipeline exec elsewhere.
// ReportResult fires exactly once per admitted chunk, strictly paired with its
// Allow, and carries the REPLY-side outcome — not the write result. A clean write
// does NOT report at admission: the obligation rides the in-flight deque on the
// chunk's last command and reports nil once every reply of the chunk has landed
// (reply-LEVEL errors such as redis.Nil / WRONGTYPE / MOVED still report nil — a
// server that answers is healthy). A write failure or encoder panic reports that
// write error immediately (the replies will never come); a later transport failure
// that abandons the chunk's unread replies reports that error. A denied (or
// panicking) Allow grants no permit and reports nothing. So a custom Limiter must
// expect its permit to be released on the reply side and to observe only transport
// failures, never reply-level Redis errors.
FullDuplex bool
// FullDuplexWindow is the maximum in-flight (written-but-unacknowledged)
// commands before the writer applies backpressure — a hard memory bound AND the
// cap on how deep the pipe can fill, so it must exceed the bandwidth-delay
// product (RTT × target rate) or it throttles throughput. The deque holds only
// ACTUAL in-flight (self-limited by throughput), so a generous window costs no
// memory until a stalled peer makes in-flight grow. Only used when FullDuplex is
// set; 0 means the default (65536, covering ~50ms links at ~1.3M ops/s) and a
// negative value is rejected by Validate.
FullDuplexWindow int
// FullDuplexIdleTimeout is how long the held full-duplex connection may sit
// with no queued work and a drained in-flight before it is returned to the pool
// (so it is reusable and its per-conn hooks — streaming-creds re-auth,
// maintnotifications — get a chance to run). Only used when FullDuplex is set.
// 0 means the default (1s); a negative value is rejected by Validate.
FullDuplexIdleTimeout time.Duration
// FullDuplexMaxHold forces the same clean return under continuous load, so the
// per-conn hooks run at least this often even when the connection never goes
// idle. Only used when FullDuplex is set. 0 means the default (5s); a negative
// value is rejected by Validate.
FullDuplexMaxHold time.Duration
// FullDuplexFastSubmit trades submit fairness for throughput on hot,
// low-RTT links. Off by default; only used when FullDuplex is set.
//
// What: normal submit waits on a blocking three-arm select. The fast path
// tries a non-blocking channel send first and only falls back to that select
// on a miss, cutting the selectgo cost (~40% of submit CPU) that dominates at
// high producer counts.
//
// Benefit: measured +15-33% throughput at 1 ms RTT and +6-18% at 5 ms with
// >=1k concurrent callers on the default window, tail equal-or-better.
//
// Drawback: it can affect fairness. A producer that finds room jumps ahead of
// producers already blocked on a full channel, so under a deep/bursting queue
// it would starve them and inflate p99. To bound that, the fast path is
// queue-depth gated (fdFastSubmitGatePct, ~10% full): once the channel backs
// up, the fair blocking select takes over. No-op on high-RTT links (RTT-bound)
// and with a small FullDuplexWindow (the channel is min(window,4096) deep, so
// it stays shallow) — the gains are at the default window.
//
// Ordering is unaffected. The enqueue is synchronous even on the async
// (Submit) face — only the reply is deferred, not the send. A caller's command
// N is on the ordered channel before its submit returns, and submit(N+1)
// cannot start until submit(N) returns, so a goroutine's own commands keep
// program order regardless of which path each took. Fast-submit changes only
// how long a synchronous enqueue waits and how it interleaves with OTHER
// producers (fairness); it can never let a caller's later command overtake its
// own earlier one.
FullDuplexFastSubmit bool
// contentSharded is set internally by cluster wiring when commands are
// routed to shards by content (slot), so same-key commands always share a
// shard and per-key order holds even with several shards. It exempts that
// wiring from the NumShards ordering check in newAutoPipeliner. Never set
// by users (unexported).
contentSharded bool
// clusterReprocess is set internally by the cluster full-duplex router on each
// per-node child config. When non-nil, the child's full-duplex engine re-runs a
// command that came back with a retryable reply or a MOVED/ASK redirect through
// this function instead of the node client's standalone process path, so the
// redirect is followed on the redirect-aware ClusterClient (which routes to the
// target node, sends ASKING, and reloads topology). Never set by users
// (unexported). See clusterFDRouter and fdEngine.reprocess.
clusterReprocess func(ctx context.Context, cmd Cmder, startAttempt int, writtenAt time.Time) error
// clusterRetryBudget is set internally by the cluster full-duplex router to the
// ClusterClient's MaxRedirects. The child's full-duplex engine uses it as its
// connection-failure recovery budget (fdEngine.retryBudget) instead of the node
// client's MaxRetries, which cluster node clients normalize to -1 (cluster
// retries live in MaxRedirects). Never set by users (unexported).
clusterRetryBudget int
// NumShards is the number of independent queue+flusher shards the
// autopipeliner runs. 0 (the default) means auto: a single shard, which
// funnels every caller into one queue so batches stay deep — measured
// throughput and latency are best with one shard even under heavy
// goroutine concurrency. Cluster clients default to several slot-routed
// shards instead, so commands for different nodes queue independently
// (per-key order still holds: a key's slot always maps to the same
// shard). Raising NumShards splits the queue: it reduces enqueue-mutex
// contention but fragments batches, which usually costs far more than the
// contention saves. Every shard always has at least one concurrency
// permit, so the effective global batch concurrency is
// max(NumShards, MaxConcurrentBatches) — and because shards flush
// concurrently, NumShards > 1 on the deferred (async) face requires
// Unordered: true (construction fails otherwise).
NumShards int
// MaxFlushDelay is the maximum delay after flushing before checking for more commands.
// A small delay (e.g., 100μs) can significantly reduce CPU usage by allowing
// more commands to batch together, at the cost of slightly higher latency.
//
// Trade-off:
// - 0 (default): Lowest latency, higher CPU usage
// - 100μs: Balanced (recommended for most workloads)
// - 500μs: Lower CPU usage, higher latency
//
// Based on benchmarks, 100μs can reduce CPU usage by 50%
// while adding only ~100μs average latency per command.
// Default: 0, meaning the flusher applies no coalescing wait — it flushes
// each batch as soon as the queue is ready and lets in-flight backpressure
// coalesce concurrent callers (see accumulateBatch). Set a value here to add
// an explicit accumulation window, trading latency for larger batches / less
// CPU as described above.
MaxFlushDelay time.Duration
// AdaptiveDelay enables smart delay calculation based on queue fill level.
// When enabled, the delay is automatically adjusted:
// - Queue ≥75% full: No delay (flush immediately to prevent overflow)
// - Queue ≥50% full: 25% of MaxFlushDelay (queue filling up)
// - Queue ≥25% full: 50% of MaxFlushDelay (moderate load)
// - Queue <25% full: 100% of MaxFlushDelay (low load, maximize batching)
//
// This provides automatic adaptation to varying load patterns without
// manual tuning. Uses integer-only arithmetic for optimal performance.
// Default: false (use fixed MaxFlushDelay)
AdaptiveDelay bool
}
// autoPipelinePermitBackstop bounds how long a flush waits for a concurrency
// permit when all are busy. It is only a safety net against a wedged semaphore:
// every permit holder releases it (via defer) and each batch Exec is itself
// bounded by the connection's read/write timeout, so in normal operation a
// permit frees long before this. It is set well above the default ReadTimeout
// and a maintnotifications relaxed window so a legitimately slow in-flight batch
// never makes waiters fail spuriously. The wait deliberately does NOT end on
// Close: commands taken from the queue were already accepted, and Close's
// contract is to flush them (it waits via wg/batchWg), so permit waits run on
// a background context bounded only by this backstop.
const autoPipelinePermitBackstop = 30 * time.Second
// autoPipelineCloseBackstop bounds Close's wait for in-flight dispatches. It
// deliberately carries the same value as the permit backstop but its OWN name:
// the two answer different questions, and this one may want tuning on its own.
//
// Why it is generous rather than snappy: the bound is only ever REACHED when a
// dispatch cannot end by itself — a blocking command with no timeout, or a
// stalled read with ReadTimeout disabled. In every other configuration the
// read timeout ends the dispatch and Close returns the moment it does, well
// under this value. A tighter bound would not speed up healthy shutdowns; it
// would instead make Close report failure while legitimate work is still
// finishing (a large final batch, or a maintnotifications relaxed window
// during a failover), turning a correct slow drain into a spurious error.
const autoPipelineCloseBackstop = 30 * time.Second
// numAutoPipelineShards is the shard-count default used by CLUSTER wiring,
// where commands are routed to shards by slot so different nodes' batches
// queue independently (every shard keeps at least one concurrency permit, so
// several shards can flush to their nodes in parallel regardless of
// MaxConcurrentBatches). It is NOT used for standalone clients: those default
// to one shard (see newAutoPipeliner), because a single deep queue pipelines
// far better than a fragmented one. Deliberately NOT derived from
// MaxConcurrentBatches — coupling shard count to the permit budget silently
// collapsed cluster slot routing to a single shard at the default budget.
func numAutoPipelineShards() int {
n := runtime.GOMAXPROCS(0)
if n < 1 {
n = 1
}
const maxShards = 16
if n > maxShards {
n = maxShards
}
return n
}
// DefaultAutoPipelineOptions returns the default autopipelining configuration.
//
// The default is ordered: MaxConcurrentBatches is 1, so batches execute
// serially in submit order (a single ordered command stream) while still
// reaching high throughput via deep pipelines when callers submit in windows.
// To trade ordering for parallel-batch throughput, set MaxConcurrentBatches > 1
// together with Unordered: true.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func DefaultAutoPipelineOptions() *AutoPipelineOptions {
return &AutoPipelineOptions{
MaxBatchSize: 200,
MaxBatchBytes: 128 * 1024, // see MaxBatchBytes doc: full-duplex deadlock guardrail, not a throughput knob
MaxConcurrentBatches: 1, // ordered by default
MaxFlushDelay: 0, // lowest latency; no coalescing wait (batch via in-flight backpressure)
}
}
// DefaultBlockingAutoPipelineOptions returns the default config for the
// blocking face (Client.AutoPipeline). It uses a single ordered batch stream
// (MaxConcurrentBatches: 1). Counterintuitively this maximizes throughput AND
// minimizes latency for the blocking face: with one batch in flight, callers whose
// commands return while it executes re-enqueue and flush together as the next
// batch, so batches stay deep (a near-continuous, double-buffered pipeline),
// while a lone caller flushes promptly in a single round-trip (no coalescing
// wait — see accumulateBatch). More parallel permits (MaxConcurrentBatches>1) do the
// opposite: each command finds a free permit and flushes on its own before
// others accumulate, collapsing batch size — and throughput — toward one command
// per round-trip while latency rises. For maximum throughput use the async face
// (AsyncAutoPipeline) with a window of in-flight commands (inflight>1); it keeps
// MaxConcurrentBatches: 1 as well.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func DefaultBlockingAutoPipelineOptions() *AutoPipelineOptions {
return &AutoPipelineOptions{
MaxBatchSize: 300,
MaxBatchBytes: 128 * 1024, // see MaxBatchBytes doc: full-duplex deadlock guardrail, not a throughput knob
MaxConcurrentBatches: 1,
}
}
// Validate reports whether the configuration is self-consistent. It returns an
// error if MaxConcurrentBatches > 1 without Unordered: true — raising
// concurrency gives up command ordering, so the caller must opt in explicitly.
//
// Validate()==nil does not guarantee construction succeeds: rules that need
// the face (e.g. NumShards>1 requires Unordered on the deferred face) are
// enforced by the AutoPipeline/AsyncAutoPipeline getters. Note also that
// Options.AutoPipelineOptions is validated lazily — on the first getter
// call, not in NewClient.
func (cfg *AutoPipelineOptions) Validate() error {
if cfg.FullDuplex {
// Full-duplex matches replies to commands by FIFO position on one connection,
// which Unordered / parallel batches break. Checked BEFORE the generic
// MaxConcurrentBatches rule so the message is FullDuplex-specific.
if cfg.Unordered {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplex requires an ordered stream " +
"(Unordered:false); full-duplex matches replies by in-flight FIFO position, which " +
"Unordered breaks")
}
if cfg.MaxConcurrentBatches > 1 {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplex requires MaxConcurrentBatches<=1 "+
"(an ordered single stream); got %d", cfg.MaxConcurrentBatches)
}
// A USER-set NumShards>1 contradicts FullDuplex the same way (one held FIFO
// connection is one stream); reject it rather than silently falling back to
// half-duplex. contentSharded is exempt: that flag is set by the CLUSTER
// wiring (never by users), where the silent fallback IS the documented
// behavior, since the options type cannot see the client type.
if cfg.NumShards > 1 && !cfg.contentSharded {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplex requires NumShards<=1 "+
"(one held FIFO connection is a single stream); got %d", cfg.NumShards)
}
}
if cfg.MaxConcurrentBatches > 1 && !cfg.Unordered {
return fmt.Errorf("redis: AutoPipelineOptions.MaxConcurrentBatches=%d requires Unordered:true "+
"(parallel batches do not preserve command ordering); set Unordered:true to allow it, "+
"or keep MaxConcurrentBatches=1 for an ordered stream", cfg.MaxConcurrentBatches)
}
// Reject obviously-wrong negatives so a typo surfaces at construction rather
// than being silently coerced to a default. Zero is allowed and means "use
// the default" (MaxBatchSize) or "no delay" (MaxFlushDelay).
if cfg.MaxBatchSize < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.MaxBatchSize=%d must be >= 0", cfg.MaxBatchSize)
}
if cfg.MaxBatchBytes < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.MaxBatchBytes=%d must be >= 0", cfg.MaxBatchBytes)
}
if cfg.MaxConcurrentBatches < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.MaxConcurrentBatches=%d must be >= 0", cfg.MaxConcurrentBatches)
}
if cfg.MaxFlushDelay < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.MaxFlushDelay=%s must be >= 0", cfg.MaxFlushDelay)
}
if cfg.NumShards < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.NumShards=%d must be >= 0", cfg.NumShards)
}
if cfg.AdaptiveDelay && cfg.MaxFlushDelay <= 0 {
return fmt.Errorf("redis: AutoPipelineOptions.AdaptiveDelay requires MaxFlushDelay > 0 " +
"(adaptive delay scales MaxFlushDelay by queue fill; with no MaxFlushDelay it would " +
"silently disable batch accumulation entirely)")
}
// The full-duplex tuning fields are consumed only when FullDuplex is enabled
// (newFDEngine resolves them; the half-duplex path never reads them), so validate
// them only then. Otherwise a leftover negative on an inactive field would reject
// an otherwise valid half-duplex config.
if cfg.FullDuplex {
if cfg.FullDuplexWindow < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplexWindow=%d must be >= 0 (0 = default)", cfg.FullDuplexWindow)
}
if cfg.FullDuplexIdleTimeout < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplexIdleTimeout=%s must be >= 0 (0 = default)", cfg.FullDuplexIdleTimeout)
}
if cfg.FullDuplexMaxHold < 0 {
return fmt.Errorf("redis: AutoPipelineOptions.FullDuplexMaxHold=%s must be >= 0 (0 = default)", cfg.FullDuplexMaxHold)
}
}
return nil
}
// cmdableClient is an interface for clients that support pipelining.
// Both Client and ClusterClient implement this interface. It embeds
// UniversalClient (Cmdable + Process + Do + AddHook + Watch + Subscribe... +
// Close + PoolStats) so the AutoPipeliner can delegate the non-batched surface
// back to the underlying client and itself satisfy UniversalClient.
type cmdableClient interface {
UniversalClient
// processPipelineHook is the hook-wrapped []Cmder pipeline entry — the same
// method Pipeline.Exec is wired to (see Client.Pipeline). The flusher
// dispatches drained batches through it directly, skipping the per-batch
// Pipeline construction; hooks/OTel see the identical call.
processPipelineHook(ctx context.Context, cmds []Cmder) error
// The async faces additionally dispatch through withProcessPipelineHook /
// withProcessHook with the base processors as the innermost, so the batch
// can be completed UNDER the user hooks (results ready the moment exec
// returns, before hooks unwind). Both *Client and *ClusterClient satisfy
// these via hooksMixin and their base processors.
withProcessPipelineHook(ctx context.Context, cmds []Cmder, hook ProcessPipelineHook) error
hookCount() int
withProcessHook(ctx context.Context, cmd Cmder, hook ProcessHook) error
processPipeline(ctx context.Context, cmds []Cmder) error
process(ctx context.Context, cmd Cmder) error
}
// apBatch is the completion signal shared by every command flushed together.
// Its done channel is closed exactly once, when the batch's pipeline has
// executed. Closing one channel wakes all waiters in a single operation,
// instead of doing one buffered-channel send per command — under high
// concurrency the per-command sends dominated CPU (channel-lock contention and
// one goroutine wake-up apiece).
type apBatch struct {
done chan struct{}
// closed makes close() idempotent: on the async faces the dispatch closes
// the batch at the innermost exec seam (under the user hooks, so a hook
// reading a result after next() does not block on a channel its own
// goroutine closes — the #3867 deadlock), while the flusher keeps its
// deferred close as a panic backstop. Whichever runs first wins.
closed atomic.Bool
// dispGid is the goroutine id of the dispatcher while the batch is inside
// the hook chain (0 otherwise). await() consults it before blocking so a
// hook on the dispatch goroutine reading a result BEFORE next() gets the
// not-yet-executed view — what a plain pipeline hook sees — instead of a
// self-deadlock.
dispGid atomic.Int64
// nodeGids registers cluster per-node executor goroutines: the cluster
// pipeline fans a batch out to one goroutine per node, and each runs the
// NODE client's own hook chain (OnNewNode hooks — redisotel's tracing
// lives there), which the single dispGid slot cannot vouch for. A node
// hook reading a result there would block on a batch that completes only
// after its own return — reproduced as a permanent wedge with a
// rediscmd-shaped Err() peek. Guarded by nodeMu; entered/left once per
// node call, consulted only on the guards' slow path (done still open).
nodeMu sync.Mutex
nodeGids []int64
// nodeCount mirrors len(nodeGids) so isExecutorGoroutine's fast path can
// skip the goroutine-id parse and the mutex entirely when nobody is
// registered — which is every standalone batch, always, and a cluster
// batch outside its node fan-out window.
nodeCount atomic.Int32
// pooled marks a batch drawn from fdBlockingBatchPool: its done channel is
// buffered(1) and completion signals via a non-blocking SEND (see close) so
// the channel is reusable, instead of the close()-once unbuffered channel
// every other batch uses. Only the full-duplex BLOCKING face produces these
// — the one path where the batch is a single-waiter completion signal that
// is never installed on the command (no setReady) and is discarded after
// Wait. Immutable for the batch's lifecycle; set at construction.
pooled bool
}
// enterNodeDispatch registers the calling goroutine as an executor of this
// batch for the duration of a cluster node call; the returned func
// unregisters it. Registered goroutines get the same treatment as the
// dispatcher in the accessor guards: result reads return the current view
// instead of self-deadlocking on the batch's own completion signal.
func (b *apBatch) enterNodeDispatch() func() {
gid := curGoroutineID()
b.nodeMu.Lock()
b.nodeGids = append(b.nodeGids, gid)
b.nodeCount.Store(int32(len(b.nodeGids)))
b.nodeMu.Unlock()
return func() {
b.nodeMu.Lock()
for i, g := range b.nodeGids {
if g == gid {
b.nodeGids[i] = b.nodeGids[len(b.nodeGids)-1]
b.nodeGids = b.nodeGids[:len(b.nodeGids)-1]
break
}
}
b.nodeCount.Store(int32(len(b.nodeGids)))
b.nodeMu.Unlock()
}
}
// isExecutorGoroutine reports whether the CALLING goroutine is currently
// executing this batch: the flusher/dispatch goroutine or a registered
// cluster node executor. The no-executor fast path (dispGid unset and no
// node executors) is two atomic loads — no goroutine-id parse, no lock. That
// laziness is load-bearing: every blocking-face command and every pre-done
// future passes here once per wait, and an earlier revision that parsed the
// goroutine id and took the mutex unconditionally cost the blocking face 6x
// of its throughput (measured 830k -> 138k ops/sec on a loopback bench).
func (b *apBatch) isExecutorGoroutine() bool {
disp := b.dispGid.Load()
if disp == 0 && b.nodeCount.Load() == 0 {
return false
}
gid := curGoroutineID()
if disp != 0 && disp == gid {
return true
}
if b.nodeCount.Load() == 0 {
return false
}
b.nodeMu.Lock()
defer b.nodeMu.Unlock()
for _, g := range b.nodeGids {
if g == gid {
return true
}
}
return false
}
// noopUnregister is registerBatchExecutors' zero-batch result, shared so the
// plain-pipeline path stays allocation-free.
var noopUnregister = func() {}
// registerBatchExecutors marks the calling goroutine as an executor of every
// deferred-face batch among cmds (plain pipeline commands carry none) and
// returns the combined unregister. The cluster pipeline calls it around each
// node's hook chain.
func registerBatchExecutors(cmds []Cmder) func() {
var undo []func()
var seenFirst *apBatch
var seenMore map[*apBatch]struct{}
for _, cmd := range cmds {
bc, ok := cmd.(interface{ readyBatch() *apBatch })
if !ok {
continue
}
b := bc.readyBatch()
if b == nil || b == seenFirst {
continue
}
if seenFirst == nil {
seenFirst = b
} else {
if seenMore == nil {
seenMore = make(map[*apBatch]struct{}, 2)
}
if _, dup := seenMore[b]; dup {
continue
}
seenMore[b] = struct{}{}
}
undo = append(undo, b.enterNodeDispatch())
}
if len(undo) == 0 {
return noopUnregister
}
return func() {
for _, u := range undo {
u()
}
}
}
func newAPBatch() *apBatch { return &apBatch{done: make(chan struct{})} }
// fdBlockingBatchPool recycles apBatch objects for the full-duplex BLOCKING
// face — the single path where a batch is a pure, single-waiter completion
// signal: that face never setReady()s the command (so the batch is invisible to
// await/readyBatch/resultReady — verified: those all read cmd.ready, set only by
// setReady) and discards the batch right after Wait returns. Pooled batches use
// a buffered(1) done channel signalled by a non-blocking SEND (see close), so
// the channel — the bulk of newAPBatch's ~190 B/op — is reused rather than
// closed and thrown away. Every other batch (async face, shared flush batches
// with many/repeat readers) keeps the unbuffered close()-once channel.
var fdBlockingBatchPool = sync.Pool{
New: func() any { return &apBatch{done: make(chan struct{}, 1), pooled: true} },
}
// getFDBlockingBatch returns a reset pooled batch. Fields are cleared
// individually (go vet copylocks forbids *b = apBatch{} because of nodeMu); a
// stale closed=true would make the next completion signal a no-op and park the
// caller forever, so the reset is not optional.
func getFDBlockingBatch() *apBatch {
b := fdBlockingBatchPool.Get().(*apBatch)
b.closed.Store(false)
b.dispGid.Store(0)
b.nodeCount.Store(0)
b.nodeGids = nil
// Drain any stray signal so the reused channel starts empty. Insurance: the
// blocking face always drains done in Wait, so this is normally a no-op.
select {
case <-b.done:
default:
}
return b
}
// putFDBlockingBatch returns a pooled batch after its single waiter has woken.
// Safe only once the batch is complete and unreferenced (see processBlocking).
func putFDBlockingBatch(b *apBatch) {
if b == nil || !b.pooled {
return
}
fdBlockingBatchPool.Put(b)
}
// close completes the batch exactly once, waking its waiter(s).
func (b *apBatch) close() {
if b.closed.CompareAndSwap(false, true) {
if b.pooled {
// Buffered(1) done: signal with a non-blocking send so the channel
// stays reusable (a closed channel cannot be reused). The CAS makes
// exactly one send and cap 1 makes it never block; the one blocking
// waiter (AutoFuture.Wait) drains it. Every completer — reader,
// failReqs, shutdownFlush, flushBacklogForClose — funnels through
// here, so this single branch covers them all.
select {
case b.done <- struct{}{}:
default:
}
return
}
close(b.done)
}
}
// curGoroutineID parses the goroutine id from runtime.Stack's header
// ("goroutine 123 ["). Called only on paths already paying a dispatch or an
// about-to-block round-trip wait — never on await()'s fast path — so the
// microsecond-scale stack read is noise against the batch RTT.
// armSelfDeadlockGuard reports whether async dispatch should stamp the
// dispatcher's goroutine id on the batches (see apBatch.dispGid) — the
// mechanism that lets a hook on the dispatch goroutine read a command
// without deadlocking on a batch only that goroutine completes: before
// next() it sees the not-yet-executed view, after next() the populated
// results (batches complete only when the whole chain has returned). Armed
// when user hooks exist — without hooks nothing can read a command inside
// the chain — and always on cluster clients, whose node clients may carry
// their own hooks (OnNewNode + AddHook, the redisotel pattern) that
// hookCount() cannot see. NOTE: node-level hooks run on node-worker
// goroutines the gid guard cannot identify, so they must not read command
// results on the async face; the same applies to a goroutine a hook spawns
// and joins before returning. A hook added concurrently with an in-flight
// dispatch misses the guard for that one batch. The guard covers result
// READS only: a hook that ISSUES a command on the same AutoPipeliner and
// synchronously waits for it cannot be saved — the nested command needs the
// dispatch slot the hook chain is holding, and the engine recovers only by
// failing the flush after the permit backstops (see
// autoPipelinePermitBackstop) expire.
func (ap *AutoPipeliner) armSelfDeadlockGuard() bool {
return ap.pipeliner.hookCount() > 0 || ap.config.contentSharded
}
func curGoroutineID() int64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
const skip = len("goroutine ")
var id int64
for _, c := range buf[skip:n] {
if c < '0' || c > '9' {
break
}
id = id*10 + int64(c-'0')
}
return id
}
// The shard queue stores bare Cmders. The batch a command waits on is the
// shard's curBatch at enqueue time — read once to wire the command's ready
// channel and never needed per-command afterward (the flusher closes the one
// shared batch). Storing []Cmder removes a per-command wrapper allocation.
var queueSlicePool = sync.Pool{
New: func() interface{} { s := make([]Cmder, 0, 100); return &s },
}
func getQueueSlice(capacity int) []Cmder {
slice := (*queueSlicePool.Get().(*[]Cmder))[:0]
if cap(slice) < capacity {
queueSlicePool.Put(&slice)
return make([]Cmder, 0, capacity)
}
return slice
}
func putQueueSlice(slice []Cmder) {
if cap(slice) <= 1000 {
// Zero only the used prefix: elements beyond len are already nil —
// slices enter the pool fully zeroed (here) and are only appended to
// afterwards, so the tail invariant holds. Zeroing the whole capacity
// memclr'd up to 8 KB per flush for small batches on large recycled
// arrays.
for i := range slice {
slice[i] = nil
}
queueSlicePool.Put(&slice)
}
}
// AutoPipeliner automatically batches commands and executes them in pipelines.
// It's safe for concurrent use by multiple goroutines.
//
// AutoPipeliner works by collecting commands from multiple goroutines into a
// shared queue and flushing them as one Redis pipeline when the batch reaches
// MaxBatchSize or a configured coalescing window (MaxFlushDelay) elapses. By
// default there is no window: each batch flushes as soon as the queue is ready
// and concurrent callers coalesce via in-flight backpressure, so a lone command
// flushes in a single round-trip while batches stay deep under load.
//
// This provides significant performance improvements for workloads with many
// concurrent small operations, as it reduces the number of network round-trips.
//
// AutoPipeliner implements the Cmdable interface, so you can use it like a
// regular client. Prefer the typed methods (Set, Get, ...); Do runs OUTSIDE
// the pipeline on a normal connection (see Do).
// AutoPipeline / AsyncAutoPipeline return an error for an invalid config, so check it once:
//
// ap, err := client.AutoPipeline()
// if err != nil {
// return err
// }
// ap.Set(ctx, "key", "value", 0)
// ap.Get(ctx, "key")
// ap.Close()
//
// Per-command contexts: a command is batched and executed on the AutoPipeliner's
// own long-lived context, NOT the context passed to the command. A per-command
// deadline or cancellation is therefore not honored once the command is queued
// (this is deliberate — a per-batch timer per command would cost a goroutine
// each). Use a plain client for commands that need their own deadline.
// The one exception is a blocking command (readTimeout() != nil, e.g. BLPOP):
// it is never batched and runs directly on the caller's context, which is
// honored as usual.
//
// Retries: like any pipeline, a batch that fails on a network error is retried
// as a whole (up to Options.MaxRetries). If the connection drops after the
// server executed part of the batch, non-idempotent commands (INCR, LPUSH, ...)
// may execute twice. Run commands that must not be retransmitted on a plain
// client, or set MaxRetries: -1.
//
// Lifetime: AutoPipeline() returns a single, client-owned instance shared by all
// callers. Close()ing it stops the shared pipeliner for everyone; a later
// AutoPipeline() call on the client builds a fresh one. Closing the CLIENT also
// stops it, but permanently: the getters then return ErrClosed.
//
// Formatting: String()/%v on a command issued by the deferred face WAITS for
// execution, exactly like Err()/Val()/Result() — formatting reads the result
// fields, and reading them unsynchronized would race the dispatcher populating
// them. The one exception is a hook formatting a command from the batch's own
// dispatch goroutine: that returns the not-yet-executed view instead of
// self-deadlocking. Use Name()/Args() if you need to log a submission without
// waiting for it.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
type AutoPipeliner struct {
cmdable // Embed cmdable to get all Redis command methods
pipeliner cmdableClient
config *AutoPipelineOptions
// fd, when non-nil, is the ordered full-duplex dispatch engine. When set,
// submit() streams on one held connection instead of the sharded batch queue
// and no shard flusher is started. See autopipeline_fullduplex.go.
fd *fdEngine
// clusterFD, when non-nil, runs ordered full-duplex natively on a
// *ClusterClient by routing each command to a per-node FD child autopipeliner
// (one held connection per master). Mutually exclusive with fd and with the
// half-duplex shard flushers: when set, submit() routes to the owning node's
// child and no shard flusher is started. See autopipeline_cluster_fd.go.
clusterFD *clusterFDRouter
// pipelinePool is the connection pool that backs autopipelined batch
// dispatch (distinct from the client's main pool). Captured once at
// construction via an in-package assertion; nil when the underlying client
// does not expose one (e.g. *ClusterClient). The straggler-hold reads it to
// tell whether flushing a tiny batch now would contend for a scarce pooled
// connection — see awaitExpectedArrivals / pipelineHasFreeConn.
pipelinePool pool.Pooler
// cscActiveFn reports whether client-side caching is CURRENTLY active on the
// underlying client (nil when the client type exposes none). Consulted per
// solo dispatch — not captured as a bool — because CSC can disable itself
// mid-life (RESP3 fallback, processor damping), after which cacheable solos
// should return to the pipeline pool instead of the main pool. Gates the
// cacheable-solo routing: only an active-CSC client routes through Process
// (which honors the cache).
cscActiveFn func() bool
// blocking selects how the typed command surface (Set, Get, ...) behaves:
// when true the command call itself blocks until the command has executed
// (drop-in, synchronous shape); when false the call returns immediately and
// the result accessors (Val/Result/Err) block. See AutoPipeline (blocking)
// vs AsyncAutoPipeline (deferred).
blocking bool
// Sharded command queues. Each shard has its own queue, mutex and flusher
// goroutine, so enqueues from many goroutines spread across shards instead
// of all contending on a single mutex and being drained by a single
// flusher. Commands are assigned to shards round-robin; per-goroutine
// ordering is still guaranteed because Do blocks for each command's result
// before issuing the next one.
shards []*apShard
next atomic.Uint32 // round-robin shard selector
// shardFn, when set, picks a command's shard from its content (cluster mode
// sets it to route by slot so all commands for one node land in the same
// shard's batch — keeping per-node pipelines deep instead of splitting every
// batch across nodes). When nil, commands are assigned round-robin.
shardFn func(Cmder) int
// preflight, when set, can reject a command at submit time, before it is
// enqueued or dispatched (cluster mode refuses fan-out-policy commands
// that cannot ride a pipeline, so one caller's command cannot poison a
// merged batch). The returned error is set on the command.
preflight func(ctx context.Context, cmd Cmder) error
// mustDivert, when set, forces a command off the batching path even though
// it is otherwise batchable — cluster mode uses it for commands whose
// routing is NOT slot-derived (ReqSpecial, e.g. FT.CURSOR READ, which is
// sticky to the node that owns the cursor). Batched, mapCmdsByNode would
// route them by slot and reach the wrong shard; diverted, they go through
// Client/ClusterClient.Process and keep their special routing.
mustDivert func(ctx context.Context, cmd Cmder) bool
// sharedClosed, when non-nil, is the owning client's pool-set closed flag
// (shared across WithTimeout clones). The getters refuse to build a fresh
// pipeliner once it is set; this reference makes an ALREADY-built
// pipeliner refuse new work too — without it, a clone's Close would leave
// a cached pipeliner accepting enqueues against closed pools, failing
// them one dispatch at a time instead of with ErrClosed at submit.
sharedClosed *atomic.Bool
// expectedArrivals counts how many commands the engine expects to arrive
// at any moment: a completed batch of N≥2 commands wakes its N waiters
// together, and in a closed loop each immediately submits its next command
// — so completion announces N expected arrivals, and every enqueue accounts
// for one. The default coalescing wait (awaitExpectedArrivals) holds the
// flusher while arrivals are still expected, so the whole wakeup wave
// flushes as one deep pipeline — an exact count, not a smoothed estimate,
// which cannot ratchet into fragmentation. Single-command batches announce
// nothing, so a lone caller and open-loop traffic never wait. May
// transiently go negative (arrivals nobody announced); readers clamp to
// zero. Pipeliner-global, not per-shard: cluster routing may land a
// follow-up on a different shard than the batch that woke its caller.
expectedArrivals atomic.Int64
// execEWMA is an exponentially-weighted moving average (alpha 1/8) of
// batch execution time in nanoseconds — the engine's own view of the
// server round-trip. It scales awaitExpectedArrivals's silence fallback so a
// wave staggered by scheduling on a slow link is not split mid-landing. Updates
// are racy read-modify-writes by design: losing an occasional sample is
// harmless for a smoothing heuristic. 0 means "no sample yet".
execEWMA atomic.Int64
// Lifecycle
ctx context.Context
cancel context.CancelFunc
// closeHooks / closeHookID: the shared baseClient onClose registry this engine
// registered a cancel callback on, and the UNIQUE id it used. Any pool-sharing
// wrapper's Close runs the registry and cancels this engine (so a clone closing
// the shared pools reaps it); ap.Close unregisters so hooks stay bounded and a
// closed engine's stale callback does not linger. The id is unique per engine —
// a client and its clone can both cache the same face and must not collide on a
// per-slot constant id (that would overwrite one hook and leak its engine).
closeHooks *onCloseHooks
closeHookID string
wg sync.WaitGroup // Tracks flusher goroutines
batchWg sync.WaitGroup // Tracks batch execution goroutines
// divertWg tracks the goroutines that execute DIVERTED commands (blocking
// and connection-hostile ones, which never enter a batch). Close waits on
// it exactly like batchWg so a diverted command's pooled connection is not
// left in flight after Close returns — bounded, see Close.
//
// divertMu serializes "observe not-closed, then register" against Close's
// "mark closed, then wait": without it a diverted command could pass the
// closed check, Close could see a zero counter and return, and only then
// would the goroutine register — leaving an accepted command holding a
// pooled connection past Close (and racing WaitGroup Add against Wait).
divertMu sync.Mutex
divertWg sync.WaitGroup
closed atomic.Bool
// closeDone is closed by the single Close that wins the closed CAS, once its
// drain has completed; closeErr then holds that drain's result. A concurrent
// Close that loses the CAS blocks on closeDone and returns closeErr, so no
// caller observes the engine as closed — and starts tearing down the pools
// underneath it — while accepted commands are still being flushed.
closeDone chan struct{}
closeErr error
// drainOnce memoizes cancelAndDrain's body: two closers can reach it for the
// same engine (an explicit AutoPipeliner.Close racing a pool-sharing wrapper's
// shared-pool close hook, which deliberately leaves ap.closed false). The body
// runs exactly once and writes closeErr inside the Once (so closeErr has no
// concurrent writer and WaitClosed reads it safely); both callers return that
// one result.
drainOnce sync.Once
// drainRuns counts drain-body executions. The Once holds it at 1 however many
// closers race, so a value > 1 means two closers double-drained the engine — the
// invariant this counter guards (and a duplicate-close diagnostic).
drainRuns atomic.Int64
}
// apShard is one queue + flusher. Its fields are touched only by enqueuing
// goroutines (under mu) and by its own single flusher goroutine.
// apEnqueueStripes is how many enqueue stripes a shard runs when striping is
// safe (unordered configs, and every blocking-face shard — a blocking caller
// waits for each command, so stripes cannot reorder its stream). The
// enqueue mutex is the hottest lock in the engine (128 concurrent callers on
// one shard spend ~half their CPU in lock slow paths); striping the queue
// spreads that contention while the flusher still drains every stripe into ONE
// merged pipeline, so batches stay deep. Ordered shards always use a single
// stripe: with several stripes a caller's consecutive commands can land in
// stripes on opposite sides of an in-progress drain and execute out of order.
const apEnqueueStripes = 8
// apStripe is one striped slice of a shard's enqueue queue. Each stripe has
// its own batch-completion signal so a drain can take stripes one lock at a
// time; every batch taken in one drain completes together after the merged
// pipeline executes. Padded so neighbouring stripes' mutexes do not share a
// cache line.
type apStripe struct {
mu sync.Mutex
queue []Cmder
queueLen atomic.Int32
// queueBytes approximates the queued commands' payload volume; maintained
// only when MaxBatchBytes is configured (see cmdApproxBytes).
queueBytes atomic.Int64
curBatch *apBatch // completion signal for currently-queued cmds
// Pad each stripe onto its own cache line(s). Without it, one stripe's hot
// fields (queueLen/curBatch) share a cache line with the NEXT stripe's
// contended mutex, so a lock-free counter bump on stripe i invalidates the
// line a different core is trying to lock stripe i+1 on — false sharing
// that measured ~16x on a contended microbenchmark. cpu.CacheLinePad is
// sized per GOARCH (64 B on x86-64/arm64, 128 B on ppc64, 256 B on s390x),
// so this is correct on every target rather than a hand-tuned constant.
_ cpu.CacheLinePad
}
type apShard struct {
ap *AutoPipeliner
next atomic.Uint32 // round-robin stripe pick (unordered mode)
stripes []apStripe // 1 stripe when ordered, apEnqueueStripes when Unordered
notify chan struct{} // buffered (cap 1) enqueue wake-up
sem *internal.FIFOSemaphore // per-shard concurrent-batch budget
// inFlight counts this shard's dispatched-but-unfinished batches. When it
// is zero and no arrivals are expected, the shard is idle and a
// new command flushes immediately; when batches are in flight, arrivals
// are mid-stream and the flusher holds them briefly to coalesce (see
// awaitExpectedArrivals).
inFlight atomic.Int32
}
// stripe picks the enqueue stripe for the next command: the single stripe in
// ordered mode (preserving strict FIFO), round-robin in unordered mode.
func (s *apShard) stripe() *apStripe {
if len(s.stripes) == 1 {
return &s.stripes[0]
}
return &s.stripes[s.next.Add(1)%uint32(len(s.stripes))]
}
// getOrCreateAutoPipeliner is the shared caching protocol behind the four
// AutoPipeline/AsyncAutoPipeline getters (Client and ClusterClient, each
// face): return the cached live instance, refuse on a closed client, or build
// and cache a new one. The caller supplies its cached-slot pointer, its
// closed flag (both guarded by the mutex), the explicit-config override, the
// fallback config, and a build closure (the cluster one wraps
// clusterAutoPipelineOptions and installs slot sharding).
func getOrCreateAutoPipeliner(
mu *sync.Mutex,
slot **AutoPipeliner,
closed *bool,
sharedClosed *atomic.Bool,
onClose *onCloseHooks,
closeHookBaseID string,
override *AutoPipelineOptions,
fallback func() *AutoPipelineOptions,
build func(*AutoPipelineOptions) (*AutoPipeliner, error),
) (*AutoPipeliner, error) {
mu.Lock()
defer mu.Unlock()
// closed covers THIS wrapper's Close; sharedClosed covers the shared
// pools closing through ANY sharer (e.g. a WithTimeout clone falling
// through to baseClient.Close) — a fresh pipeliner against closed pools
// would leak flushers that error forever.
if *closed || (sharedClosed != nil && sharedClosed.Load()) {
return nil, ErrClosed
}
if *slot != nil && !(*slot).closed.Load() {
return *slot, nil
}
cfg := override
if cfg == nil {
cfg = fallback()
}
ap, err := build(cfg)
if err != nil {
return nil, err
}
// Thread the shared pool-set closed flag into the pipeliner so an
// ALREADY-cached instance also refuses enqueues once any sharer closes
// the pools (the check above only protects fresh builds).
ap.sharedClosed = sharedClosed
// Register the shared-pool close hook ONCE, here under mu on the FRESH build —
// not per call outside the lock, which would race concurrent first-callers and
// register a hook per caller. A UNIQUE id per engine: a client and its
// WithTimeout clone share onClose, so a per-slot constant id would overwrite one
// registration and leak its engine. ap.Close unregisters by this id. onClose is
// nil for cluster/ring (they pass a nil shared flag and have no clone-close leak).
registered := true
if onClose != nil {
id := fmt.Sprintf("%s#%d", closeHookBaseID, apCloseHookSeq.Add(1))
ap.closeHooks = onClose
ap.closeHookID = id
registered = onClose.register(id, func() error {
// cancelAndDrain, not bare cancel: a pool-sharing wrapper's Close must WAIT
// for this engine's shutdown flush to finish before closeResources tears the
// shared pools down — cancel-and-return would let the pools close mid-flush
// and fail accepted work. It is bounded (the drainAll backstop) so a wedged
// flush cannot hang the closing wrapper. It deliberately does NOT set
// ap.closed (the engine is rejected via the shared-closed flag) nor detach
// this hook, so a later owner Close still runs the full teardown.
return ap.cancelAndDrain()
})
}
// Re-check after registering: a concurrent sharer Close sets sharedClosed and
// runs onClose (snapshotting its callbacks) without this slot's mutex, so it can
// pass the entry check above, snapshot the hooks, and miss the registration just
// made — leaving this freshly built engine's goroutines parked on already-closed
// pools forever. Two signals catch it: register reports false once run has taken
// its snapshot, and baseClient.Close sets sharedClosed BEFORE running onClose, so
// a close that has started — snapshot taken or not yet — shows in the flag.
//
// Either way the engine must be fully stopped before this returns, not merely
// cancelled and abandoned (cursor bugbot on #4002): closeResources is tearing the
// shared pools down, or is about to having missed the hook that would make it
// wait, so it cannot be relied on to wait for this engine. cancelAndDrain does
// the waiting here instead — the engine accepted no work (never published), so
// this is its flushers observing the cancel, bounded by the drainAll backstop;
// drainOnce makes it safe alongside a close that did snapshot the hook. Then
// detach the hook and refuse rather than cache a doomed instance.
if !registered || (sharedClosed != nil && sharedClosed.Load()) {
_ = ap.cancelAndDrain()
if onClose != nil {
onClose.unregister(ap.closeHookID)
}
return nil, ErrClosed
}
*slot = ap
return ap, nil
}
// newAutoPipeliner builds an autopipeliner in either blocking or deferred mode.
// It is unexported on purpose: the public entry points are
// Client/ClusterClient.AutoPipeline and AsyncAutoPipeline, which also install
// cluster slot-sharding. Constructing one directly would skip that wiring and
// give a *ClusterClient degraded (cross-node) batching.
func newAutoPipeliner(pipeliner cmdableClient, config *AutoPipelineOptions, blocking bool) (*AutoPipeliner, error) {
if config == nil {
config = DefaultAutoPipelineOptions()
} else {
// Copy so default-filling below doesn't mutate the caller's struct — the
// same *AutoPipelineOptions may be shared across clients (e.g. a reused
// Options.AutoPipelineOptions), and callers may inspect it afterward.
cfgCopy := *config
config = &cfgCopy
}
// Validate BEFORE default-filling: Validate treats zero as "use the
// default" but rejects negatives, and coercing first would silently
// swallow a negative typo the documented contract promises to error on.
if err := config.Validate(); err != nil {
return nil, err
}
// Apply defaults for zero values
if config.MaxBatchSize <= 0 {
config.MaxBatchSize = 200
}
if config.MaxBatchBytes <= 0 {
// Full-duplex deadlock guardrail, not a throughput knob — see the
// MaxBatchBytes field doc. Applies here too so a caller-constructed
// config that leaves this zero (rather than going through
// DefaultAutoPipelineOptions) still gets the safety net.
config.MaxBatchBytes = 128 * 1024
}
if config.MaxConcurrentBatches <= 0 {
// Default to an ordered single stream. Callers raise this (with
// Unordered:true) to opt into parallel-batch throughput.
config.MaxConcurrentBatches = 1
}
// NumShards > 1 on the deferred (async) face distributes commands
// round-robin across shards that flush concurrently, so submit order is
// not preserved — require the explicit Unordered opt-in, exactly like
// MaxConcurrentBatches > 1. The blocking face is exempt (each caller waits
// per command, and Submit is rejected there), as is cluster slot sharding
// (contentSharded: same-key commands always land in the same shard, so
// per-key order holds).
if config.NumShards > 1 && !config.Unordered && !blocking && !config.contentSharded {
return nil, fmt.Errorf(
"redis: AutoPipelineOptions.NumShards=%d requires Unordered:true on the deferred (async) face "+
"(commands are distributed round-robin across shards, which flush concurrently and do not preserve submit order)",
config.NumShards,
)
}
ctx, cancel := context.WithCancel(context.Background())
ap := &AutoPipeliner{
pipeliner: pipeliner,
config: config,
blocking: blocking,
ctx: ctx,
cancel: cancel,
closeDone: make(chan struct{}),
}
// Capture the pipeline pool (in-package, promoted to *Client). nil for a
// client that has none (e.g. *ClusterClient) — the straggler-hold then
// keeps its conservative long hold rather than guess at pool pressure.
if pp, ok := pipeliner.(interface{ getPipelinePool() pool.Pooler }); ok {
ap.pipelinePool = pp.getPipelinePool()
}
// CSC probe (in-package assertion; *ClusterClient does not expose it) — see
// the cscActiveFn field doc.
if cc, ok := pipeliner.(interface{ autopipelineCSCActive() bool }); ok {
ap.cscActiveFn = cc.autopipelineCSCActive
}
// Route the typed command surface. Blocking: the command call blocks until
// executed (synchronous drop-in shape). Deferred: the call returns at once
// and the result accessors block until the batch executes.
if blocking {
ap.cmdable = ap.processBlocking
} else {
ap.cmdable = ap.processAsync
}
// Pick the shard count. NumShards=0 (auto) means ONE shard: a single deep
// queue outperforms a sharded one because batches stay large — sharding by
// core count coupled batch fragmentation to MaxConcurrentBatches and
// collapsed pipelining (measured: 16 shards cut async throughput ~4x and
// tripled latency versus one shard at the same permit count). Cluster
// wiring passes an explicit NumShards so slot-routed shards keep each
// batch on one node.
nShards := config.NumShards
if nShards <= 0 {
nShards = 1
}
// Cluster full-duplex: a *ClusterClient cannot host an fdEngine (it has no
// pipeline pool of its own), but every master node's node.Client is a
// standalone *Client that gets one by default. When full-duplex is requested
// on the ordered single-face cluster autopipeliner, route each command to a
// per-node FD child (clusterFDRouter) instead of the half-duplex shard
// flushers. Gate on cc.opt.PipelinePoolSize >= 0 — the exact predicate that
// decides whether every node.Client gets a pipeline pool (osscluster.go passes
// it through, redis.go creates the pool on it) — so the check is synchronous
// and needs no topology load under the getter's mutex. Force a single
// flusherless shard, exactly like the fdOn path: no half-duplex flusher runs
// and enqueue's shard indexing stays safe (never a %0), even though submit
// routes past it.
var clusterFDCC *ClusterClient
clusterFDOn := false
if config.FullDuplex && !config.Unordered && config.MaxConcurrentBatches <= 1 {
// The router always routes to the slot's MASTER node child (slotMasterNode).
// A client configured for replica routing — ReadOnly, RouteByLatency, or
// RouteRandomly — would have those options silently ignored under cluster FD,
// pinning reads to masters. Fall back to the half-duplex shard flushers, which
// route through Process and honor the configured ShardPicker, and let
// Config().FullDuplex report false (honest: FD is not the effective mode).
// RouteByLatency/RouteRandomly auto-enable ReadOnly at option init (before this
// gate reads them), so !ReadOnly alone would cover all three; the explicit
// three document intent and are robust to any init reordering.
if cc, ok := pipeliner.(*ClusterClient); ok &&
cc.opt.PipelinePoolSize >= 0 &&
!cc.opt.ReadOnly && !cc.opt.RouteByLatency && !cc.opt.RouteRandomly {
clusterFDOn, clusterFDCC = true, cc
nShards = 1
// Report the actual shard count (1), not the cluster default, from Config().
config.NumShards = 1
// Resolve the FD tuning defaults on the PARENT config now, mirroring
// newFDEngine (which only writes them onto each child's config). Without
// this, Config() on a default-constructed cluster-FD autopipeliner would
// report zero for these while the children enforce nonzero defaults —
// breaking the effective-defaults contract the standalone FD path honors.
// config is the same pointer Config() reads; this runs at construction
// before ap escapes, so no Config() reader races these writes.
if config.FullDuplexWindow <= 0 {
config.FullDuplexWindow = fdDefaultWindow
}
if config.FullDuplexIdleTimeout <= 0 {
config.FullDuplexIdleTimeout = fdDefaultIdle
}
if config.FullDuplexMaxHold <= 0 {
config.FullDuplexMaxHold = fdDefaultMaxHold
}
}
}
// Split the concurrent-batch budget across shards so each shard has its own
// semaphore. A single shared semaphore became a contention point once the
// per-shard queue mutexes were no longer the bottleneck. Integer division
// drops a remainder, so hand the leftover permits to the first shards: the
// per-shard permits then sum to exactly MaxConcurrentBatches.
perShard := config.MaxConcurrentBatches / nShards
remainder := config.MaxConcurrentBatches % nShards
if perShard < 1 {
// Budget smaller than the shard count: give every shard one permit so
// each flusher can still make progress. The sum then exceeds the
// configured budget, which is unavoidable with per-shard semaphores.
perShard = 1
remainder = 0
}
// Ordered full-duplex: the ordered single-shard face on a standalone *Client
// with a pipeline pool, async or blocking. When on, submit() streams on one
// held connection and no shard flusher runs. The blocking face needs nothing
// extra: submit's fd branch skips setReady (the blocking contract) and
// processBlocking Waits on the returned batch, as for a half-duplex enqueue.
var fdClient *Client
fdOn := false
if config.FullDuplex && !config.Unordered && config.MaxConcurrentBatches <= 1 && nShards == 1 {
if c, ok := pipeliner.(*Client); ok && c.getPipelinePool() != nil {
fdOn, fdClient = true, c
}
}
// Publish the EFFECTIVE full-duplex state, not the requested one: FullDuplex
// engages on a standalone *Client with a pipeline pool (fdOn) or on a
// *ClusterClient whose node clients have pipeline pools (clusterFDOn, via the
// per-node router). On a client with no pipeline pool it is a no-op and the
// engine falls back to the half-duplex shard flushers. Config() promises what
// the engine actually runs, so a requested-but-inactive FullDuplex must report
// false rather than claim a mode the instance is not in. Only Config() reads
// this after here.
ap.config.FullDuplex = fdOn || clusterFDOn
ap.shards = make([]*apShard, nShards)
for i := range ap.shards {
permits := perShard
if i < remainder {
permits++
}
// Stripe when reordering is impossible or waived: a BLOCKING caller
// waits for each command before issuing its next, so its per-goroutine
// order holds no matter which stripe each command lands in; the async
// face may only stripe when the user set Unordered. The remaining case
// (async, ordered) keeps one stripe to preserve strict submit order.
nStripes := 1
if config.Unordered || blocking {
nStripes = apEnqueueStripes
}
s := &apShard{
ap: ap,
notify: make(chan struct{}, 1),
stripes: make([]apStripe, nStripes),
sem: internal.NewFIFOSemaphore(int32(permits)),
}
for j := range s.stripes {
// In full-duplex mode submissions go straight to the FD engine (fd.ch),
// or — on a cluster — to a per-node FD child (clusterFD); the shard
// queues are never enqueued to and no flusher drains them, so do NOT
// preallocate them to MaxBatchSize. Otherwise a large MaxBatchSize with
// a small FullDuplexWindow would allocate MaxBatchSize slots per stripe
// (times apEnqueueStripes on the blocking face) up front — tens of MB or an
// OOM before any command is sent. A nil queue is safe: nothing appends to
// it while a full-duplex engine is active, and Len reads the atomic
// counter, not the slice.
if !fdOn && !clusterFDOn {
s.stripes[j].queue = getQueueSlice(config.MaxBatchSize)
}
s.stripes[j].curBatch = newAPBatch()
}
ap.shards[i] = s
if !fdOn && !clusterFDOn {
ap.wg.Add(1)
go s.flusher()
}
}
if fdOn {
ap.fd = newFDEngine(ap, fdClient)
ap.wg.Add(1)
go ap.fd.run()
}
if clusterFDOn {
ap.clusterFD = newClusterFDRouter(ap, clusterFDCC, config, blocking)
}
return ap, nil
}
// Do executes a raw command on a NORMAL connection, outside the pipeline.
// Arbitrary command names can carry connection state (SELECT, MULTI, SUBSCRIBE,
// CLIENT ...) or block the connection (BLPOP ...); batching those onto a shared
// pipeline connection would silently poison it for every later batch, or stall
// unrelated commands. (Submit enforces the same rule for raw Cmders: names in
// the connection-hostile set are diverted off the pipeline automatically.)
// The typed surface (ap.Set, ap.Get, ...) is safe by
// construction and IS batched — prefer it. Do carries the same caveats as
// Client.Do: a stateful command still affects the (normal, non-pipeline)
// pooled connection it runs on. Do keeps each face's call shape: on
// a blocking autopipeliner the call blocks until the command has executed; on a
// deferred (async) one it returns immediately and the command's result
// accessors (Err/Val/Result) block until it completes.
func (ap *AutoPipeliner) Do(ctx context.Context, args ...interface{}) *Cmd {
cmd := NewCmd(ctx, args...)
if len(args) == 0 {
cmd.SetErr(errDoNoArgs)
return cmd
}
if ap.isClosed() {
cmd.SetErr(ErrClosed)
return cmd
}
// Both faces go through runOutsidePipeline: it applies the divert
// registration gate, so Close cannot conclude "nothing in flight" while an
// accepted raw command — a blocking one on the blocking face runs inline on
// the caller's goroutine — is still holding a pooled connection.
_ = ap.runOutsidePipeline(ctx, cmd)
return cmd
}
// runOutsidePipeline executes an escape-hatch command (Do, DoRaw,
// DoRawWriteTo) on a normal pooled connection, outside the batching engine,
// following the face's call shape. Blocking face: synchronous Process.
// Deferred face: returns-immediately — the command runs on a background
// goroutine and a ready batch makes its result accessors block until it
// completes. The batch completes at the innermost seam (under the user
// hooks) so a ProcessHook reading the result cannot self-deadlock; the
// deferred close is the panic backstop. Tracked by divertWg under divertMu,
// so Close waits for accepted diverted work (bounded — see Close) instead of
// returning while it still holds a pooled connection.
func (ap *AutoPipeliner) runOutsidePipeline(ctx context.Context, cmd Cmder) *apBatch {
if ap.blocking {
// The blocking face runs it inline, so the caller's own goroutine holds
// the connection; still take the gate so Close cannot decide "nothing
// in flight" while this command is executing.
ap.divertMu.Lock()
if ap.isClosed() {
ap.divertMu.Unlock()
cmd.SetErr(ErrClosed)
return completedBatch
}
ap.divertWg.Add(1)
ap.divertMu.Unlock()
defer ap.divertWg.Done()
_ = ap.pipeliner.Process(ctx, cmd)
return completedBatch
}
// Register under divertMu with a closed re-check, so registration and the
// close transition cannot interleave (see the divertMu comment). A command
// that loses the race is rejected here rather than running after Close.
// The gate comes BEFORE setReady: publishing the fresh batch first and then
// rejecting would leave the command gated on a batch nobody ever closes,
// hanging every accessor.
ap.divertMu.Lock()
if ap.isClosed() {
ap.divertMu.Unlock()
cmd.SetErr(ErrClosed)
cmd.setReady(completedBatch)
return completedBatch
}
b := newAPBatch()
cmd.setReady(b)
ap.divertWg.Add(1)
ap.divertMu.Unlock()
go func() {
defer ap.divertWg.Done()
defer b.close()
defer recoverDispatchPanic([]Cmder{cmd})
if ap.armSelfDeadlockGuard() {
b.dispGid.Store(curGoroutineID())
}
// A hook that returns nil WITHOUT calling next has short-circuited
// SUCCESSFULLY (it served the command itself); plain Client hooks may do
// that, so nothing here synthesizes an error for it — see dispatchCmds.
err := ap.pipeliner.withProcessHook(ctx, cmd, func(ctx context.Context, cmd Cmder) error {
return ap.pipeliner.process(ctx, cmd)
})
// The chain's final verdict, exactly like Client.Process — recorded
// before the deferred close wakes the reader, so short-circuits,
// post-next rewrites and suppressions are all honored.
cmd.SetErr(err)
}()
return b
}
// DoRaw mirrors Do for raw RESP access: AutoPipeliner embeds cmdable, so
// without this override DoRaw would ride the batching engine — but raw
// commands carry Do's caveats and DoRawWriteTo-style streaming must not run
// inside a shared batch's reply loop. Runs outside the pipeline, following
// the face's call shape (see Do).
func (ap *AutoPipeliner) DoRaw(ctx context.Context, args ...interface{}) *RawCmd {
cmd := NewRawCmd(ctx, args...)
if len(args) == 0 {
cmd.SetErr(errDoNoArgs)
return cmd
}
if ap.isClosed() {
cmd.SetErr(ErrClosed)
return cmd
}
_ = ap.runOutsidePipeline(ctx, cmd)
return cmd
}
// DoRawWriteTo mirrors Do for streamed raw RESP access (see DoRaw). On the
// deferred face the write to w happens when the command executes; use the
// result accessors (Err/Written) to wait before reading w.
func (ap *AutoPipeliner) DoRawWriteTo(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd {
cmd := NewRawWriteToCmd(ctx, w, args...)
if len(args) == 0 {
cmd.SetErr(errDoNoArgs)
return cmd
}
if ap.isClosed() {
cmd.SetErr(ErrClosed)
return cmd
}
_ = ap.runOutsidePipeline(ctx, cmd)
return cmd
}
// Process queues a command for autopipelined execution, following the
// autopipeliner's mode like the typed methods and Do: on a blocking
// autopipeliner the call blocks until the command has executed; on a deferred
// (async) one it returns immediately and reading the command's result
// (Val/Result/Err) blocks until its batch is flushed.
func (ap *AutoPipeliner) Process(ctx context.Context, cmd Cmder) error {
return ap.cmdable(ctx, cmd)
}
// The methods below complete the UniversalClient surface by delegating to the
// underlying client. They are NOT autopipelined — pub/sub, transactions (Watch),
// hooks, Do and pool stats cannot be batched — so an AutoPipeliner used as a
// UniversalClient batches only the typed data commands; everything here runs on
// the underlying client exactly as it would there.
//
// Note on lifecycle: Close() (defined elsewhere) closes the AUTOPIPELINER —
// drains in-flight batches and stops flushers — but does NOT close the
// underlying client, whose lifecycle is owned by whoever created it.
// AddHook adds a hook to the underlying client. Autopipelined batches are hooked
// too, since dispatch goes through the hook-wrapped pipeline entry.
//
// Hook contract:
// - Short-circuiting differs by dispatch mode. In half-duplex (batched) mode a
// hook MAY return without calling next to skip the server — a supported pattern
// for a mock or cache. In full-duplex mode the command is already queued on the
// held connection before the hook runs, so returning without calling next does
// NOT prevent the server write; a hook cannot cancel a full-duplex command.
// - Do not call Close, or any other client control method, from inside a hook. A
// hook runs on the engine's dispatch goroutine; in full-duplex mode a synchronous
// Close from there blocks until the close backstop, because Close waits on the
// very hook host it is running on. Trigger Close from a separate goroutine (see
// the FullDuplex GoDoc).
// - Do not panic. A panic in a batch hook is recovered so it cannot crash the
// process, but the affected batch fails.
// - Do not mutate client or connection state.
func (ap *AutoPipeliner) AddHook(hook Hook) { ap.pipeliner.AddHook(hook) }
// The four commands below have CLUSTER-WIDE overrides on ClusterClient
// (DBSize sums every master, the Script commands fan out to every shard).
// The embedded generic cmdable would route them as ordinary keyless commands
// to one picked shard — partial results, scripts missing on other shards —
// so they delegate to the underlying client instead of batching. On a
// standalone client the delegation is semantically identical to the generic
// path; these are rare admin/script-management commands, not data-path.
// DBSize delegates to the underlying client (cluster-wide sum on ClusterClient).
func (ap *AutoPipeliner) DBSize(ctx context.Context) *IntCmd {
return ap.pipeliner.DBSize(ctx)
}
// ScriptLoad delegates to the underlying client (loads every shard on ClusterClient).
func (ap *AutoPipeliner) ScriptLoad(ctx context.Context, script string) *StringCmd {
return ap.pipeliner.ScriptLoad(ctx, script)
}
// ScriptFlush delegates to the underlying client (flushes every shard on ClusterClient).
func (ap *AutoPipeliner) ScriptFlush(ctx context.Context) *StatusCmd {
return ap.pipeliner.ScriptFlush(ctx)
}
// ScriptExists delegates to the underlying client (ANDs results across shards
// on ClusterClient).
func (ap *AutoPipeliner) ScriptExists(ctx context.Context, hashes ...string) *BoolSliceCmd {
return ap.pipeliner.ScriptExists(ctx, hashes...)
}
// HImportPrepare, HImportDiscard and HImportDiscardAll are the remaining
// cluster-wide overrides (see the delegation note above): ClusterClient fans
// them out to every master and updates the shared fieldset registry, so
// running them on a single routed node would let a later HImportSet for a key
// on another master fail with "no such fieldset". TestAPDelegatesClusterWideOverrides
// fails if a future ClusterClient override is added without a delegate here.
func (ap *AutoPipeliner) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd {
return ap.pipeliner.HImportPrepare(ctx, fieldsetName, fields...)
}
func (ap *AutoPipeliner) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd {
return ap.pipeliner.HImportDiscard(ctx, fieldsetName)
}
func (ap *AutoPipeliner) HImportDiscardAll(ctx context.Context) *IntCmd {
return ap.pipeliner.HImportDiscardAll(ctx)
}
// Watch runs a transactional function on the underlying client (not batched).
func (ap *AutoPipeliner) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error {
return ap.pipeliner.Watch(ctx, fn, keys...)
}
// Subscribe opens a pub/sub on the underlying client (not batched — pub/sub
// needs a dedicated connection).
func (ap *AutoPipeliner) Subscribe(ctx context.Context, channels ...string) *PubSub {
return ap.pipeliner.Subscribe(ctx, channels...)
}
// PSubscribe opens a pattern pub/sub on the underlying client (not batched).
func (ap *AutoPipeliner) PSubscribe(ctx context.Context, channels ...string) *PubSub {
return ap.pipeliner.PSubscribe(ctx, channels...)
}
// SSubscribe opens a sharded pub/sub on the underlying client (not batched).
func (ap *AutoPipeliner) SSubscribe(ctx context.Context, channels ...string) *PubSub {
return ap.pipeliner.SSubscribe(ctx, channels...)
}
// PoolStats returns the underlying client's connection pool statistics.
func (ap *AutoPipeliner) PoolStats() *PoolStats { return ap.pipeliner.PoolStats() }
// AutoPipeline delegates to the underlying client, which returns its cached
// autopipeliner (typically this same instance). Present to satisfy the
// UniversalClient surface.
func (ap *AutoPipeliner) AutoPipeline() (*AutoPipeliner, error) {
return ap.pipeliner.AutoPipeline()
}
// AutoPipelineWithOptions delegates to the underlying client.
func (ap *AutoPipeliner) AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return ap.pipeliner.AutoPipelineWithOptions(config)
}
// AsyncAutoPipeline delegates to the underlying client. Present to satisfy the
// UniversalClient surface.
func (ap *AutoPipeliner) AsyncAutoPipeline() (*AutoPipeliner, error) {
return ap.pipeliner.AsyncAutoPipeline()
}
// AsyncAutoPipelineWithOptions delegates to the underlying client.
func (ap *AutoPipeliner) AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return ap.pipeliner.AsyncAutoPipelineWithOptions(config)
}
// AutoFuture is the handle returned by Submit. Call Wait (or Result on the
// command after Wait) once the result is needed; it blocks only until the
// command's batch has executed.
type AutoFuture struct {
cmd Cmder
batch *apBatch
}
// Wait blocks until the submitted command has executed, then returns its error.
// The zero AutoFuture (no submitted command) returns an error rather than
// panicking.
func (f AutoFuture) Wait() error {
if f.batch == nil {
if f.cmd != nil {
return f.cmd.Err()
}
return errZeroAutoFuture
}
select {
case <-f.batch.done:
default:
// Same self-deadlock guard as baseCmd.await(): a pipeline hook on
// the batch's own dispatch goroutine waiting a future pre-next()
// would block a channel only its goroutine can close. Give it the
// not-yet-executed view instead.
if f.batch.isExecutorGoroutine() {
return f.cmd.rawErr()
}
<-f.batch.done
}
return f.cmd.Err()
}
// WaitContext is like Wait but stops waiting when ctx is done. The command
// still executes and its result remains readable once its batch completes —
// ctx abandons only this wait, it does not cancel the command (per-command
// contexts are not honored after enqueue; see the AutoPipeliner doc).
//
// After a ctx error the result may simply not be there YET: the batch is
// still in flight and may populate the command at any moment, so do not read
// Cmd()'s value or error directly — that races the executing batch. Call Wait
// (or WaitContext with a fresh context) again; once it returns a non-context
// error, the command's result is complete and safe to read.
func (f AutoFuture) WaitContext(ctx context.Context) error {
if f.batch == nil {
if f.cmd != nil {
return f.cmd.Err()
}
return errZeroAutoFuture
}
select {
case <-f.batch.done:
return f.cmd.Err()
default:
if f.batch.isExecutorGoroutine() {
return f.cmd.rawErr() // see Wait: dispatch-goroutine self-deadlock guard
}
}
select {
case <-f.batch.done:
return f.cmd.Err()
case <-ctx.Done():
return ctx.Err()
}
}
// Cmd returns the underlying command (call Wait first before reading results).
func (f AutoFuture) Cmd() Cmder { return f.cmd }
// outsidePipelineCommands lists commands that must never ride a SHARED
// pipeline connection. SHUTDOWN terminates the server before replying (its
// batchmates would all fail with EOF and the batch would retry against a
// dead server); MONITOR rebinds the connection into a monitor stream,
// desyncing every reply behind it; the rest change per-connection state
// (database, auth, protocol, transaction, subscription mode) that would
// leak to every unrelated caller sharing the pipeline conn afterwards. The
// typed surface cannot produce most of the stateful ones (they live on
// statefulCmdable) — but ReadOnly/ReadWrite ARE on cmdable, and raw
// Submit/Do accept any Cmder. Diverted commands execute directly on their
// own pooled connection — the same semantics (including the same footguns)
// as plain Client.Do.
var outsidePipelineCommands = map[string]struct{}{
"shutdown": {}, "monitor": {},
"select": {}, "auth": {}, "hello": {}, "reset": {}, "quit": {},
"multi": {}, "exec": {}, "discard": {}, "watch": {}, "unwatch": {},
"subscribe": {}, "unsubscribe": {}, "psubscribe": {}, "punsubscribe": {},
"ssubscribe": {}, "sunsubscribe": {},
"client": {},
// Connection-scoped cluster state: queued onto a shared pipeline conn
// they would leak replica-reads (or a pending redirect) to every later
// batch on that conn.
"readonly": {}, "readwrite": {}, "asking": {},
}
func runsOutsidePipeline(name string) bool {
_, ok := outsidePipelineCommands[name]
return ok
}
// blockingCommands are commands that park on the server until data arrives or
// their own timeout expires. The TYPED helpers set a per-command read timeout
// (see cmdable.BLPop), which submit already diverts on; a RAW Cmder built by
// hand — NewCmd(ctx, "blpop", key, 0) via Submit/Process/Do — carries no such
// marker, so without this set it would be queued onto a shared pipeline
// connection and hold the whole batch for the block duration.
// Derived from the typed helpers rather than guessed: every cmdable method that
// calls cmd.setReadTimeout parks the connection, so
//
// grep -rn 'setReadTimeout' --include='*.go' . | grep -v _test
//
// enumerates exactly the wire names that belong here (the arg-driven ones are
// handled in isBlockingCmd instead). Re-run that grep when adding a blocking
// command.
var blockingCommands = map[string]struct{}{
"blpop": {}, "brpop": {}, "brpoplpush": {},
"blmove": {}, "blmovem": {}, "blmpop": {},
"bzpopmin": {}, "bzpopmax": {}, "bzmpop": {},
"wait": {}, "waitaof": {},
// MIGRATE blocks the source instance for up to its timeout.
"migrate": {},
}
// isHImportCmd reports whether cmd is an HIMPORT command
// (PREPARE/SET/DISCARD/DISCARDALL): a managed one — the himportCmder marker, the
// same predicate himportInjectedCmds uses to spot HIMPORT in a batch — OR a raw
// one built with NewCmd/NewStatusCmd(ctx, "himport", ...), matched by name. The
// raw form carries no marker, but it rides the same connection-session state (a
// PREPARE registered on the physical connection) that the full-duplex writer
// never injects, so after a session recycle, handoff or reconnect a raw HIMPORT
// SET would land on a connection whose PREPARE never ran and fail with "no such
// fieldset" (codex on #4002). Divert it off the shared pipe like the managed
// form: Process runs it on a pooled connection, as a plain client would. Used
// only on the full-duplex path (see submit).
func isHImportCmd(cmd Cmder) bool {
if _, ok := cmd.(himportCmder); ok {
return true
}
return cmd.Name() == "himport"
}
// isBlockingCmd reports whether cmd parks the connection. XREAD/XREADGROUP are
// decided by ARGUMENTS, not by name: only the BLOCK form blocks, and
// blanket-diverting the (far more common) non-blocking form would drop it out
// of batching for nothing.
func isBlockingCmd(cmd Cmder) bool {
name := cmd.Name()
if _, ok := blockingCommands[name]; ok {
return true
}
// Arg-driven: these block only in their BLOCK form, and blanket-diverting
// the far more common non-blocking form would drop it out of batching for
// nothing. TS.READ takes BLOCK the same way (see TSReadWithArgs).
args := cmd.Args()
switch name {
case "xread":
return blockOptionBeforeStreams(args, 1)
case "xreadgroup":
// args[1:4] are the mandatory GROUP keyword plus the group and
// consumer names. Those names are arbitrary values — a consumer
// literally named "streams" must not be mistaken for the STREAMS
// terminator (which would hide a real, later BLOCK and let the
// command ride the shared pipe — cursor bugbot on #4002) — so skip
// them positionally rather than matching on value.
return blockOptionBeforeStreams(args, 4)
case "ts.read":
// TS.READ has no STREAMS terminator, but it DOES have two fixed
// positional args before the option section: args[1] is the key and
// args[2] is the timestamp (see TSReadWithArgs), and either can be
// arbitrary user data (e.g. a key literally named "block"). Scanning
// from args[0] would mistake that key for the BLOCK option and divert
// a non-blocking TS.READ off the ordered pipe (codex on #4002).
if len(args) < 3 {
return false
}
for _, arg := range args[3:] {
if internal.ToLower(blockingArgString(arg)) == "block" {
return true
}
}
return false
default:
return false
}
}
// blockOptionBeforeStreams scans the option section of XREAD/XREADGROUP
// (COUNT/MAXCOUNT/MAXSIZE/BLOCK/NOACK/CLAIM, in any combination) for the
// BLOCK keyword, stopping at the STREAMS keyword that always terminates the
// option section. start must already be past any positional arguments
// (XREADGROUP's GROUP clause) that cannot be told apart from a keyword by
// value alone. Match the token the way the encoder does: a raw Cmder may
// carry RESP tokens as []byte or *string (see baseCmd.stringArg), and a type
// switch on string alone would let NewCmd(ctx, "xread", []byte("BLOCK"), 0,
// ...) be batched onto a shared connection.
func blockOptionBeforeStreams(args []interface{}, start int) bool {
if start > len(args) {
return false
}
for _, arg := range args[start:] {
switch internal.ToLower(blockingArgString(arg)) {
case "streams":
return false
case "block":
return true
}
}
return false
}
// blockingArgString renders a command argument as the string the encoder will
// write for the token comparisons above. Only the forms that can carry a RESP
// keyword are handled; anything else cannot be the BLOCK token.
func blockingArgString(arg interface{}) string {
switch v := arg.(type) {
case string:
return v
case []byte:
return string(v)
case *string:
if v == nil {
return ""
}
return *v
default:
return ""
}
}
// submit queues a command without blocking and returns its completion future.
func (ap *AutoPipeliner) submit(ctx context.Context, cmd Cmder) AutoFuture {
// finish marks the command ready on the deferred face so its result
// accessors (Val/Result/Err) self-gate through await() — whether the
// caller goes through the typed surface or raw Submit. Reading a
// Submit()-ed command before Wait() was previously a silent data race
// with the dispatch goroutine. The blocking face deliberately never
// carries a batch: its callers only regain control after execution, and
// the dispatcher-gid deadlock guard relies on that.
finish := func(f AutoFuture) AutoFuture {
if !ap.blocking {
cmd.setReady(f.batch)
}
return f
}
// Decide DIVERSION first. The cluster preflight rejects commands whose
// request policy cannot ride a pipeline (ReqAllNodes/ReqAllShards), but a
// diverted command never rides one: it goes through the underlying
// Client/ClusterClient.Process, which performs the normal cluster-wide
// fan-out and aggregation. Running the preflight first therefore rejected
// commands that would have worked — typed WAIT/WAITAOF on a cluster with
// command policies enabled (review finding by codex on #3942).
diverted := cmd.readTimeout() != nil || runsOutsidePipeline(cmd.Name()) || isBlockingCmd(cmd) ||
(ap.mustDivert != nil && ap.mustDivert(ctx, cmd)) ||
// HIMPORT — managed or raw (see isHImportCmd) — rides connection-session
// state (the registered PREPARE) that the full-duplex writer never injects,
// so an HIMPORT SET on the FD pipe can fail "no such fieldset". Divert it to
// the normal Process path, which injects the PREPARE for the managed form
// (and updates the registry) and runs a raw form on a pooled connection as a
// plain client would. The half-duplex sharded path injects inline
// (himportInjectedCmds) and stays on the pipeline. Cluster full-duplex
// (clusterFD) routes to per-node FD children whose engines have the same
// limitation, so divert there too.
((ap.fd != nil || ap.clusterFD != nil) && isHImportCmd(cmd))
if !diverted && ap.preflight != nil {
if err := ap.preflight(ctx, cmd); err != nil {
cmd.SetErr(err)
return finish(AutoFuture{cmd: cmd, batch: completedBatch})
}
}
if diverted {
// Blocking commands (and the conn-hostile ones above) are executed
// directly, outside the pipeline — via runOutsidePipeline, which
// keeps each face's call shape: the blocking face runs the command
// synchronously, the deferred face runs it on its own goroutine so
// this call returns immediately and the result accessors block (a
// BLPOP submitted on the async face must not stall the submitter,
// exactly like Do). They still must respect a closed AutoPipeliner:
// enqueue() rejects on the batched path, so mirror that here instead
// of running after Close().
if ap.isClosed() {
cmd.SetErr(ErrClosed)
return finish(AutoFuture{cmd: cmd, batch: completedBatch})
}
// runOutsidePipeline sets the command ready itself on the deferred
// face; the returned batch completes when the command has executed.
return AutoFuture{cmd: cmd, batch: ap.runOutsidePipeline(ctx, cmd)}
}
// No finish here: enqueue stamps ready under the stripe lock, before the
// command is visible to any drain (the error paths above still go through
// finish for uniform accessor behavior).
if ap.clusterFD != nil {
// Cluster full-duplex: route to the per-node FD child that owns this
// command's slot. The child is a standalone FD autopipeliner sharing this
// face's blocking flag, so its submit honors the same setReady/blocking
// contract — return its AutoFuture directly.
return ap.clusterFD.submit(ctx, cmd)
}
if ap.fd != nil {
// Ordered full-duplex: stream on one held connection. enqueue's async
// setReady is replicated here since we bypass it. ctx is threaded so a
// per-command process-hook host can parent its span correctly.
b := ap.fd.submit(ctx, cmd)
if !ap.blocking {
cmd.setReady(b)
}
return AutoFuture{cmd: cmd, batch: b}
}
return AutoFuture{cmd: cmd, batch: ap.enqueue(cmd)}
}
// ErrSubmitBlockingFace rejects Submit on the blocking face: Submit does not
// wait, so a windowed caller could have several commands in flight at once —
// but the blocking face stripes its enqueue queue on the strength of every
// caller waiting per command, and a non-waiting window there can be reordered.
// The deferred face (AsyncAutoPipeline) is built for exactly that usage.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
var ErrSubmitBlockingFace = errors.New(
"redis: Submit requires the deferred autopipeliner (AsyncAutoPipeline); on the blocking face use the typed methods or Do",
)
// errZeroAutoFuture is returned by Wait/WaitContext on a zero AutoFuture.
var errZeroAutoFuture = errors.New("redis: Wait on a zero AutoFuture")
// errDoNoArgs is returned by Do when called without a command.
var errDoNoArgs = errors.New("redis: AutoPipeliner.Do requires at least one argument")
// ErrAutoPipelineTimeout is set on drained commands when a flush could not
// obtain a batch permit within the engine's internal backstop — the engine is
// overloaded or an in-flight batch is wedged (e.g. read timeouts disabled on
// a dead peer). It is deliberately NOT context.DeadlineExceeded: the caller's
// own context did not expire, and errors.Is(err, context.DeadlineExceeded)
// must not fire for an internal engine timeout.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
var ErrAutoPipelineTimeout = errors.New(
"redis: autopipeline: no batch permit within the internal backstop (engine overloaded or a batch is wedged)",
)
// Submit queues a command without blocking and returns an AutoFuture; Wait on
// it when the result is needed. This is the explicit form for working with raw
// Cmders on the deferred (async) face, where the typed methods (Set, Get, ...)
// provide the same deferred behaviour returning the usual *XxxCmd. The
// command's own result accessors (Err/Val/Result) are safe to use instead of
// Wait — they block until the command has executed. Connection-hostile
// command names (SHUTDOWN, MONITOR, SELECT, AUTH, MULTI, SUBSCRIBE, CLIENT,
// ...) never ride a shared pipeline connection: they are diverted to a
// normal pooled connection with plain Client.Do semantics. On a BLOCKING
// autopipeliner Submit is rejected (the future's Wait returns an error): the
// blocking face's ordering relies on every caller waiting for each command
// before issuing the next, which Submit by design does not do.
func (ap *AutoPipeliner) Submit(ctx context.Context, cmd Cmder) AutoFuture {
if ap.blocking {
cmd.SetErr(ErrSubmitBlockingFace)
return AutoFuture{cmd: cmd, batch: completedBatch}
}
return ap.submit(ctx, cmd)
}
// processAsync is the cmdable backing the typed command surface: it queues a
// command without blocking the caller and marks it ready so the command's
// result accessors (Val/Result/Err) block until the batch executes. This gives
// the autopipeliner the full typed surface (ap.Set, ap.Get, ...) with the exact
// same call shape as a normal client — only the wait is deferred to the point a
// result is read.
func (ap *AutoPipeliner) processAsync(ctx context.Context, cmd Cmder) error {
// submit marks the command ready (see the finish closure there): a hook
// that reads the command before that store lands sees a nil ready — the
// non-blocking not-yet-executed view — while the caller always sees its
// own store before any await.
f := ap.submit(ctx, cmd)
// Report SUBMIT-time rejections (a closed pipeliner, a cluster preflight
// refusal): those paths set the error on the command and hand back the
// shared completed batch without queueing anything, so returning nil made
// Process claim success for a command that will never run — and callers
// reaching the engine through UniversalClient.Process see only this return
// value (review finding by codex on #3942). Execution errors are NOT
// reported here: the deferred face's contract is that this call does not
// wait, so those stay on the command for its accessors. rawErr keeps the
// check non-blocking.
if f.batch == completedBatch {
return cmd.rawErr()
}
return nil
}
// processBlocking is the cmdable backing the blocking face: it queues the
// command and blocks until its batch has executed, so the command call has the
// same synchronous shape as a normal client (the returned *XxxCmd already holds
// its result). The flusher still batches this command with other concurrent
// callers' commands into a pipeline, so throughput is far above a plain client
// even though each caller waits. Per-goroutine ordering holds regardless of
// MaxConcurrentBatches: a caller cannot issue its next command until this one
// returns, so its commands execute in submit order.
func (ap *AutoPipeliner) processBlocking(ctx context.Context, cmd Cmder) error {
f := ap.submit(ctx, cmd)
err := f.Wait()
// Recycle the pooled completion batch. After Wait the batch is complete and
// unreferenced: the blocking face never installs it on the command (no
// setReady), and the reader drops its fdReq — and with it the batch pointer —
// as it advances past the just-completed command, so processBlocking is the
// last holder. Gate on pooled (excludes completedBatch and every non-FD /
// diverted / async path, all of which use newAPBatch) and dispGid==0
// (insurance: a stamped dispatcher gid would mean an executor-goroutine Wait
// path that can return without draining done).
if b := f.batch; b != nil && b.pooled && b.dispGid.Load() == 0 {
putFDBlockingBatch(b)
}
return err
}
// completedBatch is a reusable already-completed batch: returned both for
// commands that already executed directly (blocking commands, Submit-time
// rejections) and for error cases like enqueue-after-Close, so Wait returns
// immediately and the command's own error tells the story.
var completedBatch = func() *apBatch {
b := newAPBatch()
b.close()
return b
}()
// enqueue queues a command and returns the batch whose done channel completes
// when it has executed. On a closed autopipeliner it errors the command and
// returns the already-closed batch.
// isClosed reports whether this pipeliner (or the shared pool set it rides
// on) has been closed. Two atomic loads; no locks.
//
// EVERY closed check that gates accepting new work must go through this, not
// ap.closed directly: a WithTimeout clone's Close sets only the shared flag,
// so a guard reading ap.closed alone would accept commands against pools that
// are already gone and surface pool-closed errors instead of ErrClosed.
// (Close's own CompareAndSwap on ap.closed is the one deliberate direct use:
// it claims the shutdown for this instance.)
func (ap *AutoPipeliner) isClosed() bool {
return ap.closed.Load() || (ap.sharedClosed != nil && ap.sharedClosed.Load())
}
func (ap *AutoPipeliner) enqueue(cmd Cmder) *apBatch {
if ap.isClosed() {
cmd.SetErr(ErrClosed)
return completedBatch
}
// Pick a shard. With shardFn (cluster mode) route by command content so all
// commands for one node collect in the same shard's batch; otherwise spread
// round-robin to keep each shard's mutex lightly contended.
var s *apShard
if ap.shardFn != nil {
// uint conversion instead of negation: -math.MinInt overflows back to
// itself and a negative modulo would panic the index. The unsigned
// modulo is deterministic for every int, including MinInt.
idx := ap.shardFn(cmd)
s = ap.shards[uint(idx)%uint(len(ap.shards))]
} else if len(ap.shards) == 1 {
// Single shard (the standalone default): skip the round-robin counter —
// it is a shared cache line bumped by every enqueue for a pick that is
// constant. Same guard the stripe pick already has.
s = ap.shards[0]
} else {
// Unsigned modulo: converting to int first goes negative after the
// uint32 counter passes 2^31 on 32-bit platforms and panics.
s = ap.shards[int((ap.next.Add(1)-1)%uint32(len(ap.shards)))]
}
// Size the command BEFORE taking the stripe lock, and panic-safely. Sizing
// runs user code — cmd.Args() on a custom Cmder, and MarshalBinary on a
// BinaryMarshaler argument — and MaxBatchBytes is on by default, so this is
// on every enqueue. A panic while holding st.mu would never reach
// st.mu.Unlock: the stripe stays locked, every later enqueue on it parks
// forever, and Close can only time out (cursor bugbot + codex on #4002).
// Outside the lock, a panic just fails this one command, exactly as the
// full-duplex serve loop does with the same helper; nothing was queued.
var cmdBytes int64
if ap.config.MaxBatchBytes > 0 {
n, err := cmdApproxBytesSafe(cmd)
if err != nil {
cmd.SetErr(err)
return completedBatch
}
cmdBytes = n
}
st := s.stripe()
st.mu.Lock()
// Re-check closed under the stripe lock (see Close): either we win the lock
// first and the shutdown drain flushes us, or the drain ran first and we
// reject here — so a late enqueue never hangs on an unclosed done.
if ap.isClosed() {
st.mu.Unlock()
cmd.SetErr(ErrClosed)
return completedBatch
}
batch := st.curBatch
if !ap.blocking {
// Publish the gating batch BEFORE the command becomes visible to a
// drain (the drain takes this same stripe lock): a flush racing the
// submitter's return path must observe ready already set, or the
// cluster node-executor registration would skip this command's batch
// and a node hook reading the command mid-dispatch could block on a
// batch its own call chain completes. The blocking face deliberately
// never carries a batch (see submit).
cmd.setReady(batch)
}
st.queue = append(st.queue, cmd)
st.queueLen.Store(int32(len(st.queue)))
if ap.config.MaxBatchBytes > 0 {
st.queueBytes.Add(cmdBytes)
}
st.mu.Unlock()
// One expected arrival has landed (see expectedArrivals).
ap.expectedArrivals.Add(-1)
s.wake()
return batch
}
// wake signals the shard's flusher that work is available without blocking.
func (s *apShard) wake() {
select {
case s.notify <- struct{}{}:
default:
}
}
// IsBlocking reports which face this autopipeliner is: true for the blocking
// face (Client.AutoPipeline — calls wait for execution), false for the
// deferred face (AsyncAutoPipeline — calls return immediately and result
// accessors block). The two faces reject different usage (Submit is
// blocking-face-rejected), so code handed an *AutoPipeliner can branch on
// this instead of probing with errors.
func (ap *AutoPipeliner) IsBlocking() bool { return ap.blocking }
// Config returns a copy of the effective configuration (defaults filled in).
func (ap *AutoPipeliner) Config() AutoPipelineOptions {
cfg := *ap.config
// Strip internal-only fields. contentSharded is set by cluster wiring and
// tells Validate that shards are slot-routed, so same-key commands cannot
// be reordered — which exempts the config from the NumShards>1 ordering
// requirement. Handing that bit back to a caller who copies this config
// into a STANDALONE async autopipeliner would silence that check for
// round-robin shards, which really do flush concurrently and really do
// break submit order (review finding by codex on #3942).
cfg.contentSharded = false
// Same hazard for clusterReprocess: it holds a live ClusterClient.process
// closure set by the cluster FD router. Round-tripping this config into a
// STANDALONE *Client would carry that closure onto an unrelated client's FD
// engine (flipping redirectAware on and routing its retries through the wrong
// ClusterClient). Strip it.
cfg.clusterReprocess = nil
// clusterRetryBudget is internal cluster-FD wiring (the ClusterClient's
// MaxRedirects, used as the child engine's recovery budget); it has no meaning
// on a config a caller copies into a standalone client, so strip it too.
cfg.clusterRetryBudget = 0
return cfg
}
// IsClosed reports whether the AutoPipeliner has been closed, either by an
// explicit Close or by closing the owning client. A closed AutoPipeliner
// rejects new commands with ErrClosed.
func (ap *AutoPipeliner) IsClosed() bool {
return ap.isClosed()
}
// numShards reports how many shards this autopipeliner runs.
func (ap *AutoPipeliner) numShards() int { return len(ap.shards) }
// setShardFn installs a content-based shard selector. In cluster mode it maps
// a command's SLOT to a shard, which is a batch-depth heuristic, not an
// invariant: slot ranges are assigned to shards proportionally, so when a
// node's slots are non-contiguous one shard's batch can still span nodes and
// mapCmdsByNode splits it (correctness is unaffected — that router resolves
// every command's own slot — but those per-node pipelines are shallower).
// What the mapping DOES guarantee is that a given key always lands on the same
// shard, so a caller's relative order for that key is preserved regardless of
// how the shard's batch is split. Must be called before the autopipeliner is
// used. Not safe to change concurrently with enqueues.
func (ap *AutoPipeliner) setShardFn(fn func(Cmder) int) { ap.shardFn = fn }
// setPreflight installs a submit-time command filter (cluster wiring rejects
// commands whose request policy cannot ride a pipeline). Called once during
// construction, before the AutoPipeliner is published.
func (ap *AutoPipeliner) setPreflight(fn func(ctx context.Context, cmd Cmder) error) {
ap.preflight = fn
}
// setMustDivert installs a predicate that forces a command off the batching
// path (see the mustDivert field). Called once during construction, before the
// AutoPipeliner is published.
func (ap *AutoPipeliner) setMustDivert(fn func(ctx context.Context, cmd Cmder) bool) {
ap.mustDivert = fn
}
// Close stops the autopipeliner and flushes any pending commands. Worst
// case it blocks up to the internal permit backstop (~30s) PER SHARD if
// in-flight batches are wedged (e.g. read timeouts disabled against a dead
// peer) — healthy shutdowns take one round trip per shard with commands
// queued, near-zero otherwise.
func (ap *AutoPipeliner) Close() error {
if !ap.closed.CompareAndSwap(false, true) {
// Another Close already claimed the shutdown. Return immediately and do
// NOT wait for its drain: a re-entrant Close from an in-flight dispatch
// (a batch's hook calling Close on its own executor goroutine) runs on a
// goroutine the winner's drain is itself waiting for, so waiting here
// would self-wait until the backstop. Callers that must observe the
// drain complete before acting on "closed" — e.g. a wrapping client's
// Close, before it tears down shared connection pools — call WaitClosed
// after Close instead.
return nil
}
// Winner: run the drain, publish its result, and release any WaitClosed
// waiters exactly once (even if the drain panics).
defer close(ap.closeDone)
// Detach this engine's shared-pool close hook, but only AFTER the drain: a
// pool-sharing wrapper that closes the shared pool concurrently with this Close
// must still find the hook registered and block on this engine's shutdown flush
// (via the hook's cancelAndDrain, serialized by drainOnce) before the pools are
// torn down. Unregistering first would let the pool close race ahead of our
// flush and tear the pools down mid-write. Detach via defer so a drain panic
// does not leave the per-engine callback lingering in the shared onClose
// registry (bounded registrations; no stale cancel on a later sharer close).
// Only the full Close detaches — the hook's own cancelAndDrain path deliberately
// leaves ap.closed false, so a later owner Close still reaches here.
if ap.closeHooks != nil {
defer ap.closeHooks.unregister(ap.closeHookID)
}
return ap.cancelAndDrain()
}
// cancelAndDrain cancels the engine and waits (bounded by the drainAll backstop)
// for its flushers, shutdown flush, and final shard sweep — WITHOUT flipping
// ap.closed or detaching the close hook. The shared-pool close hook uses this: when
// a pool-sharing clone closes the shared pools, this engine's shutdown flush must
// finish BEFORE closeResources tears the pools down, yet ap.closed must stay false
// (the engine is then rejected via the shared-closed flag, and a later owner Close
// still runs the full teardown). Close layers the closed-CAS + hook detach on top.
func (ap *AutoPipeliner) cancelAndDrain() error {
// Run the cancel+drain body exactly ONCE, even when two closers reach it for the
// same engine — an explicit AutoPipeliner.Close racing a pool-sharing wrapper's
// shared-pool close hook (the hook path deliberately leaves ap.closed false, so
// the closed-CAS does not serialize the two). Two concurrent drains are unsafe:
// drainAll orders its stages flushers -> shard sweep -> batchWg.Wait precisely so
// every batchWg.Add the sweep issues happens-before the Wait; interleaved, one
// drain's batchWg.Wait can run while the other's sweep is still dispatching and
// calling Add — "sync: WaitGroup misuse: Add called concurrently with Wait", a
// runtime panic. The Once also hands both callers the SAME close error and avoids
// a duplicate, spuriously-logged shutdown-permit acquisition. Once.Do blocks the
// loser until the winner's drain finishes and publishes closeErr (single writer,
// inside the Once, so WaitClosed reads it race-free), so this is safe without an
// extra channel. A single sweep is a
// sufficient barrier against late enqueues: whichever caller wins has already set
// the flag enqueue checks (Close sets ap.closed, the hook sets sharedClosed)
// before reaching here, so the sweep still closes the lost-command race.
ap.drainOnce.Do(func() { ap.closeErr = ap.drainBody() })
return ap.closeErr
}
// drainBody is cancelAndDrain's actual cancel+drain work, invoked exactly once via
// drainOnce. Split out only so the once wrapper stays trivial.
func (ap *AutoPipeliner) drainBody() error {
ap.drainRuns.Add(1)
// Cancel context to stop flushers
ap.cancel()
// Wake every shard's flusher so each observes the cancelled context promptly.
for _, s := range ap.shards {
s.wake()
}
// Cluster full-duplex: close the per-node FD children before the (empty) shard
// sweep and before clusterNodes closes the node clients, so each child flushes
// its accepted commands on the still-open node connection. Idempotent if a
// child was already reaped by its node client's close hook.
var clusterFDErr error
if ap.clusterFD != nil {
// Capture the child-drain error: a stalled/failed per-node child means
// accepted commands were not flushed, which the caller must be able to
// detect. Joined into closeErr below so it is not masked by the (empty)
// shard sweep's nil result.
clusterFDErr = ap.clusterFD.close()
}
// Pass through the divert gate once: by the time this runs the engine is
// already rejecting new work (Close set ap.closed before calling here; the
// shared-pool hook path has sharedClosed set before onClose runs), so any
// diverted registration either completed before this (the counter already sees
// it) or observes closed/shared-closed and rejects. Without this handshake the
// wait below could read a zero counter while a diverted command was between its
// closed check and its Add.
ap.divertMu.Lock()
ap.divertMu.Unlock() //nolint:staticcheck // handshake, not a critical section
// Drain everything that remains, BOUNDED AS ONE UNIT: the flusher exit, the
// final shard sweep, and the batch/diverted dispatch waits.
//
// None of it can be cancelled: commands taken from a queue (or accepted for
// diverted execution) were already ACCEPTED, and Close's contract is to
// flush them, so ap.cancel() deliberately does not reach an in-flight
// dispatch. With ReadTimeout disabled — a supported configuration — a
// stalled read against a dead peer, or a diverted BLPOP with a zero
// timeout, has nothing to end it. Bounding only the LAST wait would not
// help: the wedged dispatch can just as easily sit in a flusher that
// ap.wg.Wait() is waiting for, or in the shutdown sweep's own dispatch, so
// Close would hang before ever reaching the bound it documents (review
// finding by codex on #3942). On expiry, report what is still outstanding
// instead of blocking the caller: the engine is already closed to new work,
// and the leaked goroutines end when the server or the OS breaks the
// connection. See autoPipelineCloseBackstop for why the bound is generous.
ap.closeErr = errors.Join(ap.drainAll(autoPipelineCloseBackstop), clusterFDErr)
return ap.closeErr
}
// WaitClosed blocks until the Close that claimed the shutdown has finished its
// drain, then returns that drain's result. Close itself returns immediately for
// any caller that loses the shutdown CAS (so a re-entrant Close from an
// in-flight dispatch cannot self-wait); a caller that must not act on "closed"
// until accepted commands have been flushed calls Close and then WaitClosed.
// The canonical use is a wrapping client whose own Close tears down shared
// connection pools: Close, WaitClosed, then close the pools — so the pools are
// never torn down under the winning Close's in-flight drain. Call after Close;
// with no Close in progress it blocks until one happens.
func (ap *AutoPipeliner) WaitClosed() error {
<-ap.closeDone
return ap.closeErr
}
// drainAll runs Close's whole drain tail under a single bound and returns an
// error naming every stage that was still outstanding when it expired. Split
// out of Close so the bound is testable without a real stalled connection.
//
// The stages are ordered as Close needs them — the shard sweep must not start
// before the flushers are provably gone — but they are waited on
// CONCURRENTLY with the timer, which is the whole point: any stage can be the
// one that never finishes.
func (ap *AutoPipeliner) drainAll(timeout time.Duration) error {
flushers := make(chan struct{})
go func() { defer close(flushers); ap.wg.Wait() }()
// swept: after the flushers are gone, drain each shard once more under its
// lock. A command can pass enqueue's under-lock closed-recheck just before
// Close's CompareAndSwap and append to a shard AFTER that shard's flusher
// has already drained and exited — leaving its batch.done unclosed and the
// caller's accessor blocked forever. s.mu serializes the two, so either the
// late enqueue appends first and this sweep flushes it, or the sweep runs
// first and the enqueue then observes closed==true and rejects.
swept := make(chan struct{})
go func() {
defer close(swept)
<-flushers
for _, s := range ap.shards {
s.flushBatchSliceShutdown()
}
}()
batches := make(chan struct{})
go func() {
defer close(batches)
<-swept
ap.batchWg.Wait()
}()
diverted := make(chan struct{})
go func() { defer close(diverted); ap.divertWg.Wait() }()
timer := time.NewTimer(timeout)
defer timer.Stop()
batchesDone, divertedDone := false, false
for !batchesDone || !divertedDone {
select {
case <-batches:
batchesDone = true
batches = nil // a closed channel is always ready; stop selecting it
case <-diverted:
divertedDone = true
diverted = nil
case <-timer.C:
var outstanding []string
if !batchesDone {
// Name the precise stage: a wedged flusher and a wedged batch
// dispatch need different operator responses.
select {
case <-flushers:
select {
case <-swept:
outstanding = append(outstanding, "batch dispatches")
default:
outstanding = append(outstanding, "the shutdown flush")
}
default:
outstanding = append(outstanding, "the flusher drain")
}
}
if !divertedDone {
outstanding = append(outstanding, "diverted (blocking) commands")
}
return fmt.Errorf(
"redis: autopipeline: Close timed out after %s with %s still in flight; "+
"they hold pooled connections until the server or the OS ends them "+
"(most often a blocking command with no timeout, or ReadTimeout disabled)",
timeout, strings.Join(outstanding, " and "),
)
}
}
return nil
}
// flusher is the per-shard background goroutine that flushes batches.
func (s *apShard) flusher() {
defer s.ap.wg.Done()
ap := s.ap
for {
// Wait for a command to arrive (or shutdown). The notify channel is a
// cheap buffered wake-up; no lock is taken on the hot enqueue path.
if s.Len() == 0 {
select {
case <-s.notify:
case <-ap.ctx.Done():
}
}
// Check if context is cancelled
if ap.ctx.Err() != nil {
// Final flush before shutdown - use background context to avoid immediate cancellation
s.flushBatchSliceShutdown()
return
}
// Apply the coalescing window if one is configured (MaxFlushDelay /
// AdaptiveDelay). With the default config this returns at once: batching
// under concurrent load comes from in-flight backpressure, not a wait —
// see accumulateBatch.
s.accumulateBatch()
// Flush all pending commands
for s.Len() > 0 {
select {
case <-ap.ctx.Done():
// Final flush before shutdown
s.flushBatchSliceShutdown()
return
default:
}
s.flushBatchSlice()
// Between batches, apply the configured window again so the next
// pipeline is also full. A no-op with the default config (see
// accumulateBatch); the next drain picks up whatever has queued.
if s.Len() > 0 && s.Len() < ap.config.MaxBatchSize && !s.bytesFull() {
s.accumulateBatch()
}
}
}
}
// accumulateBatch lets commands pile up before the flusher drains the queue,
// so pipelines carry many commands instead of one. It returns as soon as any
// of these holds:
//
// - the queue reaches MaxBatchSize (batch is full);
// - a configured MaxFlushDelay / AdaptiveDelay window elapses; or
// - with no configured window (the default), the expected resubmission
// wave of arrivals has landed — see awaitExpectedArrivals.
//
// A configured MaxFlushDelay / AdaptiveDelay is an intentional accumulation
// window and is waited in full (AdaptiveDelay scales it down as the queue fills
// and returns 0 — flush now — once the queue is ≥75% full).
func (s *apShard) accumulateBatch() {
ap := s.ap
batchSize := ap.config.MaxBatchSize
if batchSize <= 0 {
batchSize = 1
}
if s.Len() >= batchSize || s.bytesFull() {
return
}
// Pick the accumulation window. calculateDelay returns 0 both when no
// MaxFlushDelay is configured (the default) and when AdaptiveDelay resolves
// the current fill level to "flush immediately". The fill level is this
// shard's own length — each shard flushes independently, so a global count
// would mis-tune a quiet shard while another is busy.
window := ap.calculateDelay(s.Len())
if window <= 0 {
if ap.config.MaxFlushDelay == 0 && !ap.config.AdaptiveDelay {
// Default: coalesce by expected-arrival count, not by wall-clock.
s.awaitExpectedArrivals(batchSize)
}
return
}
// Explicit window: wait the whole delay (or until the batch fills). Each
// enqueue sends on notify, so we re-check the queue length on every wake-up
// and return once the batch is full.
deadline := time.NewTimer(window)
defer deadline.Stop()
for {
select {
case <-ap.ctx.Done():
return
case <-deadline.C:
return
case <-s.notify:
if s.Len() >= batchSize || s.bytesFull() {
return
}
}
}
}
// silenceGapFloor / silenceGapCeil bound awaitExpectedArrivals's silence fallback.
// The floor covers fast links; the RTT-scaled value (execEWMA/8) takes over on
// slow ones, where a wakeup wave staggered by goroutine scheduling can pause
// longer than the floor mid-landing and a premature flush is expensive (each
// batch fragment occupies a pipeline connection for a full round trip). The
// ceiling bounds how long a stale expectation (callers that left) can delay a
// flush.
const (
silenceGapFloor = 200 * time.Microsecond
silenceGapCeil = 2 * time.Millisecond
)
// coalesceMinFlush is the smallest pipeline worth dispatching while other
// batches are still executing. Below it, a gap-fire holds the queued
// stragglers for the next wave instead of burning a connection on a
// near-empty flush; once nothing is in flight, any size flushes immediately.
const coalesceMinFlush = 8
// stragglerHoldGaps bounds the straggler-hold WHEN the pipeline pool has a free
// connection (see awaitExpectedArrivals): at most this many silence gaps (each
// clamp(execEWMA/8, 200µs, 2ms), so the bound tracks the round trip) pass before
// queued stragglers flush. The old behavior re-armed until
// autoPipelinePermitBackstop — effectively until the in-flight batch's reply
// landed, ~1 RTT — so a straggler that enqueued behind an in-flight batch waited
// a full round trip BEFORE its own, i.e. every such op paid ~2x RTT (measured
// with a phase trace on a deterministic 50ms link: uncached p95 pinned at 2x RTT
// / 107ms at low-to-mid concurrency, straggler-hold avg == 1 RTT; the bound
// collapsed that to ~1 RTT / 62ms). The bound is applied ONLY when a pooled
// connection is idle or dial-able — when the pool is saturated the long hold is
// kept, because flushing tiny batches into a full pool thrashes it and cuts
// throughput (measured ~7x at a squeezed pool). The 30s
// autoPipelinePermitBackstop remains the absolute safety ceiling on the flush
// path itself (a wedged connection).
const stragglerHoldGaps = 3
// observeBatchExec folds one batch execution duration into execEWMA.
func (ap *AutoPipeliner) observeBatchExec(d time.Duration) {
sample := int64(d)
if sample <= 0 {
return
}
old := ap.execEWMA.Load()
if old == 0 {
ap.execEWMA.Store(sample)
return
}
ap.execEWMA.Store(old + (sample-old)/8)
}
// silenceGap returns the silence fallback for awaitExpectedArrivals, scaled to the
// observed batch round-trip: clamp(execEWMA/8, floor, ceil).
func (ap *AutoPipeliner) silenceGap() time.Duration {
g := time.Duration(ap.execEWMA.Load() / 8)
if g < silenceGapFloor {
return silenceGapFloor
}
if g > silenceGapCeil {
return silenceGapCeil
}
return g
}
// pipelineHasFreeConn reports whether the pipeline pool can serve another batch
// without blocking: an idle connection is ready, or the pool has not yet dialed
// to capacity (the pipeline pool runs MinIdleConns=0, so it dials on demand up
// to Size). When the pool is unknown (nil — e.g. a cluster client) it returns
// false, so the straggler-hold keeps its conservative long hold. Called only on
// a gap fire, not per command.
//
// Prefer the pool's own HasFreeCapacity probe (all *ConnPool implement it): the
// plain IdleLen()/Len()<Size() heuristic below ignores MaxActiveConns, so a pool
// with MaxActiveConns < PoolSize and no idle conn would report free even though
// the flush's Get would hit ErrPoolExhausted (codex #3962). The heuristic stays
// as a fallback for any Pooler that does not implement the probe.
func (ap *AutoPipeliner) pipelineHasFreeConn() bool {
p := ap.pipelinePool
if p == nil {
return false
}
if hc, ok := p.(interface{ HasFreeCapacity() bool }); ok {
return hc.HasFreeCapacity()
}
return p.IdleLen() > 0 || p.Len() < p.Size()
}
// awaitExpectedArrivals holds the flusher while related work is in motion, so
// commands flush as deep pipelines instead of fragmenting into small batches
// (each fragment costs a pipeline connection for a full round trip). Two
// signals — both facts the engine already has, not wall-clock guesses — decide
// whether anything is imminent:
//
// - expectedArrivals: a completed batch of N commands wakes its N waiters
// together, and in a closed loop each immediately submits its next
// command. Completion announces the exact count; every enqueue accounts
// for one; the wait ends the moment the count drains — the wave of
// arrivals has fully landed. An exact per-wave count has no failure mode
// where an averaged estimate undershoots the true wave and locks the
// engine into fragmented flushes.
// - inFlight: batches still executing mean their waiters will wake shortly
// and stragglers are mid-stream — worth holding a moment to coalesce with,
// bounded by the silence gap. This also recovers a fragmented state (many
// singles in flight, which announce nothing): their staggered returns land
// within one gap, merge into a real batch, and arrival tracking resumes.
//
// When neither holds, the shard is idle and the flush happens immediately: a
// lone caller pays a single round trip with no timer armed. That is the point
// of the design — the previous fixed ~20µs debounce timer armed on every flush
// fires ~1ms late on an idle or low-core host (wakeup latency dominates the
// requested delay), taxing every low-concurrency command ~5x its round trip.
// Here the gap timer never fires in steady state, closed loop or open; it only
// ends waits for callers that left.
func (s *apShard) awaitExpectedArrivals(batchSize int) {
ap := s.ap
expected := ap.expectedArrivals.Load()
if expected < 0 {
// Arrivals outran what was announced (open-loop traffic); re-zero so
// the deficit does not mask the next wave. CAS: only clear the value
// we saw, never a concurrent announcement.
ap.expectedArrivals.CompareAndSwap(expected, 0)
expected = 0
}
expectingWave := expected > 0
if !expectingWave && s.inFlight.Load() == 0 {
// Idle shard: nothing imminent, flush in one round trip.
return
}
gap := ap.silenceGap()
// Reset is drain-safe on Go 1.23+ (see go.mod: go 1.24).
fallback := time.NewTimer(gap)
defer fallback.Stop()
lastSeenExpected := expected // count as of the most recent timer (re)arm
var holdStart time.Time // set on the first straggler-hold gap fire
for {
select {
case <-ap.ctx.Done():
return
case <-fallback.C:
if !expectingWave && s.Len() < coalesceMinFlush && s.inFlight.Load() > 0 {
// Only stragglers queued while batches are still executing:
// flushing a near-empty pipeline burns a connection for a full
// round trip (measured at high WAN concurrency: straggler
// flushes of 1-3 commands starved the connection pool and
// doubled p50). How long to hold depends on whether the pipeline
// pool has a connection to spare:
//
// - a connection is free -> bound the hold at stragglerHoldGaps
// silence gaps (a few ms). Flushing then costs an otherwise-
// idle connection and saves the straggler ~1 RTT. Waiting a
// whole round trip here (the old behavior) is what pinned
// low-concurrency stragglers at 2x RTT.
// - the pool is saturated -> keep the original long hold. Tiny
// flushes into a full pool thrash it: they cannot coalesce
// into the deep pipelines the scarce connections need, and
// throughput collapses (measured at a squeezed pool: an
// unconditional few-ms bound cut throughput ~7x). Holding
// lets the next completed batch's wave sweep the stragglers
// along.
//
// A wedged in-flight batch cannot hang the held stragglers past
// the bound (the free-conn case caps at a few ms; the flush path
// keeps its own autoPipelinePermitBackstop safety ceiling).
if holdStart.IsZero() {
holdStart = time.Now()
}
stragCap := autoPipelinePermitBackstop
if ap.pipelineHasFreeConn() {
stragCap = stragglerHoldGaps * gap
}
if time.Since(holdStart) < stragCap {
lastSeenExpected = ap.expectedArrivals.Load()
fallback.Reset(gap)
continue
}
}
if expectingWave {
// A whole gap passed with no arrivals on this shard: the
// expected callers left (workload shrank), so clear the stale
// expectation or future flushes will wait for ghosts. But only
// if it did not GROW during the silent gap — growth means a
// batch elsewhere (another shard, or racing this fire)
// announced a fresh wave, and erasing that would fragment a
// wave that is really coming. CAS, never a blind store, so an
// announcement racing the reset itself also survives.
if d := ap.expectedArrivals.Load(); d > 0 && d <= lastSeenExpected {
ap.expectedArrivals.CompareAndSwap(d, 0)
}
}
return
case <-s.notify:
if s.Len() >= batchSize || s.bytesFull() {
return
}
if d := ap.expectedArrivals.Load(); d > 0 {
// An in-flight batch completed mid-wait: its wave is now the
// thing to wait out, with the exact-count exit below.
expectingWave = true
lastSeenExpected = d
} else if expectingWave {
// The wave has fully landed; flush it as one batch.
return
} else if s.inFlight.Load() == 0 {
// Nothing executing, no wave expected: no completion will
// wake more callers, so flush what we have now.
return
}
fallback.Reset(gap)
}
}
}
// dispatchCmds executes the drained stripe queues as one pipeline without
// constructing a Pipeline object: the queue slices go straight to the client's
// hook-wrapped pipeline processor (the exact entry Pipeline.Exec is wired to),
// so hooks and OTel behave identically while the per-batch Pipeline allocation,
// its append-growth reallocations and the per-command Process calls disappear.
// A single-stripe drain (every ordered shard, and any drain that found one
// non-empty stripe) passes its queue zero-copy; multi-stripe drains merge into
// one pooled slice.
// The batches stay OPEN throughout: completion happens at the caller's
// deferred closes, after the whole hook chain has returned. Hooks on the
// dispatch goroutine can still read results without deadlocking via the
// dispGid guard in await() (pre-next: the not-yet-executed view; post-next:
// the populated results), and — exactly like a plain pipeline — they may
// even adjust results before any waiter wakes.
//
// The innermost records whether execution actually happened. Two hook
// behaviours the chain's return value can carry are surfaced, both while the
// batches are still open (the callers' deferred closes run after this
// returns, so no waiter is reading yet):
// - short-circuit (hook returned without calling next): nothing set the
// commands' results — the chain's error, if any, is set
// on every command;
// - post-next verdict (exec ran, a hook still returned an error): applied
// to the commands ONLY when every one of them is error-free — the case
// where the hook's verdict would otherwise vanish entirely. A plain
// pipeline hands that verdict to the Exec caller without rewriting
// per-command results; with no Exec caller here, per-command errors
// recorded by the exec always win and are never overwritten.
func (ap *AutoPipeliner) dispatchCmds(ctx context.Context, queues [][]Cmder, total int) {
cmds := queues[0]
if len(queues) > 1 {
cmds = getQueueSlice(total)
for i := range queues {
cmds = append(cmds, queues[i]...)
}
}
// A command that forbids retries (today: the zero-copy reads, whose reply
// decodes into a caller buffer that a retry could not un-write) disables
// retries for the WHOLE slice it is dispatched in — see cmdsContainNoRetry.
// In a shared batch that would silently strip retries from unrelated
// callers' ordinary commands, so a mixed batch is dispatched as several
// pipelines instead of one.
//
// Split into CONTIGUOUS RUNS, in order, never into two policy groups:
// grouping would reorder the stream — a zero-copy read submitted before a
// SET to the same key would execute after it, so the read observes the new
// value on a face that promises submit order. Runs preserve every relative
// position while still keeping each dispatched slice policy-uniform (both
// findings by codex on #3942; the grouping bug was introduced by the first
// fix for the retry leak).
if runs := splitRetryRuns(cmds); runs != nil {
ap.dispatchSequential(ctx, runs)
if len(queues) > 1 {
putQueueSlice(cmds)
}
return
}
executed := false
chainErr := ap.pipeliner.withProcessPipelineHook(ctx, cmds, func(ctx context.Context, cmds []Cmder) error {
executed = true
return ap.pipeliner.processPipeline(ctx, cmds)
})
// NOTE: a hook that returns nil WITHOUT calling next has short-circuited
// SUCCESSFULLY — it served the batch itself (a cache, a mock) and set the
// command values. Plain Pipeline/Client hooks are allowed to do exactly
// that, so no error is synthesized for it: doing so made a hook that works
// on a pipeline fail on an autopipelined batch (review finding by codex on
// #3942). Only the hook's own error propagates, below.
if chainErr != nil {
if !executed {
setCmdsErr(cmds, chainErr)
} else if cmdsFirstErr(cmds) == nil {
// Post-next error on an all-clean batch: the exec fully succeeded,
// so the error can only be the hook's own verdict — apply it.
// On a mixed batch it is applied to nothing: hooks conventionally
// return next's error (`err := next(...); return err`), so after a
// partial failure the chain error is presumed to be that echo, and
// stamping it on the commands that DID succeed would overwrite
// valid replies with their batchmates' failure. Exec-recorded
// per-command outcomes always win over a post-next rewrap.
setCmdsErr(cmds, chainErr)
}
}
if len(queues) > 1 {
putQueueSlice(cmds)
}
}
// dispatchCmdsMaybeChunked dispatches a drained batch, splitting it into
// byte-bounded chunks when MaxBatchBytes is configured: each chunk is its own
// pipeline write+read cycle, so a batch of many large values becomes several
// bounded bursts instead of one huge write that can stall a constrained link
// past its deadline. The commands' batches still complete only after ALL
// chunks executed (the caller's deferred closes), exactly like an unchunked
// dispatch — chunking bounds the wire bursts, it does not change completion
// semantics. Each chunk runs the full hook chain, like consecutive pipelines.
func (ap *AutoPipeliner) dispatchCmdsMaybeChunked(ctx context.Context, queues [][]Cmder, total int) {
limit := int64(ap.config.MaxBatchBytes)
if limit <= 0 {
ap.dispatchCmds(ctx, queues, total)
return
}
// Merge (borrowed from dispatchCmds's multi-queue path) so chunk
// boundaries can cross stripe queues.
cmds := queues[0]
merged := false
if len(queues) > 1 {
cmds = getQueueSlice(total)
for i := range queues {
cmds = append(cmds, queues[i]...)
}
merged = true
}
// Cut the byte-bounded chunks, then hand the ordered sequence to the shared
// dispatcher — which stops after a chunk dies on a transport-class failure,
// so later commands cannot overtake a failed prefix (see
// dispatchSequential; the retry-policy runs go through the same helper).
chunks := make([][]Cmder, 0, 4)
start := 0
var chunkBytes int64
for i, cmd := range cmds {
chunkBytes += cmdApproxBytes(cmd)
if chunkBytes >= limit && i+1 > start {
chunks = append(chunks, cmds[start:i+1])
start = i + 1
chunkBytes = 0
}
}
if start < len(cmds) {
chunks = append(chunks, cmds[start:])
}
ap.dispatchSequential(ctx, chunks)
if merged {
putQueueSlice(cmds)
}
}
// dispatchSequential dispatches an ORDERED sequence of sub-batches, stopping
// once one of them dies on a transport-class failure and failing the rest with
// that error.
//
// The stop is the same contract the unchunked path has: it fails or retries the
// batch as a UNIT, so in an ordered stream later commands must never overtake a
// prefix that died (retries exhausted, hook abort). Per-command redis errors
// (WRONGTYPE, nil) are normal outcomes and do not stop the sequence.
//
// Both callers that break a batch into ordered pieces — the MaxBatchBytes
// chunker and the retry-policy runs — go through here, because the first
// version of each got this wrong independently (review findings by codex on
// #3942).
func (ap *AutoPipeliner) dispatchSequential(ctx context.Context, groups [][]Cmder) {
var abortErr error
for _, group := range groups {
if len(group) == 0 {
continue
}
if abortErr != nil {
setCmdsErr(group, abortErr)
continue
}
ap.dispatchCmds(ctx, [][]Cmder{group}, len(group))
for _, cmd := range group {
if err := cmd.rawErr(); err != nil && !isRedisError(err) {
abortErr = err
break
}
}
}
}
// splitRetryRuns slices cmds into maximal CONTIGUOUS runs of one retry policy,
// preserving order: run i's commands all precede run i+1's, exactly as
// submitted. It returns nil when the whole batch is already policy-uniform —
// the overwhelmingly common case — so uniform batches allocate nothing and are
// dispatched as one pipeline.
//
// Runs are sub-slices of cmds, not copies, so they must be dispatched before
// cmds is recycled and must not be returned to the slice pool individually.
func splitRetryRuns(cmds []Cmder) [][]Cmder {
if len(cmds) < 2 {
return nil
}
first := cmds[0].NoRetry()
boundary := -1
for i := 1; i < len(cmds); i++ {
if cmds[i].NoRetry() != first {
boundary = i
break
}
}
if boundary < 0 {
return nil // uniform: one dispatch, no split
}
runs := make([][]Cmder, 0, 4)
start := 0
policy := first
for i := 1; i < len(cmds); i++ {
if p := cmds[i].NoRetry(); p != policy {
runs = append(runs, cmds[start:i])
start = i
policy = p
}
}
return append(runs, cmds[start:])
}
// recoverDispatchPanic converts a panic on a dispatch goroutine (a hook or
// command-encoder panic inside Process/Exec) into per-command errors instead
// of crashing the process. On a plain client the same panic unwinds into the
// CALLER, who can recover; the engine's dispatch goroutines have no caller,
// so an unrecovered panic here would kill the whole program on behalf of one
// bad command. Registered LAST at each dispatch site so it runs FIRST on
// unwind (LIFO) — the errors are stamped before the deferred batch closes
// wake the waiters. setCmdsErr fills only commands without an error, so
// exec-recorded outcomes for commands that finished are preserved.
func recoverDispatchPanic(cmds ...[]Cmder) {
r := recover()
if r == nil {
return
}
err := fmt.Errorf("redis: autopipeline: panic during dispatch: %v", r)
for _, batch := range cmds {
setCmdsErr(batch, err)
}
internal.Logger.Printf(context.Background(), "autopipeline: recovered dispatch panic: %v\n%s", r, debug.Stack())
}
// flushBatchSlice takes the shard's currently-queued commands as one batch,
// swaps in a fresh batch for subsequent enqueues, and dispatches the taken
// batch. Completion is signalled by closing the batch's done channel once
// (waking every waiter in a single operation) rather than one channel send
// per command.
func (s *apShard) flushBatchSlice() {
ap := s.ap
// Drain every stripe into one combined batch and roll fresh queues for the
// commands enqueued after this point. Striped enqueue spreads the hot
// mutex; one merged flush keeps the pipeline deep. accumulateBatch already
// bounds the total to roughly MaxBatchSize before we get here.
queues := make([][]Cmder, 0, len(s.stripes))
batches := make([]*apBatch, 0, len(s.stripes))
total := 0
for i := range s.stripes {
st := &s.stripes[i]
// Skip provably-empty stripes without taking their mutex. Safe in
// THIS path only: an enqueue publishes queueLen under the stripe lock
// and wakes the flusher after unlocking, so a command that appears
// concurrently with this unlocked read is re-observed by the
// flusher's Len() loop or the buffered notify — the same protocol the
// flusher already relies on. The shutdown drain must keep locking
// unconditionally (see flushBatchSliceShutdown).
if st.queueLen.Load() == 0 {
continue
}
st.mu.Lock()
if len(st.queue) > 0 {
queues = append(queues, st.queue)
batches = append(batches, st.curBatch)
total += len(st.queue)
st.queue = getQueueSlice(ap.config.MaxBatchSize)
st.curBatch = newAPBatch()
st.queueLen.Store(0)
st.queueBytes.Store(0)
}
st.mu.Unlock()
}
if total == 0 {
return
}
// Acquire a concurrency permit. The wait runs on a background context with
// a generous backstop deadline against a wedged semaphore: commands taken
// from the queue were already ACCEPTED, so a concurrent Close must not
// cancel them mid-acquire — Close's contract is to flush pending commands
// (it waits for this dispatch via wg/batchWg before tearing anything
// down). The backstop is deliberately well above both the default
// ReadTimeout and a maintnotifications relaxed window, so a legitimately
// slow batch (e.g. during a failover) holding a permit does not cause
// waiters to spuriously fail.
if !s.sem.TryAcquire() {
err := s.sem.Acquire(context.Background(), autoPipelinePermitBackstop, ErrAutoPipelineTimeout)
if err != nil {
// A permit not freeing within the backstop means the in-flight
// batch is wedged well past any configured timeout — leave an
// operator breadcrumb before failing the drained commands.
internal.Logger.Printf(context.Background(),
"redis: autopipeline: no batch permit after %s; failing %d queued commands",
autoPipelinePermitBackstop, total)
batchErr := err
for i := range queues {
for _, qc := range queues[i] {
qc.SetErr(batchErr)
}
batches[i].close()
putQueueSlice(queues[i])
}
return
}
// Wave merge. We took the queue and then waited a full batch round
// trip for the permit; callers whose replies landed just after our
// take re-submitted into the FRESH queue during that wait. Executing
// without them splits the group into two alternating waves — each
// observing two round trips, at half throughput — a state that is
// stable once entered (measured: p50 pinned at 2xRTT for entire runs
// at mid worker counts on a 52ms link). On the default window, let the
// wave of follow-ups land and fold it into this batch before
// executing, which merges the waves back into one batch per round
// trip. Explicit-delay configs keep their own timing.
if ap.config.MaxFlushDelay == 0 && !ap.config.AdaptiveDelay {
s.awaitExpectedArrivals(ap.config.MaxBatchSize)
for i := range s.stripes {
st := &s.stripes[i]
if st.queueLen.Load() == 0 {
continue
}
st.mu.Lock()
if len(st.queue) > 0 {
queues = append(queues, st.queue)
batches = append(batches, st.curBatch)
total += len(st.queue)
st.queue = getQueueSlice(ap.config.MaxBatchSize)
st.curBatch = newAPBatch()
st.queueLen.Store(0)
st.queueBytes.Store(0)
}
st.mu.Unlock()
}
}
}
// Fast path for single command: skip the pipeline and Process directly, in
// its own goroutine. The dispatch MUST NOT run inline in the flusher: a
// synchronous Process blocks the flusher for a full round trip, and on a
// slow link a solo straggler then holds up an entire landed wave for one
// RTT — whose flush then delays the straggler's next command in turn, a
// stable phase-lock where everyone pays 2x RTT (measured: ~25% of runs on
// a 57ms link locked at exactly 2x RTT until perturbed).
// No expectedArrivals announcement: a single waiter waking is the
// lone-caller case, which must keep flushing immediately.
if total == 1 {
ap.batchWg.Add(1)
s.inFlight.Add(1)
go func() {
// Defer order matters: the batch close is registered BEFORE the
// permit release and inFlight decrement so it runs AFTER them
// (LIFO) — a woken lone caller's next command then observes an
// idle shard and takes the immediate-flush path instead of
// arming the silence-gap wait.
defer ap.batchWg.Done()
defer batches[0].close()
defer s.inFlight.Add(-1)
defer s.sem.Release()
defer putQueueSlice(queues[0])
defer recoverDispatchPanic(queues[0])
// Background for the same reason as the batch goroutine below:
// accepted commands execute even under a concurrent Close.
execStart := time.Now()
b := batches[0]
if !ap.blocking && ap.armSelfDeadlockGuard() {
b.dispGid.Store(curGoroutineID())
}
solo := queues[0][0]
// Both faces run the user-hook chain via withProcessHook. The
// command records the CHAIN's final verdict — exactly what
// Client.Process does — before the deferred close wakes the
// waiter, so a hook that short-circuits, rewrites, or suppresses
// the error is honored. Hooks on this goroutine read the command
// deadlock-free via the dispGid guard stamped above.
// A successful short-circuit stays successful (see dispatchCmds).
err := ap.pipeliner.withProcessHook(context.Background(), solo, func(ctx context.Context, cmd Cmder) error {
// A cacheable solo on a CSC client goes through Process so the cache
// is honored (processPipeline bypasses processCached). Gated on
// the LIVE CSC state: without active CSC, Process would just run on
// the MAIN pool, ignoring the pipeline pool the straggler gate probed.
if ap.cscActiveFn != nil && ap.cscActiveFn() && isCacheable(cmd) {
return ap.pipeliner.process(ctx, cmd)
}
// One-command pipeline on the PIPELINE pool (falls back to the main
// pool when none exists).
return ap.pipeliner.processPipeline(ctx, []Cmder{cmd})
})
solo.SetErr(err)
ap.observeBatchExec(time.Since(execStart))
}()
return
}
// Track this goroutine in the batchWg so Close() waits for it.
// IMPORTANT: Add to WaitGroup AFTER semaphore is acquired to avoid deadlock.
ap.batchWg.Add(1)
s.inFlight.Add(1)
go func() {
defer ap.batchWg.Done()
defer s.inFlight.Add(-1)
defer s.sem.Release()
// Signal completion with one close per taken stripe. Deferred so a
// panic in Process/Exec (e.g. a malformed command or encoder panic)
// still wakes every waiter in await() instead of hanging them forever;
// the closes run after Exec on the happy path, so results are
// populated first.
defer func() {
for i := range queues {
batches[i].close()
putQueueSlice(queues[i])
}
}()
defer recoverDispatchPanic(queues...)
// Execute on a background context: these commands were accepted before
// any concurrent Close, and Close waits for this goroutine (batchWg)
// before the client tears down its pools — cancelling here would
// error already-accepted commands while the shutdown sweep flushes
// later ones, an inverted outcome. The wire timeouts (Read/Write
// Timeout, or maintnotifications relaxed windows) still bound the
// execution; no per-batch timer is allocated.
ctx := context.Background()
// The batches complete at the deferred closes, AFTER the whole hook
// chain has returned — so a hook's post-next verdict is honored and,
// like a plain pipeline, a hook may adjust results before any waiter
// wakes. Hooks on this goroutine read results deadlock-free via the
// dispGid guard in await() (armed below when hooks can exist).
if !ap.blocking && ap.armSelfDeadlockGuard() {
gid := curGoroutineID()
for i := range batches {
batches[i].dispGid.Store(gid)
}
}
execStart := time.Now()
ap.dispatchCmdsMaybeChunked(ctx, queues, total)
ap.observeBatchExec(time.Since(execStart))
// Announce the expected arrivals BEFORE the deferred closes wake this
// batch's waiters, so the flusher knows the wave size the moment its
// first command lands (see expectedArrivals).
ap.expectedArrivals.Add(int64(total))
}()
}
// flushBatchSliceShutdown flushes commands during shutdown.
// Unlike flushBatchSlice, this doesn't use ap.ctx for semaphore acquisition
// because ap.ctx is already cancelled during shutdown.
// Executes synchronously to preserve command order.
func (s *apShard) flushBatchSliceShutdown() {
ap := s.ap
// Flush all remaining commands synchronously to preserve order.
//
// The loop condition is checked UNDER each stripe's lock (not via the
// unlocked s.Len()): a late enqueue appends to a stripe's queue and updates
// its queueLen under that stripe's mutex, so reading queueLen without the
// lock could miss a command that was just appended (seeing 0 and exiting
// while a command sits in the queue). Locking first makes "is the stripe
// empty?" and "take the stripe" atomic against that enqueue — this is what
// closes the lost-command race on Close.
for {
// Take every stripe's queue as one merged batch and roll fresh queues.
queues := make([][]Cmder, 0, len(s.stripes))
batches := make([]*apBatch, 0, len(s.stripes))
total := 0
for i := range s.stripes {
st := &s.stripes[i]
st.mu.Lock()
if len(st.queue) > 0 {
queues = append(queues, st.queue)
batches = append(batches, st.curBatch)
total += len(st.queue)
st.queue = getQueueSlice(ap.config.MaxBatchSize)
st.curBatch = newAPBatch()
st.queueLen.Store(0)
st.queueBytes.Store(0)
}
st.mu.Unlock()
}
if total == 0 {
return
}
// Serialize with any still-running in-flight batch: the shutdown drain
// used to bypass the per-shard permit, so under MaxConcurrentBatches:1
// a drained command could execute CONCURRENTLY with the in-flight
// batch during Close and be observed out of order. Acquire the permit
// (bounded by the backstop, on a background context — ap.ctx is
// already cancelled here); if the backstop expires the permit holder
// is wedged and we proceed anyway rather than strand the commands.
acquired := s.sem.TryAcquire()
if !acquired {
acquired = s.sem.Acquire(context.Background(), autoPipelinePermitBackstop, ErrAutoPipelineTimeout) == nil
if !acquired {
internal.Logger.Printf(context.Background(),
"redis: autopipeline: no batch permit after %s during shutdown; flushing unserialized",
autoPipelinePermitBackstop)
}
}
// Execute each batch in a func so close(batch.done) is deferred: a panic
// in Process/Exec still signals completion (waking await()) before it
// propagates, instead of leaving shutdown waiters hung.
func() {
if acquired {
defer s.sem.Release()
}
defer func() {
for i := range queues {
batches[i].close()
putQueueSlice(queues[i])
}
}()
defer recoverDispatchPanic(queues...)
// ap.ctx is already cancelled here (Close cancels it before draining),
// so use a fresh background context with no artificial deadline. The
// wire timeout is then governed by the connection's ReadTimeout /
// WriteTimeout — exactly like the normal flush path and a plain client
// Exec. Crucially this lets a relaxed timeout (set by maintnotifications
// during a failover/migration) take effect; a hardcoded short deadline
// here would cap that relaxed window and time out in-flight commands the
// relaxation was meant to protect. (A user who wants shutdown bounded
// sets ReadTimeout/WriteTimeout on the client, as for any command.)
if !ap.blocking && ap.armSelfDeadlockGuard() {
gid := curGoroutineID()
for i := range batches {
batches[i].dispGid.Store(gid)
}
}
ap.dispatchCmdsMaybeChunked(context.Background(), queues, total)
}()
}
}
// Len returns the number of queued commands in this shard.
func (s *apShard) Len() int {
n := 0
for i := range s.stripes {
n += int(s.stripes[i].queueLen.Load())
}
return n
}
// bytesFull reports whether the shard's queued payload volume has reached the
// configured MaxBatchBytes (false when the cap is disabled). Like the
// MaxBatchSize trigger it is soft: enqueues racing the check can overshoot.
func (s *apShard) bytesFull() bool {
limit := int64(s.ap.config.MaxBatchBytes)
if limit <= 0 {
return false
}
var n int64
for i := range s.stripes {
n += s.stripes[i].queueBytes.Load()
if n >= limit {
return true
}
}
return false
}
// cmdApproxBytes estimates a command's wire payload for MaxBatchBytes
// accounting: string/[]byte argument lengths plus a small fixed overhead per
// argument (type marker, length line, CRLFs). Exactness doesn't matter — the
// cap bounds burst size, it is not a protocol calculation.
func cmdApproxBytes(cmd Cmder) int64 {
const perArgOverhead = 16
// unknownArgBytes stands in for an arg whose encoded size cannot be
// determined here (a BinaryMarshaler that errored). Deliberately large so
// such an arg isolates into its own chunk rather than silently
// undercounting and letting several of them coalesce past MaxBatchBytes —
// the same command would fail at write time anyway, so over-counting it
// here is free (codex on #4002).
const unknownArgBytes = 1 << 20
n := int64(0)
for _, a := range cmd.Args() {
switch v := a.(type) {
case string:
n += int64(len(v))
case []byte:
n += int64(len(v))
case *string:
// proto.Writer dereferences and writes *v (empty string for nil),
// same as the string case above — see Writer.WriteArg.
if v != nil {
n += int64(len(*v))
}
case encoding.BinaryMarshaler:
// proto.Writer's default arm marshals any other type through this
// interface (int/float/bool/time.Time/etc. all have their own,
// small, fixed-size case above and never reach here). A large
// custom Cmder argument marshaled through it must be sized by its
// actual encoded length, not the untyped 8-byte fallback below —
// that fallback previously let several large marshaled args
// coalesce past MaxBatchBytes and reopen the large-payload
// write/reply deadlock the cap exists to bound.
if b, err := v.MarshalBinary(); err == nil {
n += int64(len(b))
} else {
n += unknownArgBytes
}
default:
n += 8
}
n += perArgOverhead
}
return n
}
// cmdApproxBytesSafe wraps cmdApproxBytes with a recover. Sizing runs user code
// — cmd.Args() on a custom Cmder, MarshalBinary on a BinaryMarshaler argument —
// and can panic. Two callers need that contained: the full-duplex serve loop,
// which has no top-level recover (a panic would kill the engine and strand every
// in-flight and future command), and the half-duplex enqueue, which sizes before
// taking the stripe lock (a panic under that lock would leave it held forever).
// On panic it returns a non-nil error wrapping errFDPanicRecovered so the caller
// can fail and DROP just that command before anything is queued or written — the
// alternative (letting it reach the write path, whose write-time recover tears
// the session down and replays the batch) forces at-least-once re-execution of
// the poisoned command's innocent batch-mates.
func cmdApproxBytesSafe(cmd Cmder) (n int64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: Args() sizing: %v", errFDPanicRecovered, r)
internal.Logger.Printf(context.Background(),
"autopipeline: recovered Args() sizing panic: %v\n%s", r, debug.Stack())
}
}()
return cmdApproxBytes(cmd), nil
}
// Len returns the current number of queued commands across all shards.
func (ap *AutoPipeliner) Len() int {
total := 0
for _, s := range ap.shards {
total += s.Len()
}
// Full-duplex accepts commands onto fd.ch instead of the shard queues, so
// include its backlog — otherwise Len() reports 0 while accepted commands are
// buffered behind a backpressured/stalled FD writer, and callers using Len()
// for monitoring or local backpressure lose the signal in FullDuplex mode.
if ap.fd != nil {
total += len(ap.fd.ch)
}
// Cluster full-duplex accepts commands onto per-node FD children, not the
// shard queues; include their backlog for the same monitoring reason.
if ap.clusterFD != nil {
total += ap.clusterFD.len()
}
return total
}
// calculateDelay calculates the delay based on the given queue length (the
// caller's own shard, not the global total, so each shard tunes independently).
// Uses integer-only arithmetic for optimal performance (no float operations).
// Returns 0 if MaxFlushDelay is 0.
func (ap *AutoPipeliner) calculateDelay(queueLen int) time.Duration {
maxDelay := ap.config.MaxFlushDelay
if maxDelay == 0 {
return 0
}
// If adaptive delay is disabled, return fixed delay
if !ap.config.AdaptiveDelay {
return maxDelay
}
if queueLen == 0 {
return 0
}
maxBatch := ap.config.MaxBatchSize
// Use integer arithmetic to avoid float operations
// Calculate thresholds: 75%, 50%, 25% of maxBatch
// Multiply by 4 to avoid division: queueLen * 4 vs maxBatch * 3 (75%)
//
// Adaptive delay strategy:
// - ≥75% full: No delay (flush immediately to prevent overflow)
// - ≥50% full: 25% of max delay (queue filling up)
// - ≥25% full: 50% of max delay (moderate load)
// - <25% full: 100% of max delay (low load, maximize batching)
switch {
case queueLen*4 >= maxBatch*3: // queueLen >= 75% of maxBatch
return 0 // Flush immediately
case queueLen*2 >= maxBatch: // queueLen >= 50% of maxBatch
return maxDelay >> 2 // Divide by 4 using bit shift (faster)
case queueLen*4 >= maxBatch: // queueLen >= 25% of maxBatch
return maxDelay >> 1 // Divide by 2 using bit shift (faster)
default:
return maxDelay
}
}
// Pipeline returns a new pipeline that uses the underlying pipeliner.
// This allows you to create a traditional pipeline from an autopipeliner.
func (ap *AutoPipeliner) Pipeline() Pipeliner {
return ap.pipeliner.Pipeline()
}
// Pipelined executes a function in a pipeline context.
// This is a convenience method that creates a pipeline, executes the function,
// and returns the results.
func (ap *AutoPipeliner) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return ap.pipeliner.Pipeline().Pipelined(ctx, fn)
}
// TxPipelined executes a function in a transaction pipeline context.
// This is a convenience method that creates a transaction pipeline, executes the function,
// and returns the results. It delegates to the underlying client's TxPipeline.
func (ap *AutoPipeliner) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return ap.pipeliner.TxPipeline().Pipelined(ctx, fn)
}
// TxPipeline returns a new transaction pipeline that uses the underlying pipeliner.
// This allows you to create a traditional transaction pipeline from an autopipeliner.
// It delegates to the underlying client's TxPipeline.
func (ap *AutoPipeliner) TxPipeline() Pipeliner {
return ap.pipeliner.TxPipeline()
}
// validate AutoPipeliner implements Cmdable
var _ Cmdable = (*AutoPipeliner)(nil)
package redis
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// clusterFDRouter runs the ordered full-duplex autopipeline natively on a
// *ClusterClient.
//
// Full-duplex needs one held connection with a writer/reader goroutine pair
// (fdEngine), which only a standalone *Client with a dedicated pipeline pool can
// provide — a *ClusterClient has none of its own. But every master node's
// node.Client IS a standalone *Client that gets a pipeline pool by default
// (redis.go creates it whenever PipelinePoolSize >= 0, and osscluster.go passes
// that option through to each node). So instead of the half-duplex shard
// flushers, the router keeps one FD child autopipeliner per master node and
// routes each command to the child that owns its slot. The parent AutoPipeliner
// keeps diversion (blocking / fan-out / ReqSpecial) on the *ClusterClient, so
// cluster-wide commands still fan out and aggregate correctly.
//
// Children are created lazily on first use for a node and cached on the
// node.Client itself (via its AutoPipeline getters), so this router shares one
// child instance per node with anything else that asks that node client for an
// autopipeliner.
//
// Routing uses the live cluster state on every submit, so a stable topology
// routes correctly. Redirects are handled too: a child's FD engine surfaces a
// MOVED/ASK reply to the router's injected reprocess function (childCfg.
// clusterReprocess), which re-runs the command through the redirect-aware
// ClusterClient (cc.process) — MOVED is routed to the target node with a topology
// reload; ASK is followed through cc.process's own redirect loop (which issues
// ASKING). So a slot migration is followed rather than surfaced as an error.
type clusterFDRouter struct {
parent *AutoPipeliner
cc *ClusterClient
blocking bool
// childCfg is the standalone FD config every node child is built with,
// derived once from the parent's config. Reused verbatim so the node
// client's "first getter call wins" cache always sees the same config.
childCfg *AutoPipelineOptions
mu sync.RWMutex
closed bool
children map[*Client]*AutoPipeliner
// evictHooks records every node client this router registered a
// clusterFDRouterEvict close hook on (see getOrCreateChild), so close can
// unregister them. Guarded by mu. Tracked separately from children because
// the stale-child sweep and the hook itself drop children entries while the
// registration on the node client stays live.
evictHooks map[*Client]struct{}
}
// clusterFDRouterEvictID is the onClose registry id of this router's evict
// hook on node client nc. Deterministic per (router, node) so a re-register
// replaces rather than accumulates, and so close can address it.
func clusterFDRouterEvictID(r *clusterFDRouter, nc *Client) string {
return fmt.Sprintf("clusterFDRouterEvict#%p#%p", r, nc)
}
func newClusterFDRouter(parent *AutoPipeliner, cc *ClusterClient, cfg *AutoPipelineOptions, blocking bool) *clusterFDRouter {
// Derive the per-node child config from the parent's: force the ordered
// single-shard full-duplex combo the fdOn gate requires, carry the caller's
// sizing knobs (MaxBatchSize, FullDuplexWindow, MaxFlushDelay, ...) as-is, and
// strip contentSharded — a cluster-only bit that must not leak onto a
// standalone node child.
child := *cfg
child.FullDuplex = true
child.Unordered = false
child.MaxConcurrentBatches = 1
child.NumShards = 1
child.contentSharded = false
// Redirect handling: each child's FD engine re-runs a MOVED/ASK (or retryable)
// reply through the redirect-aware ClusterClient instead of its own node-local
// standalone path. cc.process is the cluster redirect loop (MOVED -> target node
// + LazyReload, ASK -> followed via its own loop with ASKING, bounded by
// MaxRedirects). One fn serves every node: the redirect target is resolved from
// the reply, not the source node. startAttempt/writtenAt are unused here —
// cc.process owns its own MaxRedirects budget.
child.clusterReprocess = func(ctx context.Context, cmd Cmder, _ int, _ time.Time) error {
return cc.process(ctx, cmd)
}
// Connection-failure recovery budget for each child engine. A node.Client
// normalizes MaxRetries to -1 (cluster retries live in MaxRedirects), which the
// FD carry-replay would read as "budget already spent" and fail every in-flight
// command on the first socket error. Give the child the cluster's MaxRedirects
// so a transient node blip replays the unacked tail on a fresh connection.
child.clusterRetryBudget = cc.opt.MaxRedirects
return &clusterFDRouter{
parent: parent,
cc: cc,
blocking: blocking,
childCfg: &child,
children: make(map[*Client]*AutoPipeliner),
}
}
// submit routes cmd to the FD child that owns its slot. It is called from
// AutoPipeliner.submit AFTER diversion and preflight have been decided, so cmd
// is a batchable single-node command. On any routing miss (keyless command,
// unresolved slot, topology not loaded, or a node whose client turns out not to
// be FD-capable) it falls back to the normal cluster Process path — correct,
// just not pipelined.
func (r *clusterFDRouter) submit(ctx context.Context, cmd Cmder) AutoFuture {
// Mirror enqueue's closed contract: reject once the parent is closing rather
// than route to a child that Close is tearing down.
if r.parent.isClosed() {
return r.rejectClosed(cmd)
}
child, closed := r.childFor(ctx, cmd)
if closed {
// close() ran between the parent.isClosed() check above and here (the router
// is tearing down). Mirror the closed contract rather than route to — or
// create — a child the drain has already swept; getOrCreateChild discards any
// child it built in this window so it cannot leak.
return r.rejectClosed(cmd)
}
if child == nil {
return r.divertToProcess(ctx, cmd)
}
// Submit straight to the child's FD engine, skipping child.submit
// (AutoPipeliner.submit). The parent already decided this command is
// pipelineable — diversion (readTimeout/runsOutsidePipeline/isBlockingCmd/
// HIMPORT/mustDivert) and preflight ran above — so the child's submit would only
// re-run that same classification, hit its nil preflight, and build a finish
// closure: pure per-command CPU on the hot path (measured ~1/3 of submit cost is
// this second pass). child.fd is non-nil (getOrCreateChild screened it).
b := child.fd.submit(ctx, cmd)
if b == completedBatch && errors.Is(cmd.Err(), ErrClosed) {
// Topology GC (a cluster reload dropping this node, or the node's own pool
// close hook) can close this child at any point up to and including while
// fd.submit is parked on a full fd.ch waiting for room — the fd.ap.ctx.Done()
// / fd.closed arms in fdEngine.submit. Those keep the race SAFE (ErrClosed,
// no hang), but silently failing the command instead of falling back
// contradicts this router's own contract (see the doc comment above): a
// routing miss should divert to Process, not error. Distinguish from the
// CALLER's own ctx cancelling mid-backpressure (fdEngine.submit's separate
// ctx.Done() arm, which sets ctx.Err(), not ErrClosed) — that one must NOT
// be retried; the caller asked to stop.
//
// Neither setReady (below) nor any waiter has observed cmd yet — child.fd's
// rejection ran on this goroutine, before the async face is armed — so it is
// still safe to override. Re-resolve once: childFor rebuilds a fresh child
// for a node still in the topology (self-healing, same as its own
// cached-but-closed handling in getOrCreateChild), reports a genuine miss
// for a node that is gone, or reports the ROUTER itself closing (in which
// case rejectClosed below is the same outcome this rejection would have
// been anyway). One retry only, no loop: SetErr on the second attempt
// (accept, or fail again) simply overwrites this one.
child, closed = r.childFor(ctx, cmd)
if closed {
return r.rejectClosed(cmd)
}
if child == nil {
return r.divertToProcess(ctx, cmd)
}
// Clear the stale ErrClosed from the first attempt before retrying: a
// live engine's OWN synchronous rejection paths (lease failure, limiter
// deny, budget exhaustion) only stamp their real error when rawErr() is
// nil, so without this reset a second, different failure would leave the
// first attempt's ErrClosed in place and misreport a live engine as
// closed. A successful retry is unaffected either way — the reader's
// inline completion unconditionally overwrites cmd's error with the
// reply outcome.
cmd.SetErr(nil)
b = child.fd.submit(ctx, cmd)
}
if !r.blocking {
cmd.setReady(b)
}
return AutoFuture{cmd: cmd, batch: b}
}
// rejectClosed mirrors the parent AutoPipeliner's closed-submit contract: fail
// cmd with ErrClosed and, on the async face, mark it ready against the shared
// completedBatch sentinel immediately (no host goroutine, nothing to wait on).
func (r *clusterFDRouter) rejectClosed(cmd Cmder) AutoFuture {
cmd.SetErr(ErrClosed)
if !r.blocking {
cmd.setReady(completedBatch)
}
return AutoFuture{cmd: cmd, batch: completedBatch}
}
// divertToProcess routes cmd through the parent's normal (non-FD) diverted
// path — correct, just not pipelined. runOutsidePipeline sets the command
// ready itself on the deferred face and returns a batch that completes when
// the command has executed.
func (r *clusterFDRouter) divertToProcess(ctx context.Context, cmd Cmder) AutoFuture {
return AutoFuture{cmd: cmd, batch: r.parent.runOutsidePipeline(ctx, cmd)}
}
// childFor resolves the FD child for cmd's owning master node. It returns
// (nil, false) when the command should divert to Process (keyless, unresolved
// slot, topology not loaded, or a non-FD node), and (nil, true) when the router
// is closing (submit must then reject with ErrClosed rather than divert).
func (r *clusterFDRouter) childFor(ctx context.Context, cmd Cmder) (*AutoPipeliner, bool) {
slot := r.cc.cmdSlot(cmd, -1)
if slot < 0 {
// Keyless command: no single owning node. Let it run through Process, which
// applies the configured ShardPicker.
return nil, false
}
state, err := r.cc.state.Get(ctx)
if err != nil {
return nil, false
}
node, err := state.slotMasterNode(slot)
if err != nil || node == nil {
return nil, false
}
return r.getOrCreateChild(node.Client)
}
// getOrCreateChild returns the FD child autopipeliner for a node client,
// creating it on first use. The second return value reports that the router is
// closed. It returns (nil, false) when the node client is not FD-capable (its
// child engine did not engage) so the caller diverts that node to Process, and
// (nil, true) when the router has been closed (the caller must reject, not
// divert — the drain has already swept the children).
func (r *clusterFDRouter) getOrCreateChild(nc *Client) (*AutoPipeliner, bool) {
r.mu.RLock()
ch := r.children[nc]
closed := r.closed
r.mu.RUnlock()
if closed {
return nil, true
}
if ch != nil && !ch.IsClosed() {
return ch, false
}
// Build (or fetch the node client's cached) child WITHOUT holding the router
// lock: the node getter is idempotent (first call wins, cached on nc, and it
// rebuilds when its cached instance is closed — see getOrCreateAutoPipeliner),
// so a concurrent build just returns the same live instance. Keeping the
// getter off r.mu means a non-FD node (child.fd == nil) does not serialize
// every later submit on the write lock re-calling it.
var (
child *AutoPipeliner
err error
)
if r.blocking {
child, err = nc.AutoPipelineWithOptions(r.childCfg)
} else {
child, err = nc.AsyncAutoPipelineWithOptions(r.childCfg)
}
// Honesty check: only treat this node as an FD node if the engine actually
// engaged, is live, AND is redirect-aware. A node client without a pipeline pool
// (PipelinePoolSize < 0) falls back to half-duplex (child.fd == nil); a
// just-closed instance must not be cached (fd is not nilled on close). The
// redirectAware requirement guards the first-call-wins node getter: application
// code may have already created a plain standalone autopipeliner on this
// node.Client (e.g. via ForEachMaster), and the getter would hand that instance
// back. It has no clusterReprocess, so a MOVED/ASK reply on it would be surfaced
// to the caller instead of routed through the cluster client. Divert all of
// these to Process rather than dispatch through an engine that cannot follow
// redirects.
if err != nil || child == nil || child.fd == nil || child.IsClosed() || !child.fd.redirectAware {
return nil, false
}
r.mu.Lock()
if r.closed {
// close() ran while we built this child outside the lock: it already
// snapshotted+cleared the map, so storing ours would leak a held FD conn and
// its writer/reader goroutines (nothing would ever close it). Discard it.
// Close AFTER releasing the lock so a concurrent close() draining the
// snapshot does not serialize behind this child's own drain (Close blocks on
// it). Close is idempotent, so double-closing a node-client-cached instance
// close() also holds is harmless.
r.mu.Unlock()
_ = child.Close()
return nil, true
}
// Evict stale entries for OTHER node clients while the write lock is
// already held: topology GC can close a node client (and its cached
// child) while this router stays up, and once the cluster's slot map
// stops pointing at that *Client, getOrCreateChild is never called with
// it again — nothing else would notice it went stale. Without this sweep
// a closed child (and its FD engine, submit channel, held connection)
// sits retained in this map for the router's whole life on a cluster that
// scales down or replaces node addresses (cursor bugbot on #4002). The
// child is already closed (its own node-client close hook got there
// first), so this only drops the reference for GC — no Close() needed.
for k, v := range r.children {
if k != nc && v.IsClosed() {
delete(r.children, k)
}
}
// Prefer a live child another goroutine cached first; otherwise store ours.
if cached := r.children[nc]; cached != nil && !cached.IsClosed() {
child = cached
} else {
// Prune THIS node's entry the moment nc itself closes (topology GC, or
// any other Close), instead of relying solely on the sweep above: that
// sweep only runs when some OTHER node's cache miss takes this same
// write-lock path, so a cluster whose live submits only ever hit
// already-cached nodes would never prune a removed node's entry (cursor
// bugbot on #4002 — a gap in the sweep it sits next to). nc.onClose is
// the same registry AutoPipelineWithOptions above already wired the
// child's own drain to.
//
// Register BEFORE publishing the child, and under r.mu. The hook takes
// r.mu, so a node close racing this section resolves one of two ways:
// its run snapshot was taken before this register, in which case
// register reports false (the hook would never fire) and the node is
// already closed — discard the child and divert; or the snapshot comes
// later and the hook blocks on r.mu until this store is published, then
// deletes it. Registering after the unlock left a window where the
// close ran in between and the entry stayed for the router's life
// (cursor bugbot on #4002). The id is deterministic per (router, node),
// so a rebuild for this node replaces the same closure.
if !nc.onClose.register(clusterFDRouterEvictID(r, nc), func() error {
r.mu.Lock()
delete(r.children, nc)
delete(r.evictHooks, nc)
r.mu.Unlock()
return nil
}) {
r.mu.Unlock()
_ = child.Close()
return nil, false
}
if r.evictHooks == nil {
r.evictHooks = make(map[*Client]struct{})
}
r.evictHooks[nc] = struct{}{}
r.children[nc] = child
}
r.mu.Unlock()
return child, false
}
// len sums the pending backlog across all node children, for AutoPipeliner.Len.
func (r *clusterFDRouter) len() int {
r.mu.RLock()
defer r.mu.RUnlock()
total := 0
for _, ch := range r.children {
total += ch.Len()
}
return total
}
// close closes every node child and waits for each drain to finish. Called from
// the parent's drain before the parent's own (empty) shard sweep, and before
// clusterNodes closes the node clients — so each child flushes its accepted
// commands on the still-open node connection. A child may already have been
// closed by its node client's shared-pool close hook; AutoPipeliner.Close is
// idempotent, so the second close returns immediately.
func (r *clusterFDRouter) close() error {
r.mu.Lock()
// Mark closed under the lock BEFORE snapshotting: a submit racing past the
// parent.isClosed() check that then builds a child outside the router lock will
// see r.closed when it takes the lock to store, and discard its orphan instead
// of leaking it (getOrCreateChild). Children created and stored before this
// point are in the snapshot below and get closed here.
r.closed = true
children := make([]*AutoPipeliner, 0, len(r.children))
for _, ch := range r.children {
children = append(children, ch)
}
r.children = make(map[*Client]*AutoPipeliner)
// Detach this router's evict hooks from every node client it registered on
// — not just the nodes still in children (the sweep and the hook itself drop
// entries while the registration stays live). Left registered, each hook
// keeps the closed router, and through parent the AutoPipeliner and its
// clusterReprocess closure, reachable until that node client itself closes,
// and a cluster that cycles autopipeliners accumulates one per cycle per
// node (codex + cursor bugbot on #4002). Snapshot under the lock, unregister
// outside it: unregister takes only the registry's own mutex.
hooked := make([]*Client, 0, len(r.evictHooks))
for nc := range r.evictHooks {
hooked = append(hooked, nc)
}
r.evictHooks = nil
r.mu.Unlock()
for _, nc := range hooked {
nc.onClose.unregister(clusterFDRouterEvictID(r, nc))
}
var firstErr error
for _, ch := range children {
// WaitClosed is the authoritative drain result: Close returns the drain error
// only to the caller that WINS the close CAS, and returns nil to a loser. If
// application code already started Close on this node autopipeliner (exposed
// via ForEachMaster), our ch.Close() loses and returns nil while the real
// error surfaces here. So prefer WaitClosed's result and fall back to Close's.
cerr := ch.Close()
if werr := ch.WaitClosed(); werr != nil {
cerr = werr
}
if cerr != nil && firstErr == nil {
firstErr = cerr
}
}
return firstErr
}
package redis
import (
"context"
"errors"
"fmt"
"runtime/debug"
"sync"
"sync/atomic"
"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"
)
// Ordered full-duplex dispatch for the async and blocking AutoPipeline faces.
//
// Half-duplex sends one batch per round trip. A slow link caps throughput at
// batch/RTT, and a late command waits one RTT behind the in-flight batch.
// Full-duplex holds one pipeline-pool connection with a writer goroutine and a
// reader goroutine. The writer streams command groups without waiting for
// replies. The reader drains replies in FIFO order and completes each command
// when its reply lands. Latency is about 1 RTT; throughput saturates the pipe.
//
// Ordering: each goroutine's commands run in submit order. Order between
// goroutines is not defined. The submit channel is MPSC and the connection is
// FIFO on the wire, so a command's position in the in-flight deque matches its
// reply.
//
// Retries: on a connection failure the engine re-issues the unacked tail, in
// order, on a fresh connection ahead of new work. It honors shouldRetry,
// MaxRetries, backoff, and the per-command NoRetry flag. A NoRetry command in the
// tail fails the tail instead of replaying it (half-duplex uses cmdsContainNoRetry
// for the same result). After the budget is spent it fails those commands and
// keeps serving on a fresh connection. The at-least-once contract matches a normal
// Pipeline: a command whose write landed but whose reply was lost can re-execute.
// Only the unacked tail is ambiguous.
//
// Enable it with AutoPipelineOptions.FullDuplex. It runs on the ordered
// single-shard faces of a standalone *Client with a pipeline pool. Tune it with
// the FullDuplex* options (see their GoDoc). RESP3 push frames are demuxed inline.
// Cluster support and window auto-tune are follow-ups (see
// AP_ORDERED_FULLDUPLEX_DESIGN.md).
var errFDReaderGone = errors.New("redis: autopipeline full-duplex reader exited")
// errFDPanicRecovered marks a session failure from a recovered panic (reply
// decode or batch encode). It wraps with %w so the retry decision recognizes it:
// the connection is desynced like a transport error, so the engine replays the
// unacked tail on a fresh connection instead of failing it. Most of that tail is
// commands the panic never touched. shouldRetry alone would reject these plain
// error values and fail innocent in-flight commands.
var errFDPanicRecovered = errors.New("redis: autopipeline: full-duplex panic recovered")
// errFDPushDrainFailed marks a session failure from a push-notification drain
// error on the reply path. A custom PushNotificationProcessor can consume part of
// a frame and desync the reader. It wraps with %w like errFDPanicRecovered so the
// retry decision recognizes it: the connection is desynced, so the next read would
// misalign the FIFO. The engine fails the session and replays the unacked tail on
// a fresh connection instead of reading shifted bytes.
var errFDPushDrainFailed = errors.New("redis: autopipeline: full-duplex push drain failed")
// fdReplyIsFatal reports whether a per-reply read error must ABORT the FD session
// (stop the reader and leave the unread tail for replay) instead of being treated
// as this command's reply. A push-drain desync is always fatal, even when it wraps
// a Redis-typed cause. errFDPushDrainFailed carries the processor error via %w for
// errors.Is/As, but that cause must not let isRedisError reclassify the desync as a
// normal reply: settling or diverting it would leave the unread frame in the stream
// and shift every later reply. Any other error is fatal only when it is not a Redis
// error (a real transport or protocol failure). A plain Redis error is a real reply
// and is handled inline.
//
// A *RawWriteToCmd is the exception: it streams the raw reply — INCLUDING a
// server error line — straight to the caller's io.Writer and returns nil for a
// server reply, so a non-nil error from its readReply is never a server reply.
// It is a sink or socket failure mid-frame, which leaves the payload unread and
// the socket desynced. That is session-fatal even when the sink error itself
// implements redis.Error (isRedisError would otherwise call it a benign reply,
// advance the reader, and let the next command read the leftover bytes). Classify
// by the command type, not the error, for this one streaming Cmder.
func fdReplyIsFatal(cmd Cmder, e error) bool {
if _, ok := cmd.(*RawWriteToCmd); ok {
return true
}
return errors.Is(e, errFDPushDrainFailed) || !isRedisError(e)
}
// errFDRetryBudgetExhausted fails a carried command that has already spent its full
// retry budget (attempts > MaxRetries) when Close routes the unacked tail to
// shutdownFlush. The shutdown pipeline must not grant another MaxRetries+1
// executions and push a mutating command past its budget.
var errFDRetryBudgetExhausted = errors.New("redis: autopipeline: retry budget exhausted before shutdown flush")
// fdPartitionByBudget splits a carried tail into commands that still have retry
// budget (kept) and commands that have spent it (exhausted, attempts > maxRetries —
// one FD attempt plus maxRetries replays). It always allocates a fresh kept slice.
// carry is caller-owned (the unacked tail, or a handoff suffix that may alias the
// in-flight ring), and shutdownFlush appends the queue to kept, so a write into
// carry's backing array would corrupt the caller's slice.
func fdPartitionByBudget(carry []fdReq, maxRetries int) (kept, exhausted []fdReq) {
kept = make([]fdReq, 0, len(carry))
for _, r := range carry {
if r.attempts > maxRetries {
exhausted = append(exhausted, r)
continue
}
kept = append(kept, r)
}
return kept, exhausted
}
// errFDConnMoving signals that carry replay stopped early because the held
// connection was marked for handoff (MOVING/FAILING_OVER) while a recovered carry
// was still being written. The connection is still alive, so writeCarryChunked
// returns the UNWRITTEN suffix out-of-band, not pushed into the in-flight deque.
// The session drains the already-written prefix to completion (those callers get
// real replies and are never re-executed) and Puts the connection back through a
// clean fdRecycle. The maintnotifications OnPut hook then performs the seamless
// handoff: queueHandoff plus MarkQueuedForHandoff clears ShouldHandoff, so the conn
// is reusable and the worker moves it to the new endpoint. Only the never-sent
// suffix replays on the next lease. Contrast errFDReaderGone and write errors,
// where the connection is dead: there the suffix IS pushed into the deque and the
// whole unacked tail replays, because a clean drain is impossible.
var errFDConnMoving = errors.New("redis: autopipeline: full-duplex connection moving")
// errFDMaxHold signals that carry replay stopped early because the connection has
// been held past FullDuplexMaxHold while re-issuing a recovered tail (a long
// replay under continuous load, or one stalled on window backpressure, never
// reaches the serve loop where max-hold is normally observed). Like errFDConnMoving
// the connection is still ALIVE: writeCarryChunked returns the UNWRITTEN suffix
// out-of-band, the session drains the already-written prefix to completion (those
// callers get real replies, never re-executed), and a clean fdRecycle Puts the
// connection back so the hold ends. Only the never-sent suffix replays on the next
// lease. Observed only on the LIVE path (ap.ctx not cancelled); a terminating Close
// bounds its own flush and outranks max-hold.
var errFDMaxHold = errors.New("redis: autopipeline: full-duplex connection max-hold reached")
// Full-duplex tuning defaults, applied by newFDEngine when the corresponding
// AutoPipelineOptions field is zero (rationale in the FullDuplex* GoDoc). The
// window must exceed the bandwidth-delay product (RTT × target rate) or it
// throttles throughput; the deque holds only ACTUAL in-flight, so a generous
// default costs no memory until a stalled peer makes in-flight grow.
const (
fdDefaultWindow = 65536
fdDefaultIdle = time.Second
fdDefaultMaxHold = 5 * time.Second
)
// fdCloseFlushWait bounds how long the graceful-Close backlog flush waits for
// the reader to drain below the window before giving up the window bound (see
// writeCarryChunked). Long enough that a live reader always drains within it,
// short enough that a stuck reader (quiet peer, ReadTimeout disabled) does not
// stall Close.
const fdCloseFlushWait = time.Second
// fdResult is why a full-duplex session ended.
type fdResult int
const (
fdGraceful fdResult = iota // AutoPipeliner Close: engine exits
fdConnErr // connection failure: unacked tail returned for replay
fdIdle // idle: conn returned cleanly; re-lease on next command
fdRecycle // max-hold: conn returned cleanly; re-lease immediately (work pending)
fdLeaseErr // could not lease/init a conn for a new session: retry, then fail carry + backlog after MaxRetries
)
// fdReq pairs a command with the per-command apBatch whose done channel is
// closed once that command's reply has landed (or it is finally failed).
//
// hookDone is non-nil only when the client has process hooks: the command then
// has a host goroutine (hostHook) running the hook chain, and finalizing closes
// hookDone instead of the batch — the host closes the batch after the hook
// returns, so the hook brackets the command and can rewrite its result before the
// waiter wakes. Nil (the hook-free fast path) finalizes the batch directly.
type fdReq struct {
cmd Cmder
batch *apBatch
hookDone chan struct{}
// ctx is the caller's submit context, kept so the per-command OTel metric can
// be recorded against it (span/baggage correlation), mirroring process().
ctx context.Context
// writtenAt is stamped at the command's FIRST flush to the wire and kept across
// replays; the reader uses write→reply as the command's operation duration for
// the OTel metric. Anchoring on the first write (not the last replay) makes the
// duration span the whole retry sequence, matching the normal command path,
// instead of timing only the final attempt.
writtenAt time.Time
// attempts counts how many times this command has been issued: 1 at submit,
// incremented on each connection-error replay of the carried tail. Fed to the
// OTel duration/error callbacks so a command that succeeded on a replacement
// connection reports its real attempt count (retry_attempts), matching the
// normal command path instead of always reporting a single attempt.
attempts int
// sent is set once the command MAY have reached the wire. writeBatch stamps it
// before the flush, so a partial write or an encoder panic still counts. It is
// sticky across replays. The NoRetry gate keys on it: a never-sent NoRetry command
// is replayable, because issuing it is its FIRST send; a sent NoRetry command must
// be failed rather than risk a second execution. Without the marker the gate failed
// never-sent NoRetry commands recovered from a dead connection's backlog, returning
// an error for a command the server never saw.
sent bool
// limReport is the Limiter obligation for the WRITTEN chunk this req closes:
// non-nil ONLY on the LAST req of a chunk that Allow() admitted and writeBatch
// then wrote cleanly. It rides the in-flight deque so the reply-side outcome
// settles the chunk's single ReportResult — nil once every reply of the chunk
// has been read (reader), or the transport error when the chunk's unread
// replies are abandoned (settleTail). A write-failed chunk reports at write
// time and carries no obligation here. See fdLimiterReport.
limReport *fdLimiterReport
}
// fdLimiterReport is one chunk's outstanding Limiter obligation: the pending
// ReportResult that must fire exactly once for the Allow() that admitted the chunk
// in writeBatch. Reporting the WRITE outcome was wrong for a circuit breaker
// (finding ed53z): a peer that accepts the write but closes before replying, with
// replay writes also succeeding, would only ever feed the breaker success and never
// open it. So the obligation records the REPLY-side outcome. The reader settles
// ReportResult(nil) once every reply of the chunk has landed (a server that answers
// is healthy, reply-level errors included). A transport failure that abandons the
// chunk's unread replies settles the error (settleTail). The CAS makes it
// exactly-once, since the reader and a failure path can both reach the same
// obligation.
type fdLimiterReport struct {
lim Limiter
done atomic.Bool
}
// settle fires ReportResult(err) at most once for this obligation. A nil
// receiver (a chunk with no Limiter) and every call after the first are no-ops.
//
// ReportResult is user code, run on FD background goroutines (the reader's success
// path, plus the write-failure and settleTail paths). A panic must not reach the
// reader's session-failure recovery, which would replay an already-consumed reply
// and execute the command twice, and must not escape fd.run. Recover and swallow it:
// the outcome is already decided, reporting is fire-and-forget, and the CAS already
// fired, so the strict Allow/ReportResult pairing still holds.
func (o *fdLimiterReport) settle(err error) {
if o == nil {
return
}
if o.done.CompareAndSwap(false, true) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex limiter ReportResult panic: %v\n%s", r, debug.Stack())
}
}()
o.lim.ReportResult(err)
}
}
// complete finalizes a command whose result is already set on it: it wakes the
// caller directly, or (when hooks are present) hands off to the command's host
// goroutine, which runs the hook chain and then wakes the caller.
func (r fdReq) complete() {
if r.hookDone != nil {
close(r.hookDone)
return
}
r.batch.close()
}
// fdInflight is an ordered FIFO of written-but-unacknowledged commands. The
// writer appends to the back; the reader reads the front's reply then pops it.
// On a connection failure the remaining entries (front→back) are exactly the
// unacked tail, in order, ready to replay. Two close modes:
// - graceful: no more pushes, but the reader keeps reading the remaining
// replies and exits once drained (clean Close).
// - recover: hard stop; the reader abandons the remaining, which are returned
// to the retry loop for replay.
type fdInflight struct {
mu sync.Mutex
cond *sync.Cond
// buf is a ring buffer of in-flight entries: the writer appends at the back
// (head+count), the reader pops from the front (head). A grow-only slice
// (append + reslice-off-front) reallocated its backing array on every window
// churn — the single largest allocation source under load — because the
// popped-off prefix was never reused. The ring reuses the whole array for the
// life of the session; it only grows when live count would exceed capacity.
buf []fdReq
head int // index of the front (oldest) live entry
count int // number of live entries
noMorePush bool // graceful: drain remaining then reader exits
hardClosed bool // recover: reader stops immediately, remaining replayed
room chan struct{} // cap-1 signal: the reader popped, so there is room
peak int // high-water mark of live count; observability for the backpressure test
advanced int // total entries the reader completed this session (progress signal)
}
//nolint:unused // used by the full-duplex tests; lint runs with tests:false.
func newFDInflight() *fdInflight { return newFDInflightCap(0) }
// newFDInflightCap presizes the ring so a busy session avoids repeated early
// reallocations, capping the initial size at min(maxBatch, window). The window is
// the hard ceiling on live count: the writer blocks once in-flight reaches it and
// caps each drain by the remaining room. So a MaxBatchSize larger than the window
// must not presize beyond it (MaxBatchSize is an uncapped soft per-flush threshold):
// a huge MaxBatchSize with a small window would allocate that many entries up front
// (tens of MB, or an OOM) and again after every idle or recycle. Capping at maxBatch
// keeps the common small-batch default unchanged. grow() is the backstop up to the
// window as load ramps. Tests use the zero-cap no-arg form and let it grow.
func newFDInflightCap(initialCap int) *fdInflight {
f := &fdInflight{room: make(chan struct{}, 1)}
if initialCap > 0 {
f.buf = make([]fdReq, initialCap)
}
f.cond = sync.NewCond(&f.mu)
return f
}
func (f *fdInflight) len() int {
f.mu.Lock()
n := f.count
f.mu.Unlock()
return n
}
// grow ensures the ring can hold at least need entries, preserving FIFO order
// and normalizing the front to index 0. Caller holds f.mu.
func (f *fdInflight) grow(need int) {
if need <= len(f.buf) {
return
}
nc := len(f.buf) * 2
if nc < need {
nc = need
}
if nc < 8 {
nc = 8
}
nb := make([]fdReq, nc)
// Unwrap the live entries into the new buffer starting at 0.
for i := 0; i < f.count; i++ {
nb[i] = f.buf[(f.head+i)%len(f.buf)]
}
f.buf = nb
f.head = 0
}
// pushBatch appends a whole write batch under one lock (fewer lock ops than
// per-command push — matters at loopback op rates).
func (f *fdInflight) pushBatch(reqs []fdReq) {
f.mu.Lock()
f.grow(f.count + len(reqs))
for _, r := range reqs {
f.buf[(f.head+f.count)%len(f.buf)] = r
f.count++
}
if f.count > f.peak {
f.peak = f.count
}
f.cond.Signal()
f.mu.Unlock()
}
// peakLen returns the high-water mark of in-flight entries seen so far (test
// observability for the backpressure bound).
//
//nolint:unused // used by the full-duplex backpressure tests; lint runs with tests:false.
func (f *fdInflight) peakLen() int {
f.mu.Lock()
n := f.peak
f.mu.Unlock()
return n
}
// fdReadBatch caps how many replies the reader snapshots per lock acquisition:
// enough to amortize the mutex over many reads, small enough that the reader
// advances (and signals writer room) frequently even with a deep in-flight.
const fdReadBatch = 256
// frontBatch blocks until entries are available (or the deque is closing) and
// returns a snapshot of the front (up to fdReadBatch). ok=false means the reader
// should exit. The writer only ever appends at the back, so this prefix stays the
// front until the reader advance()s it. The snapshot is copied into the caller's
// buf (may span the ring's wrap seam as two segments), so it never aliases the
// backing array.
func (f *fdInflight) frontBatch(buf []fdReq) ([]fdReq, bool) {
f.mu.Lock()
for f.count == 0 && !f.noMorePush && !f.hardClosed {
f.cond.Wait()
}
if f.hardClosed || f.count == 0 {
f.mu.Unlock()
return buf[:0], false
}
n := f.count
if n > fdReadBatch {
n = fdReadBatch
}
buf = buf[:0]
// First segment: head .. min(end-of-array, head+n).
seg := len(f.buf) - f.head
if seg > n {
seg = n
}
buf = append(buf, f.buf[f.head:f.head+seg]...)
if seg < n {
// Wrapped: remainder from the start of the array.
buf = append(buf, f.buf[:n-seg]...)
}
f.mu.Unlock()
return buf, true
}
// advance removes the front n entries the reader has completed and signals the
// writer that in-flight has room.
func (f *fdInflight) advance(n int) {
if n <= 0 {
return
}
f.mu.Lock()
if n > f.count {
n = f.count
}
if n == 0 {
// Nothing to drop (empty ring, or clamped away). Return before the modulo
// below, which divides by len(f.buf) and would panic on a never-grown ring
// (nil buf). The slice implementation tolerated advance on an empty queue;
// preserve that.
f.mu.Unlock()
return
}
// Zero each consumed entry before dropping it: the ring keeps its backing
// array for the whole session (curInflight holds the deque while the engine
// idles), so otherwise a drained burst retains a window's worth of completed
// fdReq values — command args, caller contexts, batches — until the slot is
// overwritten by a later push.
for i := 0; i < n; i++ {
f.buf[(f.head+i)%len(f.buf)] = fdReq{}
}
f.head = (f.head + n) % len(f.buf)
f.count -= n
f.advanced += n
f.mu.Unlock()
select {
case f.room <- struct{}{}:
default:
}
}
// advancedTotal reports how many commands the reader completed this session —
// the progress signal that resets the reconnect retry budget (a session that
// completed work makes the next connection drop a NEW failure, not a
// consecutive one).
func (f *fdInflight) advancedTotal() int {
f.mu.Lock()
n := f.advanced
f.mu.Unlock()
return n
}
func (f *fdInflight) empty() bool {
f.mu.Lock()
n := f.count
f.mu.Unlock()
return n == 0
}
func (f *fdInflight) closeGraceful() {
f.mu.Lock()
f.noMorePush = true
f.cond.Broadcast()
f.mu.Unlock()
}
// hardClose signals the reader to stop immediately (used on a connection error).
// It deliberately does NOT take the queue: the caller must wait for the reader to
// exit (<-readerDone) and THEN call takeRemaining, so every entry stays owned by
// exactly one of {the reader completed it, recovery replays/fails it}. A
// concurrent grab could scoop an entry the reader had completed but not yet
// advanced, handing an already-executed command to the retry loop — a double
// execution and, on the hooked path, a double close of hookDone (panic).
func (f *fdInflight) hardClose() {
f.mu.Lock()
f.hardClosed = true
f.cond.Broadcast()
f.mu.Unlock()
}
// takeRemaining returns the entries the reader left unacknowledged, in order,
// and clears the queue. Call ONLY after the reader has exited (<-readerDone):
// the reader advance()s every command it completes, so what remains is exactly
// the unacked tail, and with the reader gone there is no concurrent access. The
// live entries are unwrapped into a fresh contiguous slice (they may span the
// ring's wrap seam); the ring is terminal for the session, so the backing array
// is dropped.
func (f *fdInflight) takeRemaining() []fdReq {
f.mu.Lock()
if f.count == 0 {
f.buf = nil
f.head = 0
f.mu.Unlock()
return nil
}
rem := make([]fdReq, f.count)
for i := 0; i < f.count; i++ {
rem[i] = f.buf[(f.head+i)%len(f.buf)]
}
f.buf = nil
f.head = 0
f.count = 0
f.mu.Unlock()
return rem
}
type fdEngine struct {
ap *AutoPipeliner
client *Client
pool pool.Pooler
ch chan fdReq // MPSC ordered queue: many submitters -> the writer
maxBatch int
window int // max in-flight (written, unacked) before the writer waits
idle time.Duration // return the conn after this idle gap (0 = never)
maxHold time.Duration // force a clean return at least this often (0 = never)
recycles atomic.Int64 // clean returns (idle + max-hold); observability/tests
curInflight atomic.Pointer[fdInflight] // current session's in-flight deque; test observability
curConn atomic.Pointer[pool.Conn] // current session's held conn; test observability (handoff)
fastSubmitTake atomic.Int64 // fast-path submits taken; test observability
// curConnSpilled is true while the current session holds a MAIN-pool connection —
// a spilled lease, or the no-dedicated-pool case where fd.pool IS the main pool.
// retryOnNormalConn must not block the reader on retrySem then: the session pins a
// main-pool conn until the reader drains, so off-pipe retries waiting on that same
// pool would deadlock (see retryOnNormalConn). Set in attempt before the reader is
// spawned; biased true when a lease is undecided so an unknown state never blocks.
curConnSpilled atomic.Bool
submitMu sync.RWMutex // guards closed; RLock across the submit send, WLock to close the gate
closed bool // set once run() is tearing down; submit then rejects new work
retryWg sync.WaitGroup // tracks off-pipe retries diverted to the normal client path; run() waits it so Close does too
retrySem chan struct{} // caps concurrent off-pipe retries at the window (see retryOnNormalConn)
hostWg sync.WaitGroup // tracks per-command hook-host goroutines (see hostHook); run() waits it so Close does not return while a post-next ProcessHook is still running
// fastSubmit tries a non-blocking channel send before the blocking three-arm
// select in submit (from AutoPipelineOptions.FullDuplexFastSubmit). Gated on
// submit-queue depth (fdFastSubmitGatePct) so it only runs while the queue is
// shallow; a deep/bursting queue falls to the fair blocking select.
fastSubmit bool
// runPipeline runs a shutdown-flush chunk through the client's pipeline retry
// loop. Test seam: nil in production (flushReqs falls back to
// client.processPipelineRetries), so tests can drive flushReqs's chunk loop
// without a live server.
runPipeline func(ctx context.Context, cmds []Cmder, maxRetries int) error
// reprocess re-runs a command that came back with a retryable reply or a
// redirect on a NORMAL (non-FD) path. Defaults to client.processStartingAt (the
// standalone client, which cannot follow a redirect). The cluster full-duplex
// router overrides it (via AutoPipelineOptions.clusterReprocess) to route
// through the redirect-aware ClusterClient. Set once in newFDEngine before
// run() starts, so the reader goroutine reads it race-free.
reprocess func(ctx context.Context, cmd Cmder, startAttempt int, writtenAt time.Time) error
// redirectAware is true when reprocess follows MOVED/ASK (cluster mode). The
// reply path then diverts a redirect to reprocess instead of surfacing it
// inline. Standalone FD leaves this false: its normal path cannot follow a
// redirect, so diverting would waste a round trip and return the redirect anyway.
redirectAware bool
// clusterRetryBudget is the connection-failure recovery budget used ONLY in
// cluster mode (redirectAware): the ClusterClient's MaxRedirects, injected by the
// router. See retryBudget().
clusterRetryBudget int
}
// retryBudget bounds the engine's OWN connection-failure recovery: the lease retry
// loop, the carried-tail replay (fdPartitionByBudget) and the Close-path flush.
// Standalone FD uses the client's MaxRetries. Cluster node clients normalize
// MaxRetries to -1 (cluster retries live in MaxRedirects), so a cluster child would
// otherwise treat every carried command as budget-spent and fail all in-flight
// commands on the first socket error instead of replaying them; the router injects
// the ClusterClient's MaxRedirects (clusterRetryBudget) for that case. Reading the
// client's option live (rather than caching a field) keeps test-constructed engines
// working: they set client but not the cluster budget. The reply-side standalone
// retryable divert still uses client.opt.MaxRetries directly (only reached when
// !redirectAware).
func (fd *fdEngine) retryBudget() int {
if fd.redirectAware {
return fd.clusterRetryBudget
}
return fd.client.opt.MaxRetries
}
// fdFastSubmitGatePct bounds fastSubmit to when the submit channel is below this
// percent full. The non-blocking send cuts the submit-path selectgo cost, but
// past saturation it would let producers that find room jump ahead of producers
// blocked on a full channel, starving them and inflating p99. Gating on len(ch)
// closes the fast path exactly as that backup starts, so contended traffic uses
// the fair blocking select and the tail is preserved. 10% is the measured
// tail-safe point (looser gates leave the tail elevated; see FD perf notes).
const fdFastSubmitGatePct = 10
func newFDEngine(ap *AutoPipeliner, client *Client) *fdEngine {
mb := ap.config.MaxBatchSize
if mb <= 0 {
mb = 200
}
// Resolve tuning ONCE here: a zero field means "use the default". In
// particular window must never be 0 — the writer's backpressure gate is
// `for inflight.len() >= window`, so window==0 (0 >= 0) would block the
// writer on the very first submit. Validate rejects negatives.
w := ap.config.FullDuplexWindow
if w <= 0 {
w = fdDefaultWindow
}
// Publish every resolved value so Config() reports what the engine actually
// enforces (a zero field -> its default), not the raw 0 the user passed. Runs
// once at construction before ap escapes newAutoPipeliner, so no Config() reader
// races these writes. Validate rejects negatives. (MaxBatchSize is already
// defaulted upstream in newAutoPipeliner, so it needs no write-back here.)
ap.config.FullDuplexWindow = w
idle := ap.config.FullDuplexIdleTimeout
if idle <= 0 {
idle = fdDefaultIdle
}
ap.config.FullDuplexIdleTimeout = idle
maxHold := ap.config.FullDuplexMaxHold
if maxHold <= 0 {
maxHold = fdDefaultMaxHold
}
ap.config.FullDuplexMaxHold = maxHold
// The submit queue does not need window-sized storage. Backpressure comes from
// the in-flight deque, which grows only with ACTUAL in-flight, while a buffered
// channel allocates its full capacity up front: several MiB per engine at the
// default window, before any command is submitted. Cap the queue. Total
// outstanding stays bounded by cap+window, and submit just blocks a little earlier
// under a burst.
chCap := w
if chCap > 4096 {
chCap = 4096
}
// The off-pipe retry bound is a GOROUTINE budget, not a memory window. Diverted
// retries serialize on the main pool's PoolSize connections, so slots beyond about
// 2x the pool only hold 8 KiB stacks and pool-wait turns. Sizing it to w (default
// 65536) let a retryable-reply storm — e.g. LOADING during a server restart, which
// diverts EVERY reply — park tens of thousands of goroutines. 2x the pool keeps
// backoff sleepers overlapping with pool waiters. The reader blocking on a full sem
// is the designed end-to-end backpressure (see retryOnNormalConn); it now just
// engages earlier.
retryCap := 2 * client.opt.PoolSize
if retryCap > w {
retryCap = w
}
if retryCap < 1 {
retryCap = 1
}
fd := &fdEngine{
ap: ap,
client: client,
pool: client.getPipelinePool(),
ch: make(chan fdReq, chCap),
maxBatch: mb,
window: w,
idle: idle,
maxHold: maxHold,
retrySem: make(chan struct{}, retryCap),
fastSubmit: ap.config.FullDuplexFastSubmit,
}
// Redirect/retry reprocess target. Default: the standalone client's own retry
// path (cannot follow a redirect). Cluster mode injects a redirect-aware
// ClusterClient path via config, which also flips redirectAware so the reply
// path diverts MOVED/ASK. Set before run() starts (below, in the caller), so
// the reader reads both fields without a race.
if ap.config.clusterReprocess != nil {
fd.reprocess = ap.config.clusterReprocess
fd.redirectAware = true
// Cluster node clients normalize MaxRetries to -1, which would make the
// carry-replay budget treat every command as spent. Use the cluster's
// MaxRedirects (where cluster retries live), injected by the router.
// retryBudget() reads this when redirectAware.
fd.clusterRetryBudget = ap.config.clusterRetryBudget
} else {
fd.reprocess = client.processStartingAt
}
return fd
}
// submit enqueues a command onto the ordered stream and returns its batch.
// Blocks when the queue is full (backpressure) or bails if the engine is
// closing. The caller (AutoPipeliner.submit) stamps setReady on the async face.
// With process hooks installed a per-command host goroutine runs the hook chain
// (see hostHook) and ctx parents its span; the hook-free path skips that
// goroutine and channel entirely.
func (fd *fdEngine) submit(ctx context.Context, cmd Cmder) *apBatch {
if fd.ap.isClosed() {
cmd.SetErr(ErrClosed)
return completedBatch
}
var hookDone chan struct{}
// With process hooks installed, run the chain on a per-command host goroutine
// (see hostHook). Hooks are added before the client serves traffic, so the host
// loads live hook state like the synchronous path. hookCount is one atomic load.
if fd.ap.pipeliner.hookCount() > 0 {
hookDone = make(chan struct{})
}
// Hook-free blocking face: the batch is a single-waiter completion signal
// discarded after Wait, so draw it from the pool (buffered done, recycled in
// processBlocking). Every other shape — the async face (batch installed on
// the command, read repeatedly) and the hooked path (host goroutine owns
// completion) — needs the close()-once channel.
var b *apBatch
if hookDone == nil && fd.ap.blocking {
b = getFDBlockingBatch()
} else {
b = newAPBatch()
}
req := fdReq{cmd: cmd, batch: b, hookDone: hookDone, ctx: ctx, attempts: 1}
// Send under RLock and re-check closed so a send can never win the race with
// run()'s shutdown drain (takeQueue: WLock, set closed, drain fd.ch). Once the
// final drain has run no new req can land in fd.ch, where it would never be
// completed and would hang its caller forever. A send blocked on a full channel
// is released by the ctx.Done() branch below, so holding the RLock cannot wedge
// the WLock.
fd.submitMu.RLock()
if fd.closed {
fd.submitMu.RUnlock()
// Recycle the pooled completion batch drawn above but never admitted, so a
// rejection does not discard one pooled batch + channel per command (a no-op
// for the newAPBatch shape, which is not pooled).
putFDBlockingBatch(b)
cmd.SetErr(ErrClosed)
// Submit-time rejection: return the shared completedBatch sentinel (no host
// was started) so processAsync surfaces the error from raw Process(ctx,cmd),
// matching every other submit-time-rejection path.
return completedBatch
}
// Fast path (opt-in via FullDuplexFastSubmit): while the submit queue is
// shallow, a non-blocking send skips the blocking three-arm selectgo that
// dominates submit CPU at high producer counts. Admission is IDENTICAL to the
// blocking case below (same gate held, same host start, same return); only the
// wait is skipped. The queue-depth gate keeps this off once the channel bursts
// deep, so contended traffic takes the fair blocking select and the p99 tail is
// preserved. On a miss (or a full channel) it falls through to that select.
if fd.fastSubmit && len(fd.ch)*100 < cap(fd.ch)*fdFastSubmitGatePct {
select {
case fd.ch <- req:
fd.fastSubmitTake.Add(1) // test observability; single atomic on the (already RLock'd) fast path
if hookDone != nil {
fd.hostWg.Add(1)
go fd.hostHook(ctx, cmd, b, hookDone)
}
fd.submitMu.RUnlock()
return b
default:
}
}
select {
case fd.ch <- req:
// Accepted. Start the hook host ONLY now: a submission that is never admitted
// (the cancel paths below) must not leak a host goroutine. The Add happens under
// the gate, so it is ordered before the shutdown drain's WLock and run()'s
// hostWg.Wait never races an Add on a zero counter.
//
// The readiness gate (setReady, stamped by the caller after we return) is
// deliberately NOT installed here first: it would change nothing for a hook
// on the host goroutine, which is the batch's executor and whose result
// accessors never block (await's executor guard — blocking there would
// self-deadlock, since only the host closes b.done). A pre-next read on the
// host is the not-yet-executed view whether or not the gate is set; the
// FullDuplex contract forbids it (see the FullDuplex field doc).
if hookDone != nil {
fd.hostWg.Add(1)
go fd.hostHook(ctx, cmd, b, hookDone)
}
fd.submitMu.RUnlock()
return b
case <-ctx.Done():
// Caller's ctx expired while backpressured (window/channel full): honor it
// instead of blocking until room or Close (#3964). Not admitted and no host
// started, so this is a submit-time failure — return the completedBatch sentinel
// so raw Process(ctx,cmd) reports the ctx error.
fd.submitMu.RUnlock()
putFDBlockingBatch(b) // recycle the unadmitted pooled batch (no-op if not pooled)
cmd.SetErr(ctx.Err())
return completedBatch
case <-fd.ap.ctx.Done():
fd.submitMu.RUnlock()
putFDBlockingBatch(b) // recycle the unadmitted pooled batch (no-op if not pooled)
cmd.SetErr(ErrClosed)
return completedBatch
}
}
// hostHook runs the user process-hook chain for one full-duplex command on its
// own goroutine, started only when hookCount()>0. The chain starts at ≈ submit
// time and its next() blocks until the reader (or a failure/close path) signals
// hookDone, so an observing hook spans the command's real write→reply latency and
// a hook that rewrites the result is honored before the waiter wakes. Each
// command is reported individually (withProcessHook), not as a pipeline batch.
func (fd *fdEngine) hostHook(ctx context.Context, cmd Cmder, b *apBatch, hookDone chan struct{}) {
// Declared first so it runs last: Close waits hostWg (via run()), and the host
// is done only after the recover defer below has also run.
defer fd.hostWg.Done()
// Mark this goroutine as the batch's executor so a hook that reads its own
// command's result after next() (cmd.Err(), a documented pattern) sees the
// just-executed view instead of blocking on batch.done — which only THIS
// goroutine closes, below, so without the mark such a hook self-deadlocks.
// Mirrors runOutsidePipeline's async dispatch guard.
if fd.ap.armSelfDeadlockGuard() {
b.dispGid.Store(curGoroutineID())
}
// A user ProcessHook runs on this goroutine; an unrecovered panic here would
// crash the process (and leave the caller blocked on b). Recover, fail the
// command, and close the batch so the waiter always wakes — mirroring the
// dispatch path's recoverDispatchPanic.
// awaited tracks whether hookDone has already been received, so no path awaits
// it twice. hookDone is closed (not sent) by complete(), so a second receive is
// harmless in practice, but tracking it keeps the recover path unambiguously
// free of a redundant await regardless of where a panic lands.
awaited := false
defer func() {
if r := recover(); r != nil {
// The command was already streamed, so the reader still owns cmd and will
// write its reply into it. If hookDone was not yet awaited (a panic before
// next(), or before the short-circuit await below), await it here so the
// reader's writes happen-before the caller's reads.
if !awaited {
<-hookDone
}
if cmd.rawErr() == nil {
// fdSetErrSafe, not a raw SetErr: this call itself is already inside a
// panic recovery for a custom Cmder whose SetErr panicked (the normal
// assignment below), so a second unguarded call here would panic again
// mid-unwind — unrecovered, since this defer's own recover() has already
// fired — and kill the hostHook goroutine before b.close() wakes the
// waiter.
fdSetErrSafe(cmd, fmt.Errorf("redis: autopipeline: panic in full-duplex process hook: %v", r))
}
internal.Logger.Printf(ctx, "autopipeline: recovered full-duplex hook panic: %v\n%s", r, debug.Stack())
b.close()
}
}()
err := fd.ap.pipeliner.withProcessHook(ctx, cmd, func(context.Context, Cmder) error {
<-hookDone // reply landed (or the command was failed)
awaited = true
return cmd.rawErr() // direct read: cmd.Err() would await batch.done, which
// this goroutine itself closes below → self-deadlock.
})
// A hook that SHORT-CIRCUITS (returns without calling next) never received
// hookDone, but the command is already on the wire and the reader will still
// write into cmd: await that before releasing the caller. The command still
// executed; the hook's error is honored anyway (see the FullDuplex GoDoc).
if !awaited {
<-hookDone
awaited = true
}
// fdSetErrSafe: a panicking custom Cmder here must not escape unrecovered —
// the recover above only guards its OWN callers, not this line, and an
// escaping panic would skip b.close() and leave the waiter blocked forever.
fdSetErrSafe(cmd, err) // honor a hook that rewrote / short-circuited the result
b.close() // now wake the waiter
}
// retryStartAttempt returns the normal-path retry loop's starting attempt for an FD
// command diverted to it. A MOVING/ASK redirect returns 0: the command did not
// execute on the FD socket, so it gets the full MaxRetries+1 budget. A retryable
// reply such as LOADING/READONLY/TRYAGAIN returns 1: the initial attempt was already
// spent on the FD socket, so counting it keeps the total at MaxRetries+1, not +2.
func retryStartAttempt(moved, ask bool) int {
if moved || ask {
return 0
}
return 1
}
// emitMetricsGuarded runs a fire-and-forget user metric callback under panic
// recovery. Every FD metric emit funnels through here: reportReplyMetrics on the
// reader's inline completion, and the failure paths failReqs and failQueue. So a
// panicking user callback is logged and swallowed, not propagated. An escaped panic
// would leave accepted commands unsettled (callers wedged forever) and crash fd.run;
// with process hooks it would deadlock Close's hostWg.Wait while callers block on
// hookDone. On the reader path it would also reach the session-failure recovery
// BEFORE the reply is advanced out of the in-flight deque, which treats an unadvanced
// req as an unacked tail and replays it — an already-consumed mutating command run
// twice. The reply outcome is already decided; reporting is advisory.
func (fd *fdEngine) emitMetricsGuarded(octx context.Context, emit func()) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(octx,
"autopipeline: recovered full-duplex metric-callback panic: %v\n%s", r, debug.Stack())
}
}()
emit()
}
// reportReplyMetrics runs the inline-completed command's user-settable metric
// callbacks (OTel operation-duration; native error callback for a non-retryable
// Redis error, reporting attempts-1 retries like processWithRetry) for parity
// with the process() path the FD reader bypasses. Guarded by emitMetricsGuarded
// (panic containment; see there).
func (fd *fdEngine) reportReplyMetrics(octx context.Context, req fdReq, e error, cn *pool.Conn) {
fd.emitMetricsGuarded(octx, func() {
if cb := otel.GetOperationDurationCallback(); cb != nil {
// The reader has set req.cmd's final result but has NOT completed the
// batch yet — req.complete() runs only after this returns. A custom
// duration callback that reads its own command (cmd.Err()/cmd.String())
// would await batch.done and wedge the reader, since the batch completes
// only after this returns and the reader is not otherwise the batch's
// executor. Register the reader as the batch's executor for the call so
// the accessor guard hands back the just-set view without blocking — the
// same escape a dispatch hook reading its own command uses. hostHook gets
// this SAME batch from submit, so one registration covers the hook and
// hook-free async faces alike. NOT moved after complete(): complete()
// hands off to the hook host, which may rewrite/free the command, racing
// the callback's read. The curGoroutineID() cost is paid only when a
// duration callback is registered.
if req.batch != nil {
unregister := req.batch.enterNodeDispatch()
defer unregister()
}
cb(octx, time.Since(req.writtenAt), req.cmd, req.attempts, e, cn, fd.client.opt.DB)
}
if e != nil {
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType, statusCode, isInternal := classifyCommandError(e)
errorCallback(octx, errorType, cn, statusCode, isInternal, req.attempts-1)
}
}
})
}
// retryOnNormalConn re-runs a full-duplex command that came back with a retryable
// Redis error (LOADING/READONLY/…) or a redirect (MOVED/ASK) on the client's NORMAL
// path. That path routes redirects to the proper node and applies the standard
// retry/backoff, neither of which the fixed single-conn FD socket can do. It runs on
// its own goroutine so it does not stall the FD reader, is tracked by retryWg so
// Close waits for it, and settles the FD request with the outcome. process() is the
// raw exec (no hook chain); with hooks installed the FD hostHook still brackets the
// command and reports via req.complete(). Background ctx: the command was already
// accepted, so it completes even under a Close.
func (fd *fdEngine) retryOnNormalConn(req fdReq, startAttempt int) {
// Bound concurrent off-pipe retries to about 2x the main pool (see newFDEngine's
// retryCap): a sustained retryable stream would otherwise spawn one goroutine per
// reply, all parked in backoff/pool acquisition. Blocking here blocks the READER,
// which stops advancing the deque, which fills the window and blocks the writer and
// then submitters — end-to-end backpressure. No cycle: retries drain on the main
// pool, independent of the reader waiting here.
//
// Take a free slot if one is available. Otherwise, by state:
// - Close (ap.ctx done): FAIL ErrClosed. Never block (a spilled session pins a
// main-pool conn until the reader drains, so parking the reader deadlocks the
// retries that need that pool) and never run slot-less (a Close-time storm would
// spawn a goroutine per in-flight reply, up to FullDuplexWindow, and OOM). The
// engine is closing; retryWg still covers granted slots.
// - spilled session (curConnSpilled): run SLOT-LESS rather than block, for the
// same deadlock reason — the reader must keep draining so the session releases
// its pinned main-pool conn (fCCni). Residual: up to a window of transient retry
// goroutines while spilled, reachable only when the pipeline pool is saturated
// at a small PoolSize; documented, and the lesser evil versus a hang.
// - otherwise: BLOCK for a slot — end-to-end backpressure, safe because a
// pipeline-pool session does not compete with the main-pool retries.
slot := false
select {
case fd.retrySem <- struct{}{}:
slot = true
case <-fd.ap.ctx.Done():
select {
case fd.retrySem <- struct{}{}:
slot = true
default:
// fdSetErrSafe: this runs synchronously on the READER goroutine, before
// the retry's own recover (below) is even in play — a panicking custom
// Cmder here would escape to the reader's session-failure recovery,
// tearing down the whole session and replaying the unacked tail,
// including requests already written (double execution).
fdSetErrSafe(req.cmd, ErrClosed)
req.complete()
return
}
default:
if fd.curConnSpilled.Load() {
// slot stays false: run slot-less, do NOT block the reader.
} else {
// Block for a slot, but stay cancellable. A Close that arrives while
// the reader is parked here must not deadlock: cancelAndDrain waits on
// the reader to advance the deque, and the reader is the goroutine
// blocked on this send. On ctx cancel, FAIL ErrClosed (same as the
// ctx.Done arm above) rather than park forever.
select {
case fd.retrySem <- struct{}{}:
slot = true
case <-fd.ap.ctx.Done():
fdSetErrSafe(req.cmd, ErrClosed) // same reason as the ctx.Done arm above
req.complete()
return
}
}
}
fd.retryWg.Add(1)
go func() {
defer func() {
if slot {
<-fd.retrySem
}
fd.retryWg.Done()
}()
// process runs user code (hooks, arg encoders) and can panic; without
// recovery the batch never completes and the caller (and a hooked command's
// host, parked on hookDone) waits forever.
defer func() {
if r := recover(); r != nil {
// fdSetErrSafe, not a raw SetErr: this recover is itself the backstop for
// a panicking Cmder, so a SECOND panic from the same custom SetErr here —
// e.g. triggered by the retry's own req.cmd.SetErr(err) below — must not
// escape a deferred function mid-unwind, which Go cannot recover from
// (it kills the process, not just this goroutine).
fdSetErrSafe(req.cmd, fmt.Errorf("redis: autopipeline: panic in full-duplex off-pipe retry: %v", r))
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex retry panic: %v\n%s", r, debug.Stack())
req.complete()
}
}()
// Retry on the caller's context with cancellation removed (like the FD
// lease init): a CredentialsProviderContext derives credentials from
// context values, so context.Background() here would reject the retry or
// authenticate it as the wrong identity even though the FD session
// initialized correctly. WithoutCancel keeps the values but drops the
// caller's deadline/cancel, so the accepted command still completes its
// retry under a Close.
rctx := context.Background()
if req.ctx != nil {
rctx = context.WithoutCancel(req.ctx)
}
// reprocess (default client.processStartingAt): fd.client IS the pipeliner
// (fdClient is the *Client behind it), so this is the same raw exec, but it
// starts the retry loop at startAttempt — 1 for a retryable reply that already
// spent an attempt on the FD socket, 0 for a redirect that did not execute.
// Pass req.writtenAt as the operation start so the duration metric spans the
// initial FD write, not just this diverted attempt (the attempt count already
// includes the FD attempt).
// Register this retry goroutine as the batch's executor for the call, mirroring
// reportReplyMetrics: a custom RecordOperationDuration callback inside
// processStartingAt that reads its own command (cmd.Err()/cmd.String()) awaits
// batch.done, which req.complete() closes only AFTER this returns — so without the
// guard the callback wedges this goroutine and Close waits for the backstop. On the
// executor goroutine the accessor hands back the just-set view instead. The reader's
// reply-side guard does not cover this off-pipe retry path.
err := func() error {
if req.batch != nil {
defer req.batch.enterNodeDispatch()()
}
return fd.reprocess(rctx, req.cmd, startAttempt, req.writtenAt)
}()
// fdSetErrSafe: same custom-Cmder-panic hazard as the reader's reply path
// (see fdSetErrSafe's doc comment) — a panic here would otherwise reach the
// recover above, which itself calls SetErr and would then have no outer
// boundary of its own.
fdSetErrSafe(req.cmd, err)
req.complete()
}()
}
// run owns the engine for the AutoPipeliner's lifetime: acquire a pipeline-pool
// connection, run one full-duplex attempt on it, and on connection failure
// replay the unacked tail on a fresh connection (bounded by MaxRetries/backoff)
// while continuing to serve the queue. Exits only on graceful Close.
func (fd *fdEngine) run() {
defer fd.ap.wg.Done()
// Runs before wg.Done (LIFO), so Close — which waits ap.wg — also waits for
// any off-pipe retries still running on the normal client path.
defer fd.retryWg.Wait()
// Same for per-command hook hosts: a ProcessHook doing work after next()
// closes the command's batch on its host goroutine, so Close must not return
// while one runs. Every hostWg.Add is gated behind submitMu+closed, and every
// run() return follows the shutdown drain, so this never races a live Add.
//
// Reentrancy caveat: this wait CANNOT exclude its own caller, so a ProcessHook
// that synchronously calls Close (Client.Close or AutoPipeliner.Close) from its
// host goroutine deadlocks here until the close backstop (autoPipelineCloseBackstop):
// Close waits ap.wg -> run() -> this hostWg.Wait, which waits for the very host
// blocked inside Close. A reentrancy fix was rejected as unsafe: cancelAndDrain is
// NOT once-only (the shared-pool close hook leaves ap.closed false and can run
// again), and an early hostWg.Done races submit's hostWg.Add. The contract is
// documented on the FullDuplex GoDoc instead: such a hook must call Close from a
// separate goroutine.
defer fd.hostWg.Wait()
// Release the last session's in-flight ring when the engine exits (Close /
// ctx-cancel): curInflight is a grow-only deque that can hold up to fd.window
// commands (~MiB at the default), and it would otherwise stay resident for as
// long as the caller holds the Client. Safe against the progress read below —
// this defer only fires once run() has returned, after that read is done.
defer fd.curInflight.Store(nil)
bg := context.Background()
var carry []fdReq // unacked tail to re-issue at the start of the next attempt
// Two SEPARATE budgets, each counting only CONSECUTIVE failures of its own
// kind: a shared counter would let transient lease failures eat the reconnect
// budget, so the first genuine mid-session drop would fail the whole unacked
// tail with zero replay attempts. leaseAttempts resets whenever a session
// actually ran; retryAttempts resets on a clean session end (idle/recycle).
leaseAttempts := 0 // consecutive fdLeaseErr acquisition failures
retryAttempts := 0 // consecutive fdConnErr tail-replay failures
for {
if fd.ap.ctx.Err() != nil {
fd.shutdownFlush(bg, carry)
return
}
// Never lease a connection (or dial) without work in hand: block for the
// first command whenever the carry is empty. That covers the initial entry,
// the fdIdle return, and the fail-fast exits below (exhausted fdLeaseErr /
// failed fdConnErr tail), which would otherwise loop straight back into
// attempt against an empty queue — dialing a down server forever. Work
// already queued makes this non-blocking, so fdRecycle re-leases
// immediately; an empty recycle parks here.
if len(carry) == 0 {
// About to park with no session running: release the previous session's
// in-flight ring so a drained deque does not stay resident through the idle
// gap until the next session stores a fresh one. The fdConnErr progress read
// (fd.curInflight.Load, below) already ran for the prior iteration, and the
// next session re-stores before that read runs again, so this never races it.
fd.curInflight.Store(nil)
select {
case r := <-fd.ch:
carry = []fdReq{r}
case <-fd.ap.ctx.Done():
fd.shutdownFlush(bg, nil)
return
}
}
unacked, result, aerr := fd.attempt(bg, carry)
switch result {
case fdGraceful:
// Close: attempt() drained the written work and released the session conn
// via its defer. The defer Puts the conn, so the OnPut hook can hand a
// marked conn off, or Removes it if the release drain failed. Here unacked
// is the never-WRITTEN handoff suffix (nil on a plain Close), NOT written
// commands to re-execute; complete it on a fresh connection now that
// attempt() freed this one. Doing it here rather than inside session avoids
// failing the suffix against a saturated pool while the session conn is
// still held.
if len(unacked) > 0 {
fd.shutdownFlush(bg, unacked)
}
return
case fdIdle:
// Conn returned cleanly to the pool (its per-conn hooks can run); the
// loop-top wait keeps an idle engine from churning Get/Put.
carry, leaseAttempts, retryAttempts = nil, 0, 0
case fdRecycle:
// Conn returned cleanly (max-hold, or a mid-carry handoff clean recycle).
// unacked carries the never-sent suffix from a handoff recycle (nil for a
// plain max-hold recycle); replay it on the next lease — it was never
// written, so no command is re-executed. A handoff-marked conn was handed
// off by the OnPut hook when attempt() Put it.
fd.recycles.Add(1)
carry, leaseAttempts, retryAttempts = unacked, 0, 0
case fdLeaseErr:
// Could not lease/init a connection for a new session (server down, pool
// saturated). Retry for a transient outage; once retries are exhausted,
// fail-fast the carry tail AND the fd.ch backlog rather than leaving accepted
// commands buffered indefinitely, and stay alive to serve again once the
// server/pool recovers. Replaying the carry wholesale is safe: any SENT
// NoRetry was already failed by the split (a never-sent NoRetry in the carry
// gets its first send), and nothing here was written on a new conn.
// Close racing the lease surfaces here as a lease failure (the acquisition ctx
// is cancelled), so flush the accepted work through the normal pipeline path
// instead of failing it with a canceled error.
if fd.ap.ctx.Err() != nil {
fd.shutdownFlush(bg, carry)
return
}
if shouldRetry(aerr, true) && leaseAttempts < fd.retryBudget() {
leaseAttempts++
fd.sleepBackoff(leaseAttempts)
continue // carry unchanged; re-lease
}
fd.failReqs(carry, aerr)
fd.failQueue(aerr)
carry = nil
leaseAttempts++
fd.sleepBackoff(leaseAttempts)
if leaseAttempts >= fd.retryBudget() {
leaseAttempts = 0
}
default: // fdConnErr — a real connection error occurred
// A session ran, so the lease succeeded: acquisition failures are no
// longer consecutive.
leaseAttempts = 0
// A session that COMPLETED work (advanced the deque — including a
// successful carry replay) makes this drop a new failure, not a
// consecutive one: reset the reconnect budget so long-lived sessions
// under continuous traffic do not inherit stale failure counts.
if fi := fd.curInflight.Load(); fi != nil && fi.advancedTotal() > 0 {
retryAttempts = 0
}
// retryTimeout=true: a read/write timeout is a retryable connection
// failure here (re-issue the unacked tail on a fresh conn), matching
// the cluster pipeline retry paths — otherwise a single WAN timeout
// fails the whole tail. The engine's internal failure markers
// (recovered panics, reader-gone) desync the conn exactly like a
// transport error. The tail is mostly commands they never touched, so
// they are replayable too. shouldRetry alone would reject them and
// permanently fail innocent in-flight commands. The NoRetry guard
// below still protects non-idempotent writes.
replayable := shouldRetry(aerr, true) ||
errors.Is(aerr, errFDReaderGone) || errors.Is(aerr, errFDPanicRecovered) ||
errors.Is(aerr, errFDPushDrainFailed)
// Bound each command by its OWN attempt count, not the session-level
// retryAttempts (which resets on any session progress — see advancedTotal
// above — so a flaky peer that acks some replies then drops could hand the
// tail a fresh budget on every partial success). Partition the tail: a
// command that has spent its budget (attempts > MaxRetries) is failed; the
// rest stay eligible to replay. Gating the whole tail on the OLDEST command's
// count instead would deny a newer command — written behind an exhausted one,
// so carrying fewer attempts — the retries it is still owed. Carried commands
// are the oldest (written first, attempts bumped together), so the exhausted
// set is the leading run and the eligible suffix keeps FIFO order.
// retryAttempts still drives the backoff escalation only.
eligible := unacked
if replayable && len(unacked) > 0 {
// unacked is PRE-BUMP here (a command sent A times carries attempts==A),
// so attempts > retryBudget is exactly the spent-budget set. The Close-path
// flush (flushCarryBudgeted) instead sees POST-BUMP carry, where the same
// command carries attempts==A+1 — do not unify the two thresholds.
var exhausted []fdReq
eligible, exhausted = fdPartitionByBudget(unacked, fd.retryBudget())
if len(exhausted) > 0 {
fd.failReqs(exhausted, aerr) // spent MaxRetries+1 attempts; fail with the real cause
}
// Split the eligible suffix at the first SENT NoRetry command: replay the
// prefix and fail that command plus everything ordered after it (a NoRetry
// command whose bytes may have reached the wire must never be re-sent). A
// never-sent NoRetry stays in the replay prefix — issuing it is its first
// send (see fdReq.sent). With a sent NoRetry at the eligible head (n==0)
// nothing ahead of it is retryable, so fall through and fail whatever
// eligible remains (exhausted was already failed above). If the scan
// itself panicked (a custom Cmder's NoRetry() is user code on this
// recover-less serve loop), we cannot classify the tail: skip the replay
// and fall through to fail it — a command that may be a sent NoRetry must
// never be re-sent when in doubt.
if n, scanPanic := fdFirstNoRetrySafe(eligible); !scanPanic && n > 0 {
if n < len(eligible) {
fd.failReqs(eligible[n:], aerr)
}
retryAttempts++
fd.sleepBackoff(retryAttempts)
carry = eligible[:n]
// Already issued on the failed connection and about to be re-issued;
// bump attempts so a later success/failure reports the real
// retry_attempts (not always 1). The clean-recycle suffix path
// (fdRecycle) leaves attempts at 1 — that tail was never sent, so its
// replay is a first attempt.
for i := range carry {
carry[i].attempts++
}
continue
}
}
// Not retrying (not replayable, none eligible, or NoRetry-headed): fail the
// remaining unfailed commands, then ALWAYS back off before re-leasing so a
// dead server cannot spin this loop. Keep the engine alive to serve new work
// when it recovers.
fd.failReqs(eligible, aerr)
carry = nil
retryAttempts++
fd.sleepBackoff(retryAttempts)
if retryAttempts >= fd.retryBudget() {
retryAttempts = 0 // reset so backoff restarts small once we're serving again
}
}
}
}
// attempt acquires a connection, runs one full-duplex session (re-issuing carry
// first), and releases the connection. Returns the unacked tail + error on
// connection failure, or graceful=true on Close.
func (fd *fdEngine) attempt(bg context.Context, carry []fdReq) (unacked []fdReq, result fdResult, aerr error) {
// Options.Limiter is deliberately NOT consulted here: the lease is not the
// Limiter's unit. Admission is per written chunk, in writeBatch — see the
// comment there for the rationale.
var cn *pool.Conn
// connPool records which pool cn was leased from — the pipeline pool normally, or
// the main pool on a spill (see the acquire below) — so the deferred remove/release
// returns it to the pool that owns it.
connPool := fd.pool
// Bias to spilled==true until the lease is decided below: an unknown state must
// never let retryOnNormalConn block the reader (a leftover true only makes off-pipe
// retries slot-less, which is safe; a stale false is the deadlock this guards).
fd.curConnSpilled.Store(true)
defer func() {
if cn == nil {
return // nothing acquired, or already Removed inline below
}
// ANY connection-error end (result==fdConnErr) leaves the conn desynced —
// an unread reply tail, a partial write, or a reader protocol error — so it
// MUST be removed; Put()ing it would poison the pool. Keying on result (not
// isBadConn) is deliberate: errFDReaderGone or a plain write timeout are not
// classified bad-conn, yet the conn is still unusable. Clean ends (including a
// handoff recycle) go through releaseConnToPool: it drains pending pushes and
// Puts, so the OnPut hook can perform the maintenance handoff on a marked conn.
if result == fdConnErr {
connPool.Remove(bg, cn, aerr)
} else {
// releaseConnToPool drains pending pushes (a custom PushNotificationProcessor
// runs user code) and Puts. This defer runs LAST (LIFO — the attempt-init
// recover below is registered after it and runs FIRST), so a panic here has no
// outer boundary: it would escape the sole fd.run goroutine, crash the process,
// and leak the leased conn. Contain it — the conn's drain/Put state is unknown
// after a panic, so Put would poison the pool: Remove it instead.
func() {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(bg, "autopipeline: recovered full-duplex release panic: %v\n%s", r, debug.Stack())
connPool.Remove(bg, cn, fmt.Errorf("%w: release: %v", errFDPanicRecovered, r))
}
}()
fd.client.releaseConnToPool(bg, connPool, cn, nil)
}()
}
}()
// Panic boundary for the ACQUISITION/INITIALIZATION phase. initPooledConn runs
// user-controlled init (Options.OnConnect, credentials providers); a panic there
// would otherwise escape the sole fd.run goroutine and crash the process, and the
// release defer above — seeing the zero-value result (fdGraceful) — would Put the
// half-initialized conn back into the pool. Registered AFTER that defer so it runs
// FIRST (LIFO): retire the leased conn (Remove), set cn=nil so the release defer is a
// no-op, and return the carry as fdLeaseErr — the SAME disposition an initPooledConn
// error gets below, so run() applies the lease-retry budget and fails accepted work
// fast on a deterministic panic instead of poisoning the pool. A session-body panic
// is contained by session()'s own recover, which returns fdConnErr normally, so this
// boundary fires only for the lease/init phase (and as a last-resort backstop).
defer func() {
if r := recover(); r != nil {
aerr = fmt.Errorf("%w: full-duplex attempt: %v", errFDPanicRecovered, r)
internal.Logger.Printf(bg, "autopipeline: recovered full-duplex attempt panic: %v\n%s", r, debug.Stack())
if cn != nil {
connPool.Remove(bg, cn, aerr)
cn = nil
}
unacked = carry
result = fdLeaseErr
}
}()
// initCtx: initialize with the SESSION-INITIATING caller's context (values only,
// via WithoutCancel), not context.Background(). A CredentialsProviderContext
// derives credentials from context values, and Background made those invisible so
// such providers rejected FD sessions or authed with fallback identity. Full-duplex
// holds ONE connection for MANY callers, so credentials are session-scoped (the
// first caller's), like any shared/pooled connection (documented on FullDuplex).
// WithoutCancel keeps the values but drops the caller's deadline/cancel, so one
// caller's ctx expiry cannot abort an init the whole session depends on. init goes
// through initPooledConn (shared with the main/pipeline paths): it records the
// create-time metric and Removes the conn on any failure, so the defer, seeing
// cn=nil, does not double-release.
initCtx := bg
if len(carry) > 0 && carry[0].ctx != nil {
initCtx = context.WithoutCancel(carry[0].ctx)
}
// Acquire+init from the pipeline pool; SPILL to the main pool when the pipeline
// pool cannot serve the lease, mirroring withPipelineConn — an FD lease must not
// fail already-accepted commands while the main pool has idle capacity. Unlike a
// per-round-trip pipeline borrow, a spilled FD session holds the main-pool conn for
// its whole lifetime (until idle/maxHold); that is the accepted cost of not
// stranding the backlog. TryGet (non-blocking) so a saturated pipeline pool spills
// at once instead of stalling up to PoolTimeout.
//
// Spill on ANY acquisition failure EXCEPT a hard stop — saturation (ErrPoolTryFull
// / ErrPoolExhausted) AND a transient DIAL error while the pipeline pool is growing
// a connection (TryGet dials when the pool has no idle conn), plus a pipeline-conn
// init failure below. Only a cancelled ap.ctx (Close) or a closed pool surface as
// fdLeaseErr, because the main pool would fail the same way; every other error may
// clear on the main pool, which can hand back an idle conn or dial cleanly, so it
// must not fail the accepted backlog (codex on #4002 — a deny-list, not an
// allow-list: a dial error is not saturation but must still spill). Acquire under
// ap.ctx (not bg) so a Close cancelling ap.ctx returns at once instead of waiting
// out PoolTimeout; init and session I/O stay on bg so accepted commands still
// complete during Close.
if ref := fd.client.loadPipelinePool(); ref != nil {
spill := false
cn, aerr = ref.pool.TryGet(fd.ap.ctx)
if aerr != nil {
cn = nil
if errors.Is(aerr, pool.ErrClosed) ||
errors.Is(aerr, context.Canceled) ||
errors.Is(aerr, context.DeadlineExceeded) {
// Hard stop: the main pool cannot do better (closed pool, or the caller/
// Close cancelled the acquire ctx). Surface it rather than spill.
return carry, fdLeaseErr, aerr
}
spill = true
} else if e := fd.client.initPooledConn(initCtx, ref.pool, cn); e != nil {
cn = nil // initPooledConn already Removed it from the pipeline pool
spill = true
}
if spill {
cn, aerr = fd.client.connPool.Get(fd.ap.ctx)
if aerr != nil {
cn = nil
return carry, fdLeaseErr, aerr
}
connPool = fd.client.connPool
if e := fd.client.initPooledConn(initCtx, fd.client.connPool, cn); e != nil {
cn = nil // initPooledConn already Removed it from the main pool
return carry, fdLeaseErr, e
}
}
} else {
// No dedicated pipeline pool (PipelinePoolSize < 0, or an internal wrapper
// client): fd.pool IS the main pool, so acquire directly with no spill.
cn, aerr = fd.pool.Get(fd.ap.ctx)
if aerr != nil {
cn = nil
return carry, fdLeaseErr, aerr
}
if e := fd.client.initPooledConn(initCtx, fd.pool, cn); e != nil {
cn = nil // initPooledConn already Removed it
return carry, fdLeaseErr, e
}
}
// The lease is decided: spilled iff the conn came from the main pool (a spill, or
// no dedicated pipeline pool). Stored before session() spawns the reader, so the
// reader's retryOnNormalConn sees the right value (happens-before).
fd.curConnSpilled.Store(connPool == fd.client.connPool)
unacked, result, aerr = fd.session(bg, cn, carry)
return unacked, result, aerr
}
// session runs the writer (this goroutine) + reader (spawned) on one connection
// until Close (graceful) or a connection error (returns the unacked tail).
func (fd *fdEngine) session(bg context.Context, cn *pool.Conn, carry []fdReq) (unacked []fdReq, result fdResult, aerr error) {
inflight := newFDInflightCap(min(fd.maxBatch, fd.window)) // capped by the window (peak) and the batch; grow() backstops
fd.curInflight.Store(inflight) // test observability (peak in-flight)
fd.curConn.Store(cn) // test observability (handoff)
defer fd.curConn.Store(nil)
readerDone := make(chan struct{})
// Honor opt.ReadTimeout as-is for each per-reply read: options.go maps a
// disabled timeout (-1) to 0 and WithReader treats <= 0 as "no deadline", so
// disabled stays disabled instead of being clamped to some fixed value (a
// default client keeps its 5s, which bounds each read). A genuinely stuck read
// is still unblocked by the conn Close on the fdConnErr path.
readTimeout := fd.client.opt.ReadTimeout
var errOnce sync.Once
var sharedErr error
failOnce := func(e error) { errOnce.Do(func() { sharedErr = e }) }
// Reader: read replies in FIFO order, completing each command as its reply
// lands. Works a bounded front-snapshot per lock (amortizes the mutex over
// many reads), then advances. On a connection/protocol error it stops and
// leaves the unread tail in the deque (it becomes the unacked recovery set).
go func() {
defer close(readerDone)
// done counts commands completed in the CURRENT frontBatch snapshot that
// have not yet been advanced out of the in-flight deque; it is 0 outside the
// inner read loop (reset at the top of each iteration, advanced at the end).
done := 0
// A reply decoder can panic (e.g. a RawWriteToCmd whose user io.Writer panics
// while readReply streams the raw reply). Recover and mark the session failed
// (failOnce) so run() takes the connection-error path: the reader exits, the
// unacked tail is recovered and the conn is removed. advance(done) FIRST so
// commands already completed in the panicking snapshot leave the deque.
// Otherwise recovery re-owns and re-completes them, overwriting good results
// and double-closing hookDone (a second panic) when hooks are installed.
defer func() {
if r := recover(); r != nil {
inflight.advance(done)
failOnce(fmt.Errorf("%w: reader: %v", errFDPanicRecovered, r))
internal.Logger.Printf(bg, "autopipeline: recovered full-duplex reader panic: %v\n%s", r, debug.Stack())
}
}()
var buf []fdReq
for {
done = 0
var ok bool
buf, ok = inflight.frontBatch(buf)
if !ok {
return
}
var rerr error
// Read each reply as it lands (one WithReader per reply). Reading the
// whole snapshot inside a single WithReader was measurably slower on
// loopback: it blocks on commands the writer has pushed but not yet
// flushed, collapsing writer/reader overlap.
for i := range buf {
req := buf[i]
e := cn.WithReader(bg, readTimeout, func(rd *proto.Reader) error {
// Drain RESP3 push frames buffered ahead of this reply so a push is
// never misread as the command's reply (FIFO misalign). PROPAGATE a
// drain error, do NOT log-and-continue: a custom PushNotificationProcessor
// can return after consuming only part of a frame, leaving the reader
// desynced, so reading this reply would shift every later reply. Fail the
// session instead (the shared pre-command drainer treats a custom-processor
// error as connection-fatal for the same reason); the unacked tail is
// replayable (errFDPushDrainFailed is in the replay predicate) and re-runs
// on a fresh connection rather than reading shifted bytes.
if perr := fd.client.processPendingPushNotificationWithReader(bg, cn, rd); perr != nil {
internal.Logger.Printf(bg, "autopipeline: full-duplex push drain: %v", perr)
return fmt.Errorf("%w: %w", errFDPushDrainFailed, perr)
}
return req.cmd.readReply(rd)
})
if e != nil && fdReplyIsFatal(req.cmd, e) {
// Connection/protocol error, OR a push-drain desync (fatal even when it
// wraps a Redis-typed cause — see fdReplyIsFatal): stop; the unread tail
// stays in the deque and becomes the unacked recovery set for replay.
rerr = e
break
}
// The reply landed (nil, or a reply-LEVEL Redis error / redirect — a
// server that answers is healthy, NOT a transport failure). If this req
// closes an admitted chunk, settle its Limiter obligation with success:
// exactly one ReportResult(nil) per Allow, on the reply side. Fires for
// both the inline completion below and the retryable-divert branch (the
// reply WAS read; the divert re-runs the command elsewhere under its own
// getConn Allow/Report pairing).
if req.limReport != nil {
req.limReport.settle(nil)
}
// A retryable Redis error or a redirect (MOVED/ASK) is NOT the caller's
// final answer: the FD conn is one fixed socket/node, so re-run the
// command on the client's NORMAL path, which routes redirects and applies
// the standard retry/backoff. Done off the reader goroutine so it does not
// stall other in-flight replies, and counted in `done` so the reader
// advances past it now. Per-caller ordering is NOT promised across this
// divert (same exception as the blocking-command divert).
if e != nil {
moved, ask, _ := isMovedError(e)
// Cluster full-duplex redirect: a MOVED/ASK is followable for EVERY
// command, including NoRetry ones (e.g. GetToBuffer, RawWriteTo). NoRetry
// guards against replaying a command whose partial response was already
// consumed, but a MOVED/ASK reply carries no payload — the command did
// NOT execute on this node — so there is nothing to replay, and the normal
// ClusterClient.process follows redirects for all commands before
// consulting NoRetry. So divert a redirect independent of the NoRetry gate
// below. reprocess re-routes MOVED to the target node (LazyReload) and
// follows ASK through cc.process's own loop (ASKING on the next hop),
// bounded by MaxRedirects; startAttempt is unused by the cluster reprocess
// (it re-runs the full loop from the base), so pass the redirect value (0).
// isMovedError/e here are reply-level only: a transport or protocol failure
// is !isRedisError and already broke the read loop via fdReplyIsFatal above.
//
// Standalone FD (redirectAware == false) cannot follow a MOVED/ASK (it
// neither re-routes to the target node nor sends ASKING), so it falls
// through to the inline settle and surfaces the redirect, as before.
if fd.redirectAware && (moved || ask) {
fd.retryOnNormalConn(req, retryStartAttempt(moved, ask))
done++
continue
}
// A RETRYABLE execution error (not a redirect) may have produced a
// partially consumed response, so it stays gated on NoRetry.
if !fdNoRetrySafe(req.cmd) {
// Cluster full-duplex: divert a retryable server reply
// (LOADING/READONLY/TRYAGAIN/CLUSTERDOWN/MASTERDOWN/NOREPLICAS/
// max-clients) to the redirect-aware ClusterClient. It consults NO
// FD-side budget and — the key difference from the standalone branch
// below — does NOT gate on the node client's MaxRetries: cluster node
// clients default MaxRetries to -1 (osscluster.go), which is <= 0, so a
// MaxRetries>0 gate would wrongly settle the reply inline and fail the
// caller instead of recovering it the way half-duplex does. cc.process
// owns the whole cluster retry budget; startAttempt is unused by the
// cluster reprocess. shouldRetry(e) here matches only reply-level Redis
// errors (see the fdReplyIsFatal note above).
if fd.redirectAware && shouldRetry(e, false) {
fd.retryOnNormalConn(req, retryStartAttempt(false, false))
done++
continue
}
// Standalone FD: divert a RETRYABLE reply only while retries are
// enabled AND the budget is not already spent. req.attempts counts FD
// attempts spent (1 at submit, +1 on each fdConnErr carry replay). Once
// it reaches MaxRetries+1 another execution would exceed the budget, so
// fall through to the inline settle, which surfaces the reply as the
// final error and reports the true attempt count. Without this guard the
// startAttempt clamp in processWithRetry would turn an exhausted budget
// into one more send.
if !moved && !ask &&
shouldRetry(e, false) &&
fd.client.opt.MaxRetries > 0 &&
req.attempts <= fd.client.opt.MaxRetries {
// The retryable reply executed on the FD socket, so the divert starts
// one attempt in; add req.attempts-1 for FD attempts already spent on
// carry replays so a carried-then-diverted command does not run the
// full loop from the base. The guard above keeps this within
// MaxRetries+1.
fd.retryOnNormalConn(req, retryStartAttempt(false, false)+req.attempts-1)
done++
continue
}
}
}
fdSetErrSafe(req.cmd, e) // nil, a redirect (MOVED/ASK), or a non-retryable Redis error; panic-safe (see fdSetErrSafe)
// Per-command OTel duration (write→reply): the FD reader bypasses
// process, which is what normally emits it. Inline-completed commands
// only — a diverted command emits its own through process.
// req.ctx carries the caller's span for telemetry correlation
// (exemplars, context-scoped attrs); fall back to bg only when nil.
// Shared by the duration and error callbacks so both attribute to the
// request context, matching process().
octx := req.ctx
if octx == nil {
octx = bg
}
// Emit the per-command metric callbacks under a recover boundary (see
// reportReplyMetrics): they are user-settable, and an unrecovered panic
// here would reach the reader's session-failure recovery BEFORE this req
// is advanced, so recovery would re-own the already-consumed reply and
// replay it — a mutating command twice.
fd.reportReplyMetrics(octx, req, e, cn)
req.complete() // wake the caller, or hand off to the hook host
done++
}
inflight.advance(done)
if rerr != nil {
failOnce(rerr)
return
}
}
}()
// Panic boundary for the WRITER path. The reader goroutine above has its own recover;
// the writer (this goroutine) runs writeCarryChunked, the serve loop and the
// Close-backlog flush with no top-level recover, so an unguarded user-code panic (a
// Cmder Args()/encoder, a Limiter, a metrics callback) would kill the sole fd.run
// goroutine and leave attempt()'s defer to Put a live conn. Registered AFTER the
// reader is spawned, so readerDone is guaranteed to close. On a panic, run the
// fdConnErr teardown (stop the reader, wait it out, recover the unacked tail) and
// return fdConnErr NORMALLY, so attempt()'s release defer Removes the desynced conn
// and run() replays the eligible tail (errFDPanicRecovered is replayable, bounded by
// each command's own attempt budget). The known sizing/limiter/metrics panics are
// already contained at their sites (cmdApproxBytesSafe, fdBatchEndSafe, fdAllow,
// reportReplyMetrics); this backstop guarantees the goroutine survives any other.
defer func() {
if r := recover(); r != nil {
e := fmt.Errorf("%w: full-duplex session: %v", errFDPanicRecovered, r)
internal.Logger.Printf(bg, "autopipeline: recovered full-duplex session writer panic: %v\n%s", r, debug.Stack())
failOnce(e)
inflight.hardClose()
_ = cn.Close()
<-readerDone
unacked = fd.settleTail(inflight.takeRemaining(), e)
result = fdConnErr
aerr = e
}
}()
// Idle / max-hold timers arm the clean-return paths. A disabled timer uses a
// nil channel (never selected).
var idleC, maxC <-chan time.Time
var idleT, maxT *time.Timer
if fd.idle > 0 {
idleT = time.NewTimer(fd.idle)
idleC = idleT.C
defer idleT.Stop()
}
if fd.maxHold > 0 {
maxT = time.NewTimer(fd.maxHold)
maxC = maxT.C
defer maxT.Stop()
}
resetIdle := func() {
if idleT == nil {
return
}
if !idleT.Stop() {
select {
case <-idleT.C:
default:
}
}
idleT.Reset(fd.idle)
}
result = fdConnErr // default until a break sets otherwise
// Writer: re-issue the recovered tail first, then serve the queue. The tail goes
// in the SAME MaxBatchSize/MaxBatchBytes-capped chunks as freshly drained work —
// it can hold up to fd.window commands, so one flush would ignore MaxBatchBytes
// and hit a write-timeout/burst on the new connection.
carrySuffix, writeErr := fd.writeCarryChunked(bg, cn, inflight, carry, readerDone, maxC)
if writeErr == nil {
// Cap the drain scratch at the window, not MaxBatchSize: the writer can never
// have more than fd.window commands in flight, so a large MaxBatchSize with a
// small window would over-allocate (up to OOM) for no gain. Matches the
// in-flight ring's min(maxBatch, window) cap.
scratch := make([]fdReq, 0, min(fd.maxBatch, fd.window))
byteLimit := int64(fd.ap.config.MaxBatchBytes) // 0 = disabled
serve:
for {
// Backpressure: bound the in-flight (written-but-unacked) deque. Wait
// for the reader to drain below the window BEFORE taking new work, so
// a slow/stalled peer cannot grow in-flight without bound. Done here
// (not mid-batch) so no drained work is ever held during the wait.
for inflight.len() >= fd.window {
// Poll handoff here too, not just after the gate: under sustained
// backpressure the writer can sit in this loop, and room fires on
// every reader advance, so a MOVING mark is observed within one
// drained reply instead of waiting for max-hold.
if cn.ShouldHandoff() {
result = fdRecycle
break serve
}
select {
case <-inflight.room:
case <-readerDone:
break serve // reader hit a connection error
case <-fd.ap.ctx.Done():
result = fdGraceful
break serve
case <-maxC:
result = fdRecycle
break serve
}
}
// Go's select picks randomly among ready cases, so with work queued AND
// the reader gone (decode panic, protocol error) the main select below
// could write a batch to a connection known to have no reader — needlessly
// enlarging the ambiguous at-least-once set. Check readerDone first.
select {
case <-readerDone:
break serve // result stays fdConnErr; unacked tail is recovered
default:
}
// A maintenance MOVING/FAILING_OVER push (drained by the reader) marks
// the held connection for handoff. The pool queues the handoff only when
// the conn is Put back, so end this session promptly with a CLEAN recycle
// (drain in-flight to a RESP boundary, then Put) instead of continuing to
// write to a node known to be moving until idle/max-hold. Otherwise the
// handoff can miss its deadline. ShouldHandoff() is an atomic load, so it
// is safe to poll here while the reader sets it; room fires on every
// reader advance, so a writer parked on the window gate above re-checks
// this within one drained reply.
if cn.ShouldHandoff() {
result = fdRecycle
break serve
}
select {
case req := <-fd.ch:
batch := append(scratch[:0], req)
batchBytes, sizeErr := cmdApproxBytesSafe(req.cmd)
if sizeErr != nil {
// req.cmd.Args() panicked (custom Cmder) while sizing the batch, on
// this recover-less serve loop. Fail just that command and take the
// next: nothing was written and nothing is in flight, so dropping it
// here avoids letting it reach writeBatch, whose write-time recover
// would tear the whole session down and replay its batch-mates
// at-least-once. It is the only command in the batch, so skip the flush.
// Do not resetIdle: a dropped command is not session activity, and the
// idle timer firing normally is harmless.
fd.failReqs(batch, sizeErr)
continue
}
// Cap this batch by the REMAINING window room, not just MaxBatchSize:
// the gate above only ensures in-flight < window before draining, so a
// window smaller than MaxBatchSize would let one drain blow through it
// (window=1, batch=200 → 200 in flight). The first command always goes
// (room is >= 1 after the gate).
limit := fd.maxBatch
if room := fd.window - inflight.len(); room < limit {
limit = room
}
drain:
for len(batch) < limit {
// Soft MaxBatchBytes cap (like the half-duplex path): stop
// accumulating once the payload reaches the limit, so one flush
// cannot buffer an unbounded write. The first command is always
// included, so a lone oversized command still goes.
if byteLimit > 0 && batchBytes >= byteLimit {
break drain
}
select {
case r := <-fd.ch:
batch = append(batch, r)
rb, sizeErr := cmdApproxBytesSafe(r.cmd)
if sizeErr != nil {
// r.cmd.Args() panicked while sizing. Fail just r, DROP it from
// the batch, and flush the good prefix accumulated so far. Failing
// it while leaving it in batch would double-complete it: writeBatch
// would push it into inflight and the reader would settle it again.
fd.failReqs(batch[len(batch)-1:], sizeErr)
batch = batch[:len(batch)-1]
break drain
}
batchBytes += rb
default:
break drain
}
}
if e := fd.writeBatch(bg, cn, inflight, batch); e != nil {
writeErr = e
break serve
}
resetIdle()
case <-readerDone:
break serve // reader hit a connection error (result stays fdConnErr)
case <-fd.ap.ctx.Done():
result = fdGraceful
break serve
case <-idleC:
// Only return the conn when genuinely idle: nothing queued AND the
// in-flight drained. Otherwise the timer fired mid-stream (e.g. a long
// flush) — re-arm and keep the hot session.
if inflight.empty() && len(fd.ch) == 0 {
result = fdIdle
break serve
}
resetIdle()
case <-maxC:
// Max-hold reached. With the pipe drained (nothing in-flight, nothing
// queued) return fdIdle so run() blocks for the next command: otherwise a
// quiet engine with FullDuplexMaxHold < FullDuplexIdleTimeout would
// Get/Put-churn (and re-run the session hooks) every interval.
// With work pending, recycle to keep serving.
if inflight.empty() && len(fd.ch) == 0 {
result = fdIdle
} else {
result = fdRecycle
}
break serve
}
}
}
if writeErr != nil {
if errors.Is(writeErr, errFDConnMoving) || errors.Is(writeErr, errFDMaxHold) {
// Handoff (errFDConnMoving) or max-hold (errFDMaxHold) mid-carry-replay on a
// LIVE conn. Route to the clean fdRecycle arm below: the reader drains the
// already-written prefix so those callers complete normally (not re-executed),
// attempt then PUTS the conn — the maintnotifications OnPut hook performs the
// seamless handoff for a moving conn, or the conn simply returns to the pool for
// max-hold so the hold ends — and run() replays only the never-sent carrySuffix.
// Do NOT failOnce — nothing failed.
result = fdRecycle
} else {
failOnce(writeErr)
result = fdConnErr
}
}
switch result {
case fdGraceful:
// Clean Close: flush the accepted-but-unwritten fd.ch backlog on this
// connection first, so Close honors "accepted ⇒ completes" instead of failing
// it ErrClosed, then let the reader drain every in-flight reply to a RESP
// boundary.
unwritten, e := fd.flushBacklogForClose(bg, cn, inflight, readerDone)
if e != nil && !errors.Is(e, errFDConnMoving) {
// A real write error (dead conn) failed the backlog partway: some flushed
// commands have no reply coming and the unwritten suffix is already in
// inflight (writeCarryChunked pushed it), so closeGraceful would park the
// reader on replies that never arrive. Close the conn to wake the reader,
// then RETURN the unacked tail as the recovery set instead of failing it
// here. As fdConnErr it flows through run()'s standard tail recovery — the
// per-command budget partition and the NoRetry split. Because ap.ctx
// is cancelled (this is Close), the eligible prefix is then executed by
// shutdownFlush on a fresh connection. That honors "accepted ⇒ completes"
// for the never-sent fd.ch backlog (attempts==1, never touched the dead
// socket) instead of failing it errFDReaderGone, while the NoRetry split
// still keeps a written-but-unacked NoRetry command from a second execution
// (it is failed by the split, never reaching shutdownFlush). takeRemaining
// holds only UNACKED commands — the reader advanced completed ones out — so
// nothing already settled is re-run.
failOnce(e)
inflight.hardClose()
_ = cn.Close()
<-readerDone
return fd.settleTail(inflight.takeRemaining(), e), fdConnErr, e
}
// e == nil (fully flushed) OR errFDConnMoving (handoff mid-flush on a LIVE
// conn). Either way the connection is healthy: drain the already-written prefix
// to a boundary so those callers complete with real replies (never failed with a
// synthetic moving error), then let attempt() Put the conn — a handoff-marked
// conn is handed off seamlessly by OnPut, exactly like the serve-loop and
// carry-replay recycles.
inflight.closeGraceful()
<-readerDone
if sharedErr != nil {
// Reader failed during the final drain: RECOVER the stranded tail (plus the
// never-sent handoff suffix, in order) instead of failing it wholesale, and
// report the error so attempt() removes the desynced conn. As fdConnErr the
// set flows through run()'s standard tail recovery — per-command budget
// partition and the sent-NoRetry split. Because ap.ctx is cancelled
// (this is Close), the eligible prefix is then executed by shutdownFlush on a
// fresh connection, honoring "accepted ⇒ completes" exactly like the
// backlog-flush failure branch above. Failing here handed every acked-write
// (replayable reads included) the read error just because Close raced a slow
// reply.
return fd.settleTail(fdRecoverTail(inflight.takeRemaining(), unwritten), sharedErr), fdConnErr, sharedErr
}
// Handoff mid-flush: the prefix drained cleanly and the conn will be Put
// (OnPut handoff). RETURN the never-sent suffix so run() completes it on
// ANOTHER connection AFTER attempt() has Put this conn — accepted ⇒
// completes. Flushing it HERE would run while attempt() still holds this
// conn, so with both pools saturated the suffix would deterministically
// fail. Empty on a plain Close with no handoff. Mirrors the recycle
// path, which likewise returns its suffix for run() to replay.
return unwritten, fdGraceful, nil
case fdIdle, fdRecycle:
// Clean return: no more pushes, the reader drains the remaining replies (the
// already-written carry PREFIX included, so those callers complete normally and
// are NOT replayed), then the conn is at a RESP boundary and safe to Put back —
// a handoff-marked conn is handed off seamlessly by the OnPut hook, and run()
// replays only the never-sent suffix (carrySuffix).
inflight.closeGraceful()
<-readerDone
if sharedErr != nil {
// Reader failed while draining for the clean return: recover the unacked tail
// for replay and report the error so the conn is removed instead of reused
// poisoned. carrySuffix (never-sent, e.g. the unwritten tail of a handoff
// recycle) rides behind the drained tail, in order, and is REFUNDED by
// fdRecoverTail: writeCarryChunked deliberately did not refund it (the clean
// fdRecycle return below does not re-bump), but this path re-enters run()'s
// fdConnErr recovery, which does — without the refund the suffix would be
// charged for a send that never happened and could be declared
// budget-exhausted one replay early (e.g. MaxRetries=1: one real send on a
// dropped session, then MOVING mid-replay plus a reader failure here).
return fd.settleTail(fdRecoverTail(inflight.takeRemaining(), carrySuffix), sharedErr), fdConnErr, sharedErr
}
// carrySuffix is the never-sent suffix to replay on the next lease (nil for a
// plain idle/recycle; the unwritten tail on a handoff recycle).
return carrySuffix, result, nil
default: // fdConnErr
// Stop the reader, wait for it to exit, THEN take the unacked tail: the reader
// advances every command it completes, so taking only after <-readerDone is
// what keeps an entry from being owned by both sides.
//
// Close the connection before waiting: on a WRITE error the reader is
// typically parked in WithReader awaiting a reply that will never arrive, and
// hardClose only wakes a reader parked in frontBatch. Closing makes that read
// return at once so recovery does not stall for the read deadline; attempt()
// removes this conn right after, so the close is safe and idempotent.
inflight.hardClose()
_ = cn.Close()
<-readerDone
unacked = inflight.takeRemaining()
if sharedErr == nil {
sharedErr = errFDReaderGone
}
return fd.settleTail(unacked, sharedErr), fdConnErr, sharedErr
}
}
// fdAllow calls the user Limiter's Allow under a recovery boundary. Allow is user
// code that runs on the engine's background writer goroutine, BEFORE writeBatch
// arms its serialize-panic defer, so a panicking Allow would otherwise escape
// fd.run and crash the process with the whole accepted chunk unsettled (half-duplex
// wraps user code via recoverDispatchPanic). A recovered panic is converted to a
// wrapped error and handled EXACTLY like a deny: no permit was granted, so the
// caller reports nothing (strict Allow/ReportResult pairing) and fails only this
// chunk, leaving the connection healthy and untouched.
func fdAllow(ctx context.Context, lim Limiter) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: limiter Allow: %v", errFDPanicRecovered, r)
internal.Logger.Printf(ctx, "autopipeline: recovered full-duplex limiter Allow panic: %v\n%s", r, debug.Stack())
}
}()
return lim.Allow()
}
// writeBatch pushes each req onto the in-flight FIFO (so it is tracked as
// unacked even if the flush then fails) and writes the whole batch in one
// buffered flush. A write error leaves the reqs in the deque for recovery.
func (fd *fdEngine) writeBatch(bg context.Context, cn *pool.Conn, inflight *fdInflight, reqs []fdReq) (err error) {
if len(reqs) == 0 {
return nil
}
// Per-chunk Limiter admission. The Limiter's unit everywhere else in the
// client is one connection-acquiring wire operation — a single-command
// attempt, a pipeline exec, a half-duplex autopipeline flush — and the FD
// equivalent of that unit is one written chunk, so Allow/ReportResult pair
// here, per chunk. The session LEASE deliberately does not pay: a lease-long
// permit pinned a breaker's half-open probe budget for the whole session
// lifetime (probe starvation) and gave near-zero failure-signal density (one
// report per session, however long it ran). The obligation is settled on the
// REPLY side (see fdLimiterReport): reply-LEVEL errors report success (nil) — a
// server that answers is healthy — while a transport failure that abandons the
// chunk's unread replies reports that error (settleTail); further failures also
// surface through the next chunk's write attempt on the replacement conn and
// through diverted retries, which keep their own Allow/Report pairing via
// getConn (parity with single-command retries paying per attempt).
//
// A deny fails ONLY this chunk, with the Limiter's error verbatim (failReqs
// sets it on each command, emits the error metric, and completes the
// callers): the connection is healthy and untouched — nothing stamped sent,
// nothing pushed in-flight — so the session continues and the NEXT chunk
// pays Allow again (fast-fail while a breaker is open, automatic resume when
// it closes). This early return sits BEFORE the recovery defer below is
// armed, so a denied chunk can never be pushed into the in-flight deque. A
// panicking Allow (user code on the writer goroutine) is caught by fdAllow and
// folded into this same deny path — no permit, so no ReportResult.
var report *fdLimiterReport
if lim := fd.client.opt.Limiter; lim != nil {
if aerr := fdAllow(bg, lim); aerr != nil {
fd.failReqs(reqs, aerr)
return nil
}
// Admitted: one obligation for this chunk's Allow, settled exactly once on
// the REPLY side, not at write time (finding ed53z: a peer that accepts the
// write then drops before replying must be a FAILURE the breaker sees). A
// clean write hands the obligation to the reader via the chunk's last req
// (settle nil once every reply lands); a transport failure that abandons the
// unread replies settles the error (settleTail). A WRITE failure/panic HERE
// means the replies will never come, so report the write error now — this
// defer is registered BEFORE the recovery defer below so it runs AFTER it
// (LIFO) and sees the final err (an encoder panic converted to
// errFDPanicRecovered is a failed write, not a skipped report). On a clean
// write it is a no-op; the obligation rides the deque. A denied Allow reports
// nothing, per the Limiter contract.
report = &fdLimiterReport{lim: lim}
defer func() {
if err != nil {
report.settle(err)
}
}()
}
// Publish the batch to the in-flight deque only AFTER a clean serialize+flush
// (see below). Then the reader arms its per-reply ReadTimeout once the write has
// reached the socket, not while a slow user encoder (a BinaryMarshaler) is still
// running. A slow encoder could time out a healthy connection and trigger a
// spurious replay that duplicates a mutating command. On a partial write or an
// encoder panic the conn is desynced, so the batch must still land in the deque
// for the normal conn-error recovery to settle every caller; this defer does that
// if the happy-path push below did not run. A command encoder panic (writeCmd on a
// bad BinaryMarshaler) runs on the writer goroutine, where it would otherwise
// crash the process — convert it to a connection error.
pushed := false
// written is the count of commands the serialize loop REACHED this call (index+1
// of the last one it touched): the attempt-local twin of the lifetime `sent`
// stamp, reset every call. The recovery defer refunds by it, not by `sent`.
written := 0
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: encoding batch: %v", errFDPanicRecovered, r)
internal.Logger.Printf(bg, "autopipeline: recovered full-duplex write panic: %v\n%s", r, debug.Stack())
}
if !pushed {
// Recovery push (partial write or encoder panic): refund the optimistic
// submit/replay attempt for every command the serializer never REACHED
// THIS call (index >= written). run()'s fdConnErr recovery partitions the
// tail by attempt count (fdPartitionByBudget) BEFORE it consults the
// NoRetry/sent gate, and that partition keys on attempts, not sent. So a
// command left at its pre-charged attempt count is declared budget-exhausted
// and FAILED without ever executing (acute at MaxRetries<=1).
//
// Refund by `written`, NOT by the lifetime `sent` flag: `sent` is sticky
// across replays, so on a SECOND-session replay whose earlier command's
// encoder panics the later commands still carry sent==true from the first
// session. A `!sent` refund would skip that suffix, leave it over-charged,
// and lose a retry it never got (MaxRetries==1: exhausted after only its
// original send). `written` is attempt-local, so it refunds exactly the
// suffix this call did not reach, regardless of a prior session's send. The
// prefix reached this call (< written, including a command whose own writeCmd
// panicked) keeps its charge. It was attempted at-least-once. Mirrors the
// never-written-suffix refunds in writeCarryChunked / fdRecoverTail.
fdRefundUnsentAttempt(reqs[written:])
inflight.pushBatch(reqs)
}
}()
// Stamp the wire-write time and mark each command SENT per-command, immediately
// before its writeCmd runs, NOT in a bulk loop before the flush. Only commands
// the serializer actually REACHES are marked sent: if an earlier command's encoder
// panics (a bad BinaryMarshaler) or a partial write aborts the loop, the
// never-serialized suffix keeps sent=false, so the NoRetry gate replays it
// (issuing it is its FIRST send) instead of failing a command the server never
// saw. A command whose own writeCmd fails/panics stays sent=true: its bytes may
// have reached the buffer/wire, so the conservative choice avoids re-sending a
// NoRetry twice; a WithWriter flush error after N commands serialized leaves those
// N sent. Stamped before pushBatch so the deque copies the reader reads carry both.
now := time.Now()
err = cn.WithWriter(bg, fd.client.opt.WriteTimeout, func(wr *proto.Writer) error {
for i := range reqs {
if reqs[i].writtenAt.IsZero() {
reqs[i].writtenAt = now // first write anchors the duration; replays keep it
}
reqs[i].sent = true
written = i + 1 // reached this command this call (attempt-local; see refund defer)
if e := writeCmd(wr, reqs[i].cmd); e != nil {
return e
}
}
return nil
})
if err == nil {
// Clean flush: attach this chunk's Limiter obligation to its LAST req so the
// reader settles ReportResult(nil) once every reply lands, then publish — the
// reader only starts its reply deadline once the bytes are on the wire. On
// error/panic the report defer above fires the write error and the recovery
// defer pushes the reqs WITHOUT an obligation, so nothing double-reports.
if report != nil {
reqs[len(reqs)-1].limReport = report
}
inflight.pushBatch(reqs)
pushed = true
}
return err
}
// fdBatchEnd returns the exclusive end index of the next write chunk starting at
// `start`, applying the same caps as the drain loop: at most maxBatch commands,
// and (when byteLimit > 0) stop once the accumulated approximate payload reaches
// the limit — but always include the first command, so a lone oversized command
// still goes. Pure; the boundary logic is unit-tested.
func fdBatchEnd(reqs []fdReq, start, maxBatch int, byteLimit int64) int {
end := start + 1
bytes := cmdApproxBytes(reqs[start].cmd)
for end < len(reqs) && end-start < maxBatch {
if byteLimit > 0 && bytes >= byteLimit {
break
}
bytes += cmdApproxBytes(reqs[end].cmd)
end++
}
return end
}
// fdBatchEndSafe is fdBatchEnd with a per-command recover. cmd.Args() (used by
// cmdApproxBytes) is user code and may panic. A carried chunk can include commands
// that never passed the serve loop's cmdApproxBytesSafe admission — the session-start
// command handed straight from fd.ch (run() blocks on the first command and re-issues
// it as carry) and an unacked tail carried between sessions — so a deterministic
// panicking Args() can reach here; it must be contained WITHOUT tearing down a healthy
// connection (nothing in the chunk is written yet, so the conn is not desynced). This
// is the LIVE-session write path (writeCarryChunked only); the terminal Close backlog
// drained by takeQueue does not reach here — it flushes through shutdownFlush/flushReqs,
// which contains a panic with its own recover and aborts the ordered flush. On a panic
// it returns the clean prefix end (start..end, end>=start) and
// bad = the index of the offending command, plus the wrapped error; the caller writes
// [start:end), fails+drops carry[bad], and resumes at bad+1. bad == -1 means the whole
// chunk sized cleanly.
func fdBatchEndSafe(reqs []fdReq, start, maxBatch int, byteLimit int64) (end, bad int, err error) {
end, bad = start, -1
idx := start // the command currently being sized; the recover reports it as bad
defer func() {
if r := recover(); r != nil {
end, bad = idx, idx // clean prefix is [start:idx); idx is what panicked
err = fmt.Errorf("%w: Args: %v", errFDPanicRecovered, r)
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex carry Args() panic: %v\n%s", r, debug.Stack())
}
}()
bytes := cmdApproxBytes(reqs[start].cmd)
end = start + 1
for end < len(reqs) && end-start < maxBatch {
if byteLimit > 0 && bytes >= byteLimit {
break
}
idx = end
bytes += cmdApproxBytes(reqs[end].cmd)
end++
}
return end, -1, nil
}
// writeCarryChunked re-issues a recovered tail on a fresh connection in the same
// capped chunks as freshly drained work (see fdBatchEnd), so a large recovered
// window is not flushed in one oversized write.
//
// maxC is the session's FullDuplexMaxHold timer (nil when disabled or on the Close
// flush, where a terminating Close bounds its own wait and outranks max-hold). On the
// LIVE path a long or backpressured replay that would hold the conn past max-hold
// stops early, exactly like the ShouldHandoff poll below.
//
// Returns (unwritten, err):
// - (nil, nil): the whole carry was written.
// - (suffix, errFDConnMoving): the connection was marked for handoff mid-replay
// on a still-alive connection. The unwritten suffix is returned OUT-OF-BAND
// (not pushed into inflight) so the caller can drain the already-written prefix
// to completion, REMOVE the moving connection, and replay ONLY the never-sent
// suffix on a fresh connection — the written prefix is not re-executed.
// - (suffix, errFDMaxHold): the LIVE connection was held past FullDuplexMaxHold
// mid-replay. Same out-of-band suffix handling as errFDConnMoving; the caller
// clean-recycles (drains the prefix, Puts the conn so the hold ends) and replays
// only the never-sent suffix on the next lease.
// - (nil, errFDReaderGone | write error): the connection is dead; the unwritten
// suffix is pushed into inflight so the whole unacked tail is recovered and
// replayed at-least-once (a clean drain is impossible).
func (fd *fdEngine) writeCarryChunked(bg context.Context, cn *pool.Conn, inflight *fdInflight, carry []fdReq, readerDone <-chan struct{}, maxC <-chan time.Time) (unwritten []fdReq, err error) {
byteLimit := int64(fd.ap.config.MaxBatchBytes) // 0 = disabled
// stuck becomes true if the reader is observed not draining under a full
// window; from then on we stop window-gating and just write, so a graceful
// Close cannot hang here waiting on a reader that never advances (a quiet peer
// with ReadTimeout disabled). A truly stuck reader is caught downstream by the
// graceful-drain <-readerDone / Close backstop.
stuck := false
for i := 0; i < len(carry); {
// Between chunks, stop if the reader is gone (decode panic, protocol
// error mid-replay): writing further chunks to a reader-less connection
// only enlarges the ambiguous at-least-once set — same priority rule as
// the serve loop. Push the un-written remainder so takeRemaining recovers
// the whole accepted set.
if readerDone != nil {
select {
case <-readerDone:
fdRefundUnsentAttempt(carry[i:]) // never written: do not charge this send
inflight.pushBatch(carry[i:])
return nil, errFDReaderGone
default:
}
}
// Between chunks, stop if the connection was marked for handoff, exactly as
// the serve loop polls ShouldHandoff: a MOVING/FAILING_OVER push drained by
// the reader marks cn, and carry replay runs BEFORE the serve loop, so a
// large replay must not keep streaming to a moving node. The connection is
// still ALIVE, so return the unwritten suffix OUT-OF-BAND (do NOT push it into
// inflight): the caller drains the already-written prefix to completion (no
// re-execution), REMOVES the moving connection, and replays only the suffix on
// a fresh one. ShouldHandoff() is an atomic load, safe to poll here.
if cn.ShouldHandoff() {
return carry[i:], errFDConnMoving
}
// Between chunks, stop if the connection has been held past FullDuplexMaxHold, so
// a long replay under continuous load recycles the conn instead of pinning it (the
// serve loop's max-hold select is never reached while replay runs). LIVE path only:
// a terminating Close (ap.ctx cancelled) bounds its own flush and outranks max-hold.
// Same out-of-band suffix handling as the ShouldHandoff poll above — the conn is
// still alive.
if maxC != nil && fd.ap.ctx.Err() == nil {
select {
case <-maxC:
return carry[i:], errFDMaxHold
default:
}
}
// Bound in-flight to the window between chunks, like the serve loop — which
// uses a PREDICATE LOOP, not a one-shot wait: inflight.room is a cap-1
// channel that can hold a STALE signal (the reader popped, the writer
// observed the room and refilled it without consuming the signal). So after
// each wake recheck inflight.len() >= fd.window before writing. A one-shot if
// could consume that stale signal with the deque still full, compute zero
// remaining room, leave lim at MaxBatchSize, and write past FullDuplexWindow.
// On graceful Close this writes the backlog on top of replies still in
// flight, so without the gate a small FullDuplexWindow is exceeded by up to
// ~2x (window + buffered backlog). Two wait modes, split on ap.ctx:
// - LIVE engine (ordinary carry replay at session start): honor the window
// like the serve loop — wait for room with no bail-out. The wait is
// bounded by the reader itself (a dead peer trips its ReadTimeout, the
// reader exits, readerDone fires), and ap.ctx.Done switches a concurrent
// Close to the bounded mode without waiting on a drain.
// - CLOSE-time flush (ap.ctx cancelled): the wait is BOUNDED instead — if
// the reader does not drain within fdCloseFlushWait, stop gating (set
// stuck) so Close cannot block forever; correctness of a terminating
// Close outranks a transient teardown overshoot.
for !stuck && readerDone != nil && inflight.len() >= fd.window {
// Poll handoff on every wake too, like the serve loop's window gate, so a
// MOVING mark under backpressure recycles within one drained reply. Suffix
// out-of-band (no push), same as the between-chunk check above.
if cn.ShouldHandoff() {
return carry[i:], errFDConnMoving
}
if fd.ap.ctx.Err() == nil {
select {
case <-inflight.room:
case <-readerDone:
fdRefundUnsentAttempt(carry[i:]) // never written: do not charge this send
inflight.pushBatch(carry[i:])
return nil, errFDReaderGone
case <-maxC:
// Held past FullDuplexMaxHold while waiting for window room: recycle the
// live conn (out-of-band suffix), same as the between-chunk poll above. A
// nil maxC (disabled, or the Close flush) never fires.
return carry[i:], errFDMaxHold
case <-fd.ap.ctx.Done():
// Close raced in: re-enter the loop in bounded mode.
}
continue
}
timer := time.NewTimer(fdCloseFlushWait)
select {
case <-inflight.room:
case <-readerDone:
timer.Stop()
fdRefundUnsentAttempt(carry[i:]) // never written: do not charge this send
inflight.pushBatch(carry[i:])
return nil, errFDReaderGone
case <-timer.C:
stuck = true
}
timer.Stop()
}
// Cap this chunk to the remaining window room (not just MaxBatchSize): right
// after the wait releases, a full MaxBatchSize chunk on top of window-1
// in-flight would still nearly double the bound. Once stuck, write full
// chunks to finish the flush promptly.
lim := fd.maxBatch
if !stuck {
if room := fd.window - inflight.len(); room > 0 && room < lim {
lim = room
}
}
// Carry re-sizing uses cmd.Args() (user code), guarded by fdBatchEndSafe. A
// carried chunk CAN include commands that never passed the serve loop's
// cmdApproxBytesSafe admission — the session-start command taken straight from
// fd.ch (run() blocks on the first command and hands it in as carry) and an
// unacked tail carried between sessions — so a deterministic panicking Args()
// can reach here. (The terminal Close backlog drained by takeQueue does NOT reach
// here; it flushes through shutdownFlush/flushReqs, whose own recover aborts the
// ordered flush.) Contain it WITHOUT tearing the session down: nothing in this chunk
// is written yet, so the conn is healthy. Fail+drop just the offending command
// (like the serve loop's sizing guard) and resume with the rest, instead of
// killing the engine goroutine and letting attempt()'s defer Put a live conn.
// The contract (see AddHook / a Cmder's Args) still requires deterministic,
// panic-free Args(); this only stops one bad command from stranding a whole
// accepted backlog.
end, bad, sizeErr := fdBatchEndSafe(carry, i, lim, byteLimit)
if end > i {
if e := fd.writeBatch(bg, cn, inflight, carry[i:end]); e != nil {
// writeBatch pushed carry[i:end] into inflight before the failed write (it
// was attempted — at-least-once — so the serialized prefix keeps its bumped count), but
// the suffix carry[end:] was never pushed. Push it too, or it sits in neither
// fd.ch nor inflight and its callers hang: on fdConnErr takeRemaining replays
// it, on Close the caller fails it. It is only ever settled via failReqs or
// replayed — never completed inline by the reader — so its zero writtenAt
// never reaches the write→reply metric. carry[end:] was NEVER written, so
// refund its optimistic attempt bump here; the never-serialized tail of
// carry[i:end] (behind an encoder panic) is refunded by writeBatch itself, so
// only its serialized prefix keeps the charge.
if end < len(carry) {
fdRefundUnsentAttempt(carry[end:])
inflight.pushBatch(carry[end:])
}
return nil, e
}
}
if bad >= 0 {
// carry[bad] (== carry[end]) panicked while sizing. It was never written and
// is not in inflight, so failing it here cannot double-complete it. Refund the
// optimistic attempt bump (run() charged the whole carry a send before
// re-issuing it), fail just this command, and resume past it — the healthy
// conn keeps serving the rest of the carry.
fdRefundUnsentAttempt(carry[bad : bad+1])
fd.failReqs(carry[bad:bad+1], sizeErr)
i = bad + 1
continue
}
i = end
}
return nil, nil
}
// fdRefundUnsentAttempt undoes the optimistic attempt bump for a carried suffix
// that was NEVER written this session (the reader died or the connection broke
// before this chunk was sent). run() bumps the whole carry's attempts before
// re-issuing it, charging each command for a send; a command that was not actually
// sent must not keep that charge, or with a tight MaxRetries it can be declared
// budget-exhausted one replay early. The next replay re-bumps it when it is really
// sent. Floored at 0. Callers apply this to the never-sent suffix BEFORE pushing it
// into the in-flight deque, so the recovered copies carry the corrected count.
func fdRefundUnsentAttempt(reqs []fdReq) {
for i := range reqs {
if reqs[i].attempts > 0 {
reqs[i].attempts--
}
}
}
// fdFirstNoRetry returns the index of the first command that must not be
// (re-)issued: a NoRetry command that was already SENT (its bytes may have
// reached the wire — see fdReq.sent), or len(reqs) when there is none. The
// unacked tail is retried up to this index and failed from it on: retryable
// commands ahead of it still get their network retries, while the sent NoRetry
// command and anything ordered after it is never re-sent. A NEVER-SENT NoRetry
// command does not split the tail — replaying it is its first send, so failing
// it would error a command the server never saw.
func fdFirstNoRetry(reqs []fdReq) int {
for i := range reqs {
if reqs[i].cmd.NoRetry() && reqs[i].sent {
return i
}
}
return len(reqs)
}
// fdFirstNoRetrySafe wraps fdFirstNoRetry with a recover. cmd.NoRetry() may be a
// custom Cmder's user code, and the retry-classification scan runs on run()'s
// serve loop, which has no top-level recover — a panic there would kill the
// engine and strand every in-flight and future command. On panic it returns
// panicked=true; the caller then declines to replay and fails the tail (the
// conservative choice: a command that cannot be classified as retryable, and may
// be a sent NoRetry, must never be re-sent).
func fdFirstNoRetrySafe(reqs []fdReq) (n int, panicked bool) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex NoRetry() scan panic: %v\n%s", r, debug.Stack())
n, panicked = 0, true
}
}()
return fdFirstNoRetry(reqs), false
}
// fdNoRetrySafe wraps a single cmd.NoRetry() call with a recover. On the reader's
// reply path NoRetry() is consulted AFTER the reply has already been consumed but
// BEFORE the request is counted complete; a custom Cmder whose NoRetry() panics there
// would otherwise reach the reader's session-failure recover, which treats the request
// as an unacked tail and REPLAYS it — running an already-answered mutating command
// twice. Recover locally and report the command as non-retryable (true) so the caller
// surfaces the already-landed reply inline and never diverts or replays it.
func fdNoRetrySafe(cmd Cmder) (noRetry bool) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex NoRetry() panic: %v\n%s", r, debug.Stack())
noRetry = true
}
}()
return cmd.NoRetry()
}
// fdSetErrSafe wraps a single cmd.SetErr() call with a recover. Same hazard as
// fdNoRetrySafe: every caller settles a request that is about to be marked
// complete regardless of outcome (the reader's reply path, and the off-pipe
// retry goroutine's own recover) — completion must happen unconditionally so
// the request is never left in an unacked, replayable state. A custom Cmder
// whose SetErr() panics here would otherwise escape to whichever recover is
// the caller's own outer boundary (for the reader, the session-failure
// recover, which treats an incomplete request as an unacked tail and REPLAYS
// it — running an already-executed mutating command twice; for the retry
// goroutine's recover, there is no outer boundary at all, so a second panic
// mid-unwind would kill the process). Recover locally and log; the caller
// proceeds to complete the request either way.
func fdSetErrSafe(cmd Cmder, err error) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex SetErr() panic: %v\n%s", r, debug.Stack())
}
}()
cmd.SetErr(err)
}
// fdRecoverTail builds an fdConnErr recovery set from a drained unacked tail and
// a never-sent suffix, in order. Fresh slice: rem is takeRemaining's deque-owned
// backing array, so appending onto it could corrupt the deque. The suffix is
// refunded (fdRefundUnsentAttempt): it was never written this session, and run()'s
// fdConnErr recovery bumps the whole replay set for the NEXT issue — without the
// refund a never-sent command would be charged for a send that never happened and
// could be declared budget-exhausted one replay early.
func fdRecoverTail(rem, suffix []fdReq) []fdReq {
out := make([]fdReq, 0, len(rem)+len(suffix))
out = append(out, rem...)
out = append(out, suffix...)
fdRefundUnsentAttempt(out[len(rem):])
return out
}
// settleTail settles every Limiter obligation carried by an fdConnErr recovery
// set with err, exactly once, and returns the SAME slice so it wraps a recovery
// expression inline. Called at each session() point that hands a
// written-but-unacked tail back for replay/failure: the reader never completed
// these chunks, so their reply-side outcome is this transport error. Settling
// here — inside session(), inseparable from producing the recovery set — keeps
// "no obligation outlives its session" readable in one function and pairs every
// Allow whose replies never arrived. The field is cleared so the obligation
// never travels into the replay (the rewrite's writeBatch mints a fresh Allow +
// obligation).
func (fd *fdEngine) settleTail(reqs []fdReq, err error) []fdReq {
for i := range reqs {
if reqs[i].limReport != nil {
reqs[i].limReport.settle(err)
reqs[i].limReport = nil
}
}
return reqs
}
// failReqs completes a set of commands with err (used on retry exhaustion / Close).
// classifyCommandErrorGuarded wraps classifyCommandError with a recover: it calls
// err.Error(), which is user-reachable (e.g. an error returned by a custom Limiter) and
// can panic. The failing paths classify BEFORE completing their requests, and the chunk
// is not in the in-flight queue, so an escaping panic here would leave every caller
// blocked forever (session recovery cannot reclaim it). On panic, fall back to empty
// classification so the requests still settle.
func classifyCommandErrorGuarded(err error) (errorType, statusCode string, isInternal bool) {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"autopipeline: recovered full-duplex error classification panic: %v", r)
errorType, statusCode, isInternal = "", "", false
}
}()
return classifyCommandError(err)
}
func (fd *fdEngine) failReqs(reqs []fdReq, err error) {
// Error-metric parity: commands terminated here (lease failure, retry
// exhaustion, a NoRetry tail, Close) never reach the reader's inline
// completion, so emit the native error callback per command. One classification
// for the whole set (every req fails with the same err), and no duration metric
// — many of these were never written.
errorCallback := pool.GetMetricErrorCallback()
var errorType, statusCode string
var isInternal bool
if errorCallback != nil && len(reqs) > 0 {
errorType, statusCode, isInternal = classifyCommandErrorGuarded(err)
}
for i := range reqs {
// rawErr(), not Err(): this runs on the engine goroutine, and Err()
// awaits batch.done — the very channel complete() closes just below — so
// awaiting here would self-deadlock (the same trap hostHook documents).
if reqs[i].cmd.rawErr() == nil {
// fdSetErrSafe: a panicking custom Cmder must not escape here — this
// runs on the sole fd.run goroutine with no outer recover, so an
// unguarded panic would kill the engine and leave every later req in
// reqs unsettled.
fdSetErrSafe(reqs[i].cmd, err)
}
if errorCallback != nil {
octx := reqs[i].ctx
if octx == nil {
octx = context.Background()
}
// Report attempts-1 retries (like processWithRetry), so a carried tail
// failed after replays is not undercounted as zero. max() guards a req that
// somehow carries attempts==0. Guarded per-req (not around the loop) so a
// panicking callback still lets THIS req and every later one settle below.
retries := max(0, reqs[i].attempts-1)
fd.emitMetricsGuarded(octx, func() {
errorCallback(octx, errorType, nil, statusCode, isInternal, retries)
})
}
reqs[i].complete()
}
}
// takeQueue closes the submit gate and returns everything buffered in fd.ch. The
// WLock blocks until in-flight submit sends finish (each either landed in fd.ch,
// is drained below, or took its ctx.Done() branch), so after the drain no submit
// can enqueue work that would be left un-completed.
//
// INVARIANT: every takeQueue call is a terminal shutdown drain — run() exits
// right after, past a ctx-cancel check. Never call it on a non-close path: a
// submit blocked on a full channel is unwedged only by its ctx.Done() branch, so
// without a cancelled ctx the WLock deadlocks against the RLock held across that
// send.
func (fd *fdEngine) takeQueue() []fdReq {
fd.submitMu.Lock()
fd.closed = true
fd.submitMu.Unlock()
var reqs []fdReq
for {
select {
case r := <-fd.ch:
reqs = append(reqs, r)
default:
return reqs
}
}
}
// shutdownFlush is the between-sessions Close flush: accepted commands in carry
// (an unacked tail from a failed session, never re-leased) and in fd.ch
// (accepted while no session held a connection) are executed on the client's
// normal pipeline path, honoring the "accepted ⇒ completes" Close contract
// instead of failing them ErrClosed just because Close won the race between
// sessions (#3964). Uses a background ctx (ap.ctx is already cancelled);
// processPipeline bounds it with the client's own timeouts/retries and setCmdsErr
// puts any failure on every command, so callers always settle.
func (fd *fdEngine) shutdownFlush(bg context.Context, carry []fdReq) {
// Drain and close the queue now (before flushing carry) so no new submit lands
// mid-flush. fresh commands (attempts == 1) have not run yet.
fresh := fd.takeQueue()
// Flush the carried tail honoring EACH command's remaining retry budget across
// the Close boundary (flushCarryBudgeted), then the fresh queue at the full
// budget. Flushing carry first keeps FIFO order across the two sets.
if err := fd.flushCarryBudgeted(bg, carry); err != nil {
// Carry hit an unreachable endpoint; do not run the fresh queue through a full
// retry cycle against the same dead endpoint (Close would otherwise stall for
// chunks × retries × backoff) — fail it with the same transport error.
fd.failReqs(fresh, err)
return
}
// Last flush: a transport failure is already handled inside flushReqs (it fails
// the remainder), and there is nothing after it, so the returned error is moot.
_ = fd.flushReqs(bg, fresh, fd.retryBudget())
}
// fdCarryRemainingRetries returns the retry bound for a carried command flushed on
// Close. carry commands are POST-BUMP: run() bumps attempts before re-issuing, so a
// command carried at attempts=A has completed A-1 executions (contrast the pre-bump
// unacked tail in run(), where A executions are done). Of its MaxRetries+1 total
// budget it may still run MaxRetries+2-A times, i.e. a retry bound of
// MaxRetries+1-A. A negative result means the budget is spent (drop the command).
// attempts is clamped to >=1: attempts==0 is only reachable from test-constructed
// fdReq literals, and the clamp keeps such a command at full budget rather than
// granting MaxRetries+2.
func fdCarryRemainingRetries(attempts, maxRetries int) int {
if attempts < 1 {
attempts = 1
}
return maxRetries + 1 - attempts
}
// flushCarryBudgeted flushes the carried tail on Close so no command exceeds — or
// falls short of — its configured MaxRetries+1 total executions. Commands are
// flushed in contiguous groups of equal attempt count, each with its own remaining
// budget (fdCarryRemainingRetries); a group whose budget is spent is failed, not
// re-run. Grouping equal-attempt RUNS is correct regardless of ordering (carry is
// normally attempts-descending, so groups are few, but a non-sorted slice just
// yields more groups). Returns a transport error that aborted the remainder.
func (fd *fdEngine) flushCarryBudgeted(bg context.Context, carry []fdReq) error {
mr := fd.retryBudget()
for i := 0; i < len(carry); {
a := carry[i].attempts
j := i + 1
for j < len(carry) && carry[j].attempts == a {
j++
}
group := carry[i:j]
i = j
rem := fdCarryRemainingRetries(a, mr)
if rem < 0 {
fd.failReqs(group, errFDRetryBudgetExhausted) // budget spent; do not re-run
continue
}
if err := fd.flushReqs(bg, group, rem); err != nil {
if i < len(carry) {
fd.failReqs(carry[i:], err) // transport failure: fail the rest of the carry too
}
return err
}
}
return nil
}
// flushReqs runs reqs through the client pipeline in the same
// MaxBatchSize/MaxBatchBytes chunks as normal FD writes and completes each
// request. maxRetries bounds each chunk's retry loop (0 = a single execution,
// used for already-attempted carried commands so their per-command budget is not
// exceeded). Returns a non-nil error when the remainder was aborted and failed
// here: a transport failure, a desynchronized reply stream (errConnUnusable — e.g.
// a custom push processor errored during the drain, so the chunk was never sent),
// or a recovered serialize panic. A plain per-command Redis error is a normal
// result and does not abort.
//
// Unlike the live-session write path (writeCarryChunked, which sizes with
// fdBatchEndSafe and isolates a single panicking command), this terminal Close
// flush does NOT isolate a per-command Args()/NoRetry() panic: the outer recover
// fails the remainder so an ordered flush stops rather than running later chunks
// out of order. Every accepted command still settles (it is failed, not left
// hanging), honoring accepted⇒completes. See TestFDShutdownFlushAbortsAfterRecoveredPanic.
func (fd *fdEngine) flushReqs(bg context.Context, reqs []fdReq, maxRetries int) (retErr error) {
if len(reqs) == 0 {
return nil
}
// A panic here (user arg encoder inside the pipeline) runs on the engine
// goroutine with no other recovery; fail and complete the remainder so no caller
// hangs. Set the NAMED return so an ordered shutdown flush aborts like a
// transport failure: an unnamed result would zero to nil after recovery, and
// flushCarryBudgeted/shutdownFlush would then treat the failed group as success
// and run later groups + the fresh queue even though an earlier command never
// completed.
i := 0
defer func() {
if r := recover(); r != nil {
retErr = fmt.Errorf("%w: shutdown flush: %v", errFDPanicRecovered, r)
internal.Logger.Printf(bg, "autopipeline: recovered shutdown-flush panic: %v\n%s", r, debug.Stack())
fd.failReqs(reqs[i:], retErr)
}
}()
// Test seam: nil in production, so this is exactly client.processPipelineRetries.
run := fd.runPipeline
if run == nil {
run = fd.client.processPipelineRetries
}
// Same MaxBatchSize/MaxBatchBytes chunking as normal FD writes: reqs can hold a
// large window, and one unchunked pipeline would ignore MaxBatchBytes and burst
// the connection.
byteLimit := int64(fd.ap.config.MaxBatchBytes) // 0 = disabled
for i < len(reqs) {
end := fdBatchEnd(reqs, i, fd.maxBatch, byteLimit)
// Do not mix retry policies in one chunk: generalProcessPipeline disables
// retries for the WHOLE chunk if any command is NoRetry (cmdsContainNoRetry).
// That would strip retryable commands in the same accepted backlog of their
// budget. Break the chunk at the first NoRetry-policy change so a NoRetry
// command (e.g. RawWriteToCmd) is isolated from its retryable neighbors, like
// the half-duplex dispatcher's contiguous retry-policy runs. The clamp starts
// at i+1, so end stays > i and the chunk is never empty (no infinite loop).
policy := reqs[i].cmd.NoRetry()
for k := i + 1; k < end; k++ {
if reqs[k].cmd.NoRetry() != policy {
end = k
break
}
}
cmds := make([]Cmder, end-i)
for j := i; j < end; j++ {
cmds[j-i] = reqs[j].cmd
}
// Initialize the flush with a request's own context (cancellation removed), not
// the engine's background context: if this flush initializes a fresh pooled
// connection, a CredentialsProviderContext resolves credentials from the
// request's context values, so it authenticates as the right tenant.
// WithoutCancel because these contexts may already be cancelled (often what
// triggered Close), yet accepted-⇒-completes still requires the write. A chunk
// can mix callers; the first request is the representative — a documented
// approximation, matching the diverted-retry and session-init paths.
fctx := bg
if c := reqs[i].ctx; c != nil {
fctx = context.WithoutCancel(c)
}
err := run(fctx, cmds, maxRetries) // per-command results/errors set inside
for j := i; j < end; j++ {
reqs[j].complete()
}
i = end
// Stop and fail the remaining chunks when the error means the earlier chunk
// may never have been written correctly: a transport failure that survived
// the retry loop (dead endpoint), OR a desynchronized reply stream marked
// errConnUnusable (e.g. a custom push processor errored during the close-time
// drain, so the chunk was never sent). Continuing would run an ordered
// shutdown flush out of order. pipelineErrShouldStamp is the same
// errConnUnusable precedence used in generalProcessPipeline; a plain
// per-command Redis error is a normal result and does not abort.
if err != nil && pipelineErrShouldStamp(err) {
fd.failReqs(reqs[i:], err)
return err
}
}
return nil
}
// failQueue fails every command currently buffered in fd.ch with err WITHOUT
// closing the engine (unlike takeQueue, the shutdown drain, which sets closed).
// Used on fdLeaseErr, where the carry goes through failReqs and this drains the
// accepted backlog — both halves emit the native error metric. The engine stays
// alive, so a command submitted after this returns is served once the
// server/pool recovers, or failed when the next lease exhausts its retries. The
// channel receive is safe against a concurrent submit send, so no lock is taken
// here.
func (fd *fdEngine) failQueue(err error) {
errorCallback := pool.GetMetricErrorCallback()
var errorType, statusCode string
var isInternal bool
classified := false
for {
select {
case r := <-fd.ch:
// fdSetErrSafe: same hazard as failReqs — a panicking custom Cmder must
// not escape on the sole fd.run goroutine with no outer recover.
fdSetErrSafe(r.cmd, err)
if errorCallback != nil {
if !classified {
errorType, statusCode, isInternal = classifyCommandErrorGuarded(err)
classified = true
}
octx := r.ctx
if octx == nil {
octx = context.Background()
}
// Guarded per-req so a panicking callback still lets r.complete() run.
fd.emitMetricsGuarded(octx, func() {
errorCallback(octx, errorType, nil, statusCode, isInternal, 0)
})
}
r.complete()
default:
return
}
}
}
// flushBacklogForClose is the graceful-Close flush: it stops new submits (sets
// closed) and writes every command still buffered in fd.ch on the current
// connection, in the same MaxBatchSize/MaxBatchBytes chunks as normal writes, so
// ACCEPTED commands complete instead of failing ErrClosed. The caller then
// closeGraceful()s the deque so the reader drains these replies before exiting.
// Returns (unwritten, err) from writeCarryChunked: on errFDConnMoving (handoff
// mid-flush, live conn) unwritten is the never-sent suffix the caller flushes
// elsewhere; on a real write error unwritten is nil (that suffix is already in
// inflight) and the caller degrades to the conn-error path.
func (fd *fdEngine) flushBacklogForClose(bg context.Context, cn *pool.Conn, inflight *fdInflight, readerDone <-chan struct{}) ([]fdReq, error) {
fd.submitMu.Lock()
fd.closed = true
fd.submitMu.Unlock()
var backlog []fdReq
for {
select {
case r := <-fd.ch:
backlog = append(backlog, r)
default:
// Return the unwritten suffix OUT-OF-BAND (do not push it into inflight) so
// the caller can tell a live handoff (errFDConnMoving) from a dead-conn write
// error: on handoff it clean-recycles — drains the written prefix, Puts the
// conn for the OnPut maintenance handoff, and completes the never-sent suffix
// on another connection — instead of failing accepted work. The dead-conn
// paths inside writeCarryChunked already pushed their suffix into inflight and
// return an empty one here.
// nil maxC: this is the Close flush; a terminating Close bounds its own wait
// (fdCloseFlushWait) and outranks max-hold, which applies only to a LIVE session.
return fd.writeCarryChunked(bg, cn, inflight, backlog, readerDone, nil)
}
}
}
// sleepBackoff waits the retry backoff, interruptible by Close.
func (fd *fdEngine) sleepBackoff(attempt int) {
d := internal.RetryBackoff(attempt, fd.client.opt.MinRetryBackoff, fd.client.opt.MaxRetryBackoff)
if d <= 0 {
return
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
case <-fd.ap.ctx.Done():
}
}
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
}
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
}
package redis
import (
"bufio"
"context"
"fmt"
"io"
"maps"
"net"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/hscan"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/internal/routing"
"github.com/redis/go-redis/v9/internal/util"
)
// keylessCommands contains Redis commands that have empty key specifications (9th slot empty)
// Only includes core Redis commands, excludes FT.*, ts.*, timeseries.*, search.* and subcommands
var keylessCommands = map[string]struct{}{
"acl": {},
"asking": {},
"auth": {},
"bgrewriteaof": {},
"bgsave": {},
"client": {},
"cluster": {},
"config": {},
"debug": {},
"discard": {},
"echo": {},
"exec": {},
"failover": {},
"function": {},
"hello": {},
"hotkeys": {},
"latency": {},
"lolwut": {},
"module": {},
"monitor": {},
"multi": {},
"pfselftest": {},
"ping": {},
"psubscribe": {},
"psync": {},
"publish": {},
"pubsub": {},
"punsubscribe": {},
"quit": {},
"readonly": {},
"readwrite": {},
"replconf": {},
"replicaof": {},
"role": {},
"save": {},
"script": {},
"select": {},
"shutdown": {},
"slaveof": {},
"slowlog": {},
"subscribe": {},
"swapdb": {},
"sync": {},
"time": {},
"unsubscribe": {},
"unwatch": {},
"wait": {},
}
// CmdTyper interface for getting command type
type CmdTyper interface {
GetCmdType() CmdType
}
// CmdTypeGetter interface for getting command type without circular imports
type CmdTypeGetter interface {
GetCmdType() CmdType
}
type CmdType uint8
const (
CmdTypeGeneric CmdType = iota
CmdTypeString
CmdTypeInt
CmdTypeBool
CmdTypeFloat
CmdTypeStringSlice
CmdTypeIntSlice
CmdTypeFloatSlice
CmdTypeBoolSlice
CmdTypeMapStringString
CmdTypeMapStringInt
CmdTypeMapStringInterface
CmdTypeMapStringInterfaceSlice
CmdTypeSlice
CmdTypeStatus
CmdTypeDuration
CmdTypeTime
CmdTypeKeyValueSlice
CmdTypeStringStructMap
CmdTypeXMessageSlice
CmdTypeXStreamSlice
CmdTypeXPending
CmdTypeXPendingExt
CmdTypeXAutoClaim
CmdTypeXAutoClaimWithDeleted
CmdTypeXAutoClaimJustID
CmdTypeXInfoConsumers
CmdTypeXInfoGroups
CmdTypeXInfoStream
CmdTypeXInfoStreamFull
CmdTypeZSlice
CmdTypeZWithKey
CmdTypeScan
CmdTypeClusterSlots
CmdTypeGeoLocation
CmdTypeGeoSearchLocation
CmdTypeGeoPos
CmdTypeCommandsInfo
CmdTypeSlowLog
CmdTypeMapStringStringSlice
CmdTypeMapMapStringInterface
CmdTypeKeyValues
CmdTypeZSliceWithKey
CmdTypeFunctionList
CmdTypeFunctionStats
CmdTypeLCS
CmdTypeKeyFlags
CmdTypeClusterLinks
CmdTypeClusterShards
CmdTypeRankWithScore
CmdTypeClientInfo
CmdTypeACLLog
CmdTypeInfo
CmdTypeMonitor
CmdTypeJSON
CmdTypeJSONSlice
CmdTypeIntPointerSlice
CmdTypeScanDump
CmdTypeBFInfo
CmdTypeCFInfo
CmdTypeCMSInfo
CmdTypeTopKInfo
CmdTypeTDigestInfo
CmdTypeFTSynDump
CmdTypeAggregate
CmdTypeFTInfo
CmdTypeFTSpellCheck
CmdTypeFTSearch
CmdTypeTSTimestampValue
CmdTypeTSTimestampValueSlice
CmdTypeTSNRangePivotRowSlice
CmdTypeHotKeys
CmdTypeIncrEXInt
CmdTypeIncrEXFloat
CmdTypeUint
CmdTypeUintSlice
CmdTypeAREntrySlice
)
type (
CmdTypeXAutoClaimValue struct {
messages []XMessage
start string
}
CmdTypeXAutoClaimWithDeletedValue struct {
messages []XMessage
start string
deletedIDs []string
}
CmdTypeXAutoClaimJustIDValue struct {
ids []string
start string
}
CmdTypeScanValue struct {
keys []string
cursor uint64
}
CmdTypeKeyValuesValue struct {
key string
values []string
}
CmdTypeZSliceWithKeyValue struct {
key string
zSlice []Z
}
)
type Cmder interface {
// command name.
// e.g. "set k v ex 10" -> "set", "cluster info" -> "cluster".
Name() string
// full command name.
// e.g. "set k v ex 10" -> "set", "cluster info" -> "cluster info".
FullName() string
// all args of the command.
// e.g. "set k v ex 10" -> "[set k v ex 10]".
Args() []interface{}
// format request and response string.
// e.g. "set k v ex 10" -> "set k v ex 10: OK", "get k" -> "get k: v".
String() string
// Clone creates a copy of the command.
Clone() Cmder
stringArg(int) string
firstKeyPos() int8
SetFirstKeyPos(int8)
stepCount() int8
SetStepCount(int8)
// cachedSlot/setCachedSlot memoize the cluster slot so it is computed once
// (in the autopipeline shard router) and reused at pipeline-flush routing.
cachedSlot() (int, bool)
setCachedSlot(int)
readTimeout() *time.Duration
readReply(rd *proto.Reader) error
readRawReply(rd *proto.Reader) error
SetErr(error)
Err() error
// setReady marks a command as asynchronously pending (autopipeline async
// faces); await blocks the public accessors until it has executed; rawErr
// reads the error without awaiting (internal execution path).
setReady(*apBatch)
await()
rawErr() error
// NoRetry returns true if the command should not be retried on failure.
// Commands that write directly to an io.Writer should return true since
// partial writes cannot be undone on retry.
NoRetry() bool
// GetCmdType returns the command type for fast value extraction
GetCmdType() CmdType
}
func setCmdsErr(cmds []Cmder, e error) {
for _, cmd := range cmds {
// rawErr: this runs on the execution path; never await here.
if cmd.rawErr() == nil {
cmd.SetErr(e)
}
}
}
func cmdsFirstErr(cmds []Cmder) error {
for _, cmd := range cmds {
// rawErr: this runs on the execution path; never await here.
if err := cmd.rawErr(); err != nil {
return err
}
}
return nil
}
// cmdsContainNoRetry returns true if any command in the slice has NoRetry() == true.
// If a pipeline contains a non-retryable command (e.g., RawWriteToCmd), the entire
// pipeline must not be retried to prevent data corruption from partial writes.
func cmdsContainNoRetry(cmds []Cmder) bool {
for _, cmd := range cmds {
if cmd.NoRetry() {
return true
}
}
return false
}
func writeCmds(wr *proto.Writer, cmds []Cmder) error {
for _, cmd := range cmds {
if err := writeCmd(wr, cmd); err != nil {
return err
}
}
return nil
}
func writeCmd(wr *proto.Writer, cmd Cmder) error {
return wr.WriteArgs(cmd.Args())
}
// cmdFirstKeyPosWithInfo returns the first key position in a command's args (0 if none).
// Uses CommandInfo.FirstKeyPos when available (via cache peek, no network call), falling
// back to a hardcoded table. eval/evalsha variants are resolved from the runtime numkeys arg.
func cmdFirstKeyPosWithInfo(cmd Cmder, info *CommandInfo) int {
if pos := cmd.firstKeyPos(); pos != 0 {
return int(pos)
}
name := cmd.Name()
// first check if the command is keyless
if _, ok := keylessCommands[name]; ok {
return 0
}
// Module commands registered keyless in the static policy table (e.g.
// ft.aliaslist) route as keyless even while the command-info cache is
// cold, so the first calls of a process don't hash a non-key argument
// (such as an index name) into a slot.
if defaultPolicyKeyless(name) {
return 0
}
switch name {
case "eval", "evalsha", "eval_ro", "evalsha_ro":
if cmd.stringArg(2) != "0" {
return 3
}
return 0
case "memory":
// https://github.com/redis/redis/issues/7493
if cmd.stringArg(1) == "usage" {
return 2
}
case "msetex":
// MSetEX's constructor already sets this via SetFirstKeyPos; this
// fallback only covers raw Do("msetex", ...) calls, which aren't
// guaranteed to route correctly and aren't the recommended usage.
return 2
}
// Use CommandInfo cache when warm (in-memory only, no extra round-trips).
if info != nil {
return int(info.FirstKeyPos)
}
return 1
}
func cmdString(cmd Cmder, val interface{}) string {
b := make([]byte, 0, 64)
for i, arg := range cmd.Args() {
if i > 0 {
b = append(b, ' ')
}
b = internal.AppendArg(b, arg)
}
if err := cmd.rawErr(); err != nil {
b = append(b, ": "...)
b = append(b, err.Error()...)
} else if val != nil {
b = append(b, ": "...)
b = internal.AppendArg(b, val)
}
return util.BytesToString(b)
}
//------------------------------------------------------------------------------
type baseCmd struct {
ctx context.Context
args []interface{}
err error
keyPos int8
_stepCount int8
rawVal interface{}
_readTimeout *time.Duration
cmdType CmdType
// slotCache memoizes the cluster slot once computed, so the cluster
// autopipeline shard router and the pipeline flush router don't each
// recompute it. 0 = not computed; it stores slot+1 so a real slot of 0 is
// distinguishable from unset. A plain field is safe by construction: it
// is written at most once, on the submitting goroutine BEFORE the command
// is published to a stripe queue (the stripe mutex is the happens-before
// edge to the flusher that later reads it). Do not write it from any
// other point in the command's life.
slotCache uint16
// ready, when non-nil, is the batch whose done channel closes once the
// command has executed. It is set only by the deferred (async)
// autopipeliner, which hands the command back to the caller before it
// runs. The public result accessors (Err/Val/Result/String) call await
// so they transparently block until execution; internal execution-path
// reads use rawErr to avoid awaiting the very batch they are producing
// (formatting included: cmdString reads rawErr and receives the value
// snapshot from its caller, so String methods await BEFORE reading their
// val field — otherwise formatting an in-flight async command would race
// with reply processing). ready stays
// nil for ordinary synchronous commands, whose accessors never block.
ready atomic.Pointer[apBatch]
}
// setReady publishes the batch gating this command's result accessors. The
// field is atomic, NOT lock-ordered with the enqueue: a dispatch-side hook
// racing this store simply reads nil and takes the non-blocking path — the
// correct "not executed yet" view — while the setting goroutine always sees
// its own store before it awaits.
func (cmd *baseCmd) setReady(b *apBatch) { cmd.ready.Store(b) }
// await blocks until an asynchronously-submitted command has executed. It is a
// single nil-pointer load for synchronous commands, so the common path stays
// allocation- and contention-free.
func (cmd *baseCmd) await() {
b := cmd.ready.Load()
if b == nil {
return
}
select {
case <-b.done:
return
default:
}
if b.isExecutorGoroutine() {
// A hook on one of the batch's own executor goroutines (the
// dispatcher, or a cluster per-node executor) is reading this
// command's result BEFORE next() has executed it. Blocking would
// self-deadlock (the batch completes only after that goroutine
// returns); return the not-yet-executed state instead — the same
// view a plain pipeline hook has before next().
return
}
<-b.done
}
// rawErr returns the command error WITHOUT awaiting. The internal
// execution/serialization path (setCmdsErr, cmdsFirstErr, and the cmdString
// formatter — public String methods await before calling it) uses it so that
// reading errors while a batch is being executed does not deadlock on the
// batch's own completion signal.
func (cmd *baseCmd) rawErr() error { return cmd.err }
// readyBatch exposes the deferred-face batch gating this command (nil for
// synchronous commands) to the cluster fan-out, which registers its per-node
// goroutines as executors of every batch they carry.
func (cmd *baseCmd) readyBatch() *apBatch { return cmd.ready.Load() }
// resultReady reports whether the command's result can be read WITHOUT
// blocking: either it never rode the deferred autopipeline face (no gating
// batch) or that batch has already completed. Post-execution bookkeeping in
// the command wrappers — the OTel metric emissions — consults it so that
// enabling telemetry cannot turn a deferred submission into a blocking call.
func (cmd *baseCmd) resultReady() bool {
b := cmd.ready.Load()
if b == nil {
return true
}
select {
case <-b.done:
return true
default:
return false
}
}
var _ Cmder = (*Cmd)(nil)
func (cmd *baseCmd) Name() string {
if len(cmd.args) == 0 {
return ""
}
// Cmd name must be lower cased.
return internal.ToLower(cmd.stringArg(0))
}
func (cmd *baseCmd) FullName() string {
switch name := cmd.Name(); name {
case "cluster", "command":
if len(cmd.args) == 1 {
return name
}
if s2, ok := cmd.args[1].(string); ok {
return name + " " + s2
}
return name
default:
return name
}
}
func (cmd *baseCmd) Args() []interface{} {
return cmd.args
}
func (cmd *baseCmd) stringArg(pos int) string {
if pos < 0 || pos >= len(cmd.args) {
return ""
}
arg := cmd.args[pos]
switch v := arg.(type) {
case string:
return v
case *string:
if v == nil {
return ""
}
return *v
case []byte:
return string(v)
default:
// TODO: consider using appendArg
return fmt.Sprint(v)
}
}
func (cmd *baseCmd) firstKeyPos() int8 {
return cmd.keyPos
}
func (cmd *baseCmd) SetFirstKeyPos(keyPos int8) {
cmd.keyPos = keyPos
}
// cachedSlot returns the cached cluster slot and whether one was set.
func (cmd *baseCmd) cachedSlot() (int, bool) {
if cmd.slotCache == 0 {
return 0, false
}
return int(cmd.slotCache - 1), true
}
// setCachedSlot stores the computed cluster slot (0..16383) for reuse.
func (cmd *baseCmd) setCachedSlot(slot int) {
if slot >= 0 && slot < 16384 {
cmd.slotCache = uint16(slot + 1)
}
}
func (cmd *baseCmd) stepCount() int8 {
return cmd._stepCount
}
func (cmd *baseCmd) SetStepCount(stepCount int8) {
cmd._stepCount = stepCount
}
func (cmd *baseCmd) SetErr(e error) {
cmd.err = e
}
func (cmd *baseCmd) Err() error {
cmd.await()
return cmd.err
}
func (cmd *baseCmd) readTimeout() *time.Duration {
return cmd._readTimeout
}
func (cmd *baseCmd) setReadTimeout(d time.Duration) {
cmd._readTimeout = &d
}
func (cmd *baseCmd) readRawReply(rd *proto.Reader) (err error) {
cmd.rawVal, err = rd.ReadReply()
return err
}
// NoRetry returns true if the command should not be retried on failure.
// By default, commands can be retried. Commands that write directly to an
// io.Writer (like RawWriteToCmd) should override this to return true since
// partial writes cannot be undone on retry.
func (cmd *baseCmd) NoRetry() bool {
return false
}
func (cmd *baseCmd) GetCmdType() CmdType {
return cmd.cmdType
}
func (cmd *baseCmd) cloneBaseCmd() baseCmd {
var readTimeout *time.Duration
if cmd._readTimeout != nil {
timeout := *cmd._readTimeout
readTimeout = &timeout
}
// Create a copy of args slice
args := make([]interface{}, len(cmd.args))
copy(args, cmd.args)
return baseCmd{
ctx: cmd.ctx,
args: args,
err: cmd.err,
keyPos: cmd.keyPos,
_stepCount: cmd._stepCount,
rawVal: cmd.rawVal,
_readTimeout: readTimeout,
cmdType: cmd.cmdType,
}
}
//------------------------------------------------------------------------------
type Cmd struct {
baseCmd
val interface{}
}
func NewCmd(ctx context.Context, args ...interface{}) *Cmd {
return &Cmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeGeneric,
},
}
}
func (cmd *Cmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *Cmd) SetVal(val interface{}) {
cmd.val = val
}
func (cmd *Cmd) Val() interface{} {
cmd.await()
return cmd.val
}
func (cmd *Cmd) Result() (interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *Cmd) Text() (string, error) {
cmd.await()
if cmd.err != nil {
return "", cmd.err
}
return toString(cmd.val)
}
func toString(val interface{}) (string, error) {
switch val := val.(type) {
case string:
return val, nil
default:
err := fmt.Errorf("redis: unexpected type=%T for String", val)
return "", err
}
}
func (cmd *Cmd) Int() (int, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
switch val := cmd.val.(type) {
case int64:
return int(val), nil
case string:
return strconv.Atoi(val)
default:
err := fmt.Errorf("redis: unexpected type=%T for Int", val)
return 0, err
}
}
func (cmd *Cmd) Int64() (int64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return toInt64(cmd.val)
}
func toInt64(val interface{}) (int64, error) {
switch val := val.(type) {
case int64:
return val, nil
case string:
return strconv.ParseInt(val, 10, 64)
default:
err := fmt.Errorf("redis: unexpected type=%T for Int64", val)
return 0, err
}
}
func (cmd *Cmd) Uint64() (uint64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return toUint64(cmd.val)
}
func toUint64(val interface{}) (uint64, error) {
switch val := val.(type) {
case int64:
return uint64(val), nil
case string:
return strconv.ParseUint(val, 10, 64)
default:
err := fmt.Errorf("redis: unexpected type=%T for Uint64", val)
return 0, err
}
}
func (cmd *Cmd) Float32() (float32, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return toFloat32(cmd.val)
}
func toFloat32(val interface{}) (float32, error) {
switch val := val.(type) {
case int64:
return float32(val), nil
case string:
f, err := strconv.ParseFloat(val, 32)
if err != nil {
return 0, err
}
return float32(f), nil
default:
err := fmt.Errorf("redis: unexpected type=%T for Float32", val)
return 0, err
}
}
func (cmd *Cmd) Float64() (float64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return toFloat64(cmd.val)
}
func toFloat64(val interface{}) (float64, error) {
switch val := val.(type) {
case int64:
return float64(val), nil
case string:
return strconv.ParseFloat(val, 64)
default:
err := fmt.Errorf("redis: unexpected type=%T for Float64", val)
return 0, err
}
}
func (cmd *Cmd) Bool() (bool, error) {
cmd.await()
if cmd.err != nil {
return false, cmd.err
}
return toBool(cmd.val)
}
func toBool(val interface{}) (bool, error) {
switch val := val.(type) {
case bool:
return val, nil
case int64:
return val != 0, nil
case string:
return strconv.ParseBool(val)
default:
err := fmt.Errorf("redis: unexpected type=%T for Bool", val)
return false, err
}
}
func (cmd *Cmd) Slice() ([]interface{}, error) {
cmd.await()
if cmd.err != nil {
return nil, cmd.err
}
switch val := cmd.val.(type) {
case []interface{}:
return val, nil
default:
return nil, fmt.Errorf("redis: unexpected type=%T for Slice", val)
}
}
func (cmd *Cmd) StringSlice() ([]string, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
ss := make([]string, len(slice))
for i, iface := range slice {
val, err := toString(iface)
if err != nil {
return nil, err
}
ss[i] = val
}
return ss, nil
}
func (cmd *Cmd) Int64Slice() ([]int64, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
nums := make([]int64, len(slice))
for i, iface := range slice {
val, err := toInt64(iface)
if err != nil {
return nil, err
}
nums[i] = val
}
return nums, nil
}
func (cmd *Cmd) Uint64Slice() ([]uint64, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
nums := make([]uint64, len(slice))
for i, iface := range slice {
val, err := toUint64(iface)
if err != nil {
return nil, err
}
nums[i] = val
}
return nums, nil
}
func (cmd *Cmd) Float32Slice() ([]float32, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
floats := make([]float32, len(slice))
for i, iface := range slice {
val, err := toFloat32(iface)
if err != nil {
return nil, err
}
floats[i] = val
}
return floats, nil
}
func (cmd *Cmd) Float64Slice() ([]float64, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
floats := make([]float64, len(slice))
for i, iface := range slice {
val, err := toFloat64(iface)
if err != nil {
return nil, err
}
floats[i] = val
}
return floats, nil
}
func (cmd *Cmd) BoolSlice() ([]bool, error) {
slice, err := cmd.Slice()
if err != nil {
return nil, err
}
bools := make([]bool, len(slice))
for i, iface := range slice {
val, err := toBool(iface)
if err != nil {
return nil, err
}
bools[i] = val
}
return bools, nil
}
func (cmd *Cmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadReply()
return err
}
func (cmd *Cmd) Clone() Cmder {
return &Cmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
// RawCmd returns raw RESP protocol bytes without parsing.
type RawCmd struct {
baseCmd
val []byte
}
var _ Cmder = (*RawCmd)(nil)
func NewRawCmd(ctx context.Context, args ...interface{}) *RawCmd {
return &RawCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeGeneric,
},
}
}
func (cmd *RawCmd) SetVal(val []byte) {
cmd.val = val
}
func (cmd *RawCmd) Val() []byte {
cmd.await()
return cmd.val
}
func (cmd *RawCmd) Result() ([]byte, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *RawCmd) Bytes() ([]byte, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *RawCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *RawCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadRawReply()
return err
}
func (cmd *RawCmd) Clone() Cmder {
var val []byte
if cmd.val != nil {
val = make([]byte, len(cmd.val))
copy(val, cmd.val)
}
return &RawCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
// RawWriteToCmd streams raw RESP protocol bytes directly to an io.Writer without intermediate allocations.
type RawWriteToCmd struct {
baseCmd
w io.Writer
written int64
}
var _ Cmder = (*RawWriteToCmd)(nil)
func NewRawWriteToCmd(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd {
return &RawWriteToCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeGeneric,
},
w: w,
}
}
func (cmd *RawWriteToCmd) SetVal(written int64) {
cmd.written = written
}
func (cmd *RawWriteToCmd) Val() int64 {
cmd.await()
return cmd.written
}
func (cmd *RawWriteToCmd) Result() (int64, error) {
cmd.await()
return cmd.written, cmd.err
}
func (cmd *RawWriteToCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.written)
}
func (cmd *RawWriteToCmd) readReply(rd *proto.Reader) (err error) {
cmd.written, err = rd.ReadRawReplyWriteTo(cmd.w)
return err
}
// NoRetry returns true because RawWriteToCmd writes directly to an io.Writer.
// If a retry occurs, partial data from failed attempts would be appended to
// the writer, causing data corruption. The caller must handle retries manually
// if needed, using a fresh writer for each attempt.
func (cmd *RawWriteToCmd) NoRetry() bool {
return true
}
func (cmd *RawWriteToCmd) Clone() Cmder {
return &RawWriteToCmd{
baseCmd: cmd.cloneBaseCmd(),
w: cmd.w,
written: cmd.written,
}
}
//------------------------------------------------------------------------------
// ZeroCopyStringCmd reads a bulk string response directly into a user-provided
// buffer, avoiding intermediate allocations. The RESP header is parsed through
// the buffered reader, then bulk data is read straight into the caller's buffer
// via proto.Reader.ReadStringInto — for values larger than the bufio buffer,
// this is effectively zero-copy from the socket to the user buffer.
//
// The buffer must be sized to fit the value; if it is too small an error is
// returned and the payload plus trailing CRLF are drained from the reader so
// the connection stays aligned for subsequent commands.
type ZeroCopyStringCmd struct {
baseCmd
buf []byte // user-provided buffer to read into
n int // number of bytes read into buf
cloned bool // set by Clone(); causes readReply to drain + error
}
var _ Cmder = (*ZeroCopyStringCmd)(nil)
func NewZeroCopyStringCmd(ctx context.Context, buf []byte, args ...interface{}) *ZeroCopyStringCmd {
return &ZeroCopyStringCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeString,
},
buf: buf,
}
}
func (cmd *ZeroCopyStringCmd) SetVal(n int) {
cmd.n = n
}
func (cmd *ZeroCopyStringCmd) Val() int {
cmd.await()
return cmd.n
}
// Result returns the number of bytes read and any error.
func (cmd *ZeroCopyStringCmd) Result() (int, error) {
cmd.await()
return cmd.n, cmd.err
}
// Bytes returns the slice of the user-provided buffer containing the read data.
func (cmd *ZeroCopyStringCmd) Bytes() []byte {
cmd.await()
return cmd.buf[:cmd.n]
}
func (cmd *ZeroCopyStringCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.n)
}
func (cmd *ZeroCopyStringCmd) readReply(rd *proto.Reader) error {
// Reset the byte count before reading so a previous successful run
// can't leak its data through Bytes() if this call errors out before
// updating cmd.n.
cmd.n = 0
if cmd.cloned {
// A cloned ZeroCopyStringCmd has no usable destination buffer
// (see Clone for the rationale). Drain the network reply so the
// connection stays aligned for the next command, then surface a
// clear error rather than silently producing a wrong result.
if err := rd.DiscardNext(); err != nil {
return err
}
return fmt.Errorf("redis: ZeroCopyStringCmd cannot be cloned (cmd writes into caller-owned memory)")
}
n, err := rd.ReadStringInto(cmd.buf)
if err != nil {
return err
}
cmd.n = n
return nil
}
// NoRetry returns true because the response is written directly into the
// caller's buffer. A retry could leave partial data from a failed attempt in
// the buffer, so the caller must handle retries explicitly if needed.
func (cmd *ZeroCopyStringCmd) NoRetry() bool {
return true
}
// Clone returns a clone that is intentionally non-functional. Cloning a
// ZeroCopyStringCmd has no well-defined semantics: the cmd writes into
// caller-owned memory (the buf passed to GetToBuffer), and a clone can
// neither safely share that buf (concurrent writes from sibling clones
// would race, last-writer wins) nor allocate its own buf (the result
// would be invisible to whoever asked for the original cmd's reply).
//
// The Cmder interface requires Clone, so we return a clone marked so
// that its readReply drains the network reply (keeping the connection
// aligned) and fails the cmd with a clear error. Whoever processes the
// clone gets an explicit error instead of silently-wrong bytes.
//
// In practice this path is unreachable through normal client flows:
// Clone is only called from cluster fan-out routing
// (osscluster_router.go) for multi-shard commands like DBSIZE / KEYS /
// FLUSHDB, and ZeroCopyStringCmd is only produced by GetToBuffer which
// issues GET — a single-key command routed to one shard, never fanned
// out. Combined with NoRetry() returning true, the retry path also will
// not clone this cmd.
func (cmd *ZeroCopyStringCmd) Clone() Cmder {
return &ZeroCopyStringCmd{
baseCmd: cmd.cloneBaseCmd(),
cloned: true,
}
}
//------------------------------------------------------------------------------
type SliceCmd struct {
baseCmd
val []interface{}
}
var _ Cmder = (*SliceCmd)(nil)
func NewSliceCmd(ctx context.Context, args ...interface{}) *SliceCmd {
return &SliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeSlice,
},
}
}
func (cmd *SliceCmd) SetVal(val []interface{}) {
cmd.val = val
}
func (cmd *SliceCmd) Val() []interface{} {
cmd.await()
return cmd.val
}
func (cmd *SliceCmd) Result() ([]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *SliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
// Scan scans the results from the map into a destination struct. The map keys
// are matched in the Redis struct fields by the `redis:"field"` tag.
func (cmd *SliceCmd) Scan(dst interface{}) error {
cmd.await()
if cmd.err != nil {
return cmd.err
}
// Pass the list of keys and values.
// Skip the first two args for: HMGET key
var args []interface{}
if cmd.args[0] == "hmget" {
args = cmd.args[2:]
} else {
// Otherwise, it's: MGET field field ...
args = cmd.args[1:]
}
return hscan.Scan(dst, args, cmd.val)
}
func (cmd *SliceCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadSlice()
return err
}
func (cmd *SliceCmd) Clone() Cmder {
var val []interface{}
if cmd.val != nil {
val = make([]interface{}, len(cmd.val))
copy(val, cmd.val)
}
return &SliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type StatusCmd struct {
baseCmd
val string
}
var _ Cmder = (*StatusCmd)(nil)
func NewStatusCmd(ctx context.Context, args ...interface{}) *StatusCmd {
return &StatusCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStatus,
},
}
}
func (cmd *StatusCmd) SetVal(val string) {
cmd.val = val
}
func (cmd *StatusCmd) Val() string {
cmd.await()
return cmd.val
}
func (cmd *StatusCmd) Result() (string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *StatusCmd) Bytes() ([]byte, error) {
cmd.await()
return util.StringToBytes(cmd.val), cmd.err
}
func (cmd *StatusCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *StatusCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadString()
return err
}
func (cmd *StatusCmd) Clone() Cmder {
return &StatusCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
type IntCmd struct {
baseCmd
val int64
}
var _ Cmder = (*IntCmd)(nil)
func NewIntCmd(ctx context.Context, args ...interface{}) *IntCmd {
return &IntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeInt,
},
}
}
func (cmd *IntCmd) SetVal(val int64) {
cmd.val = val
}
func (cmd *IntCmd) Val() int64 {
cmd.await()
return cmd.val
}
func (cmd *IntCmd) Result() (int64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *IntCmd) Uint64() (uint64, error) {
cmd.await()
return uint64(cmd.val), cmd.err
}
func (cmd *IntCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *IntCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadInt()
return err
}
func (cmd *IntCmd) Clone() Cmder {
return &IntCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
type UintCmd struct {
baseCmd
val uint64
}
var _ Cmder = (*UintCmd)(nil)
func NewUintCmd(ctx context.Context, args ...any) *UintCmd {
return &UintCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeUint,
},
}
}
func (cmd *UintCmd) SetVal(val uint64) {
cmd.val = val
}
func (cmd *UintCmd) Val() uint64 {
cmd.await()
return cmd.val
}
func (cmd *UintCmd) Result() (uint64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *UintCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *UintCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadUint()
return err
}
func (cmd *UintCmd) Clone() Cmder {
return &UintCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
// DigestCmd is a command that returns a uint64 xxh3 hash digest.
//
// This command is specifically designed for the Redis DIGEST command,
// which returns the xxh3 hash of a key's value as a hex string.
// The hex string is automatically parsed to a uint64 value.
//
// The digest can be used for optimistic locking with SetIFDEQ, SetIFDNE,
// and DelExArgs commands.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/digest/
type DigestCmd struct {
baseCmd
val uint64
}
var _ Cmder = (*DigestCmd)(nil)
func NewDigestCmd(ctx context.Context, args ...interface{}) *DigestCmd {
return &DigestCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *DigestCmd) SetVal(val uint64) {
cmd.val = val
}
func (cmd *DigestCmd) Val() uint64 {
cmd.await()
return cmd.val
}
func (cmd *DigestCmd) Result() (uint64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *DigestCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *DigestCmd) Clone() Cmder {
return &DigestCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
func (cmd *DigestCmd) readReply(rd *proto.Reader) (err error) {
// Redis DIGEST command returns a hex string (e.g., "a1b2c3d4e5f67890")
// We parse it as a uint64 xxh3 hash value
var hexStr string
hexStr, err = rd.ReadString()
if err != nil {
return err
}
// Parse hex string to uint64
cmd.val, err = strconv.ParseUint(hexStr, 16, 64)
return err
}
//------------------------------------------------------------------------------
type IntSliceCmd struct {
baseCmd
val []int64
}
var _ Cmder = (*IntSliceCmd)(nil)
func NewIntSliceCmd(ctx context.Context, args ...interface{}) *IntSliceCmd {
return &IntSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIntSlice,
},
}
}
func (cmd *IntSliceCmd) SetVal(val []int64) {
cmd.val = val
}
func (cmd *IntSliceCmd) Val() []int64 {
cmd.await()
return cmd.val
}
func (cmd *IntSliceCmd) Result() ([]int64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *IntSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *IntSliceCmd) 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++ {
switch num, err := rd.ReadInt(); {
case err == Nil:
cmd.val[i] = 0
case err != nil:
return err
default:
cmd.val[i] = num
}
}
return nil
}
func (cmd *IntSliceCmd) Clone() Cmder {
var val []int64
if cmd.val != nil {
val = make([]int64, len(cmd.val))
copy(val, cmd.val)
}
return &IntSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
type UintSliceCmd struct {
baseCmd
val []uint64
}
var _ Cmder = (*UintSliceCmd)(nil)
func NewUintSliceCmd(ctx context.Context, args ...any) *UintSliceCmd {
return &UintSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeUintSlice,
},
}
}
func (cmd *UintSliceCmd) SetVal(val []uint64) {
cmd.val = val
}
func (cmd *UintSliceCmd) Val() []uint64 {
cmd.await()
return cmd.val
}
func (cmd *UintSliceCmd) Result() ([]uint64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *UintSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *UintSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]uint64, n)
for i := range cmd.val {
switch num, err := rd.ReadUint(); {
case err == Nil:
cmd.val[i] = 0
case err != nil:
return err
default:
cmd.val[i] = num
}
}
return nil
}
func (cmd *UintSliceCmd) Clone() Cmder {
var val []uint64
if cmd.val != nil {
val = make([]uint64, len(cmd.val))
copy(val, cmd.val)
}
return &UintSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type DurationCmd struct {
baseCmd
val time.Duration
precision time.Duration
}
var _ Cmder = (*DurationCmd)(nil)
func NewDurationCmd(ctx context.Context, precision time.Duration, args ...interface{}) *DurationCmd {
return &DurationCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeDuration,
},
precision: precision,
}
}
func (cmd *DurationCmd) SetVal(val time.Duration) {
cmd.val = val
}
func (cmd *DurationCmd) Val() time.Duration {
cmd.await()
return cmd.val
}
func (cmd *DurationCmd) Result() (time.Duration, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *DurationCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *DurationCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadInt()
if err != nil {
return err
}
switch n {
// -2 if the key does not exist
// -1 if the key exists but has no associated expire
case -2, -1:
cmd.val = time.Duration(n)
default:
cmd.val = time.Duration(n) * cmd.precision
}
return nil
}
func (cmd *DurationCmd) Clone() Cmder {
return &DurationCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
precision: cmd.precision,
}
}
//------------------------------------------------------------------------------
type TimeCmd struct {
baseCmd
val time.Time
}
var _ Cmder = (*TimeCmd)(nil)
func NewTimeCmd(ctx context.Context, args ...interface{}) *TimeCmd {
return &TimeCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTime,
},
}
}
func (cmd *TimeCmd) SetVal(val time.Time) {
cmd.val = val
}
func (cmd *TimeCmd) Val() time.Time {
cmd.await()
return cmd.val
}
func (cmd *TimeCmd) Result() (time.Time, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TimeCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TimeCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
second, err := rd.ReadInt()
if err != nil {
return err
}
microsecond, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val = time.Unix(second, microsecond*1000)
return nil
}
func (cmd *TimeCmd) Clone() Cmder {
return &TimeCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
type BoolCmd struct {
baseCmd
val bool
}
var _ Cmder = (*BoolCmd)(nil)
func NewBoolCmd(ctx context.Context, args ...interface{}) *BoolCmd {
return &BoolCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeBool,
},
}
}
func (cmd *BoolCmd) SetVal(val bool) {
cmd.val = val
}
func (cmd *BoolCmd) Val() bool {
cmd.await()
return cmd.val
}
func (cmd *BoolCmd) Result() (bool, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *BoolCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *BoolCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadBool()
// `SET key value NX` returns nil when key already exists. But
// `SETNX key value` returns bool (0/1). So convert nil to bool.
if err == Nil {
cmd.val = false
err = nil
}
return err
}
func (cmd *BoolCmd) Clone() Cmder {
return &BoolCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
type StringCmd struct {
baseCmd
val string
}
var _ Cmder = (*StringCmd)(nil)
func NewStringCmd(ctx context.Context, args ...interface{}) *StringCmd {
return &StringCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeString,
},
}
}
func (cmd *StringCmd) SetVal(val string) {
cmd.val = val
}
func (cmd *StringCmd) Val() string {
cmd.await()
return cmd.val
}
func (cmd *StringCmd) Result() (string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *StringCmd) Bytes() ([]byte, error) {
cmd.await()
return util.StringToBytes(cmd.val), cmd.err
}
func (cmd *StringCmd) Bool() (bool, error) {
cmd.await()
if cmd.err != nil {
return false, cmd.err
}
return strconv.ParseBool(cmd.val)
}
func (cmd *StringCmd) Int() (int, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return strconv.Atoi(cmd.val)
}
func (cmd *StringCmd) Int64() (int64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return strconv.ParseInt(cmd.val, 10, 64)
}
func (cmd *StringCmd) Uint64() (uint64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return strconv.ParseUint(cmd.val, 10, 64)
}
func (cmd *StringCmd) Float32() (float32, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
f, err := strconv.ParseFloat(cmd.val, 32)
if err != nil {
return 0, err
}
return float32(f), nil
}
func (cmd *StringCmd) Float64() (float64, error) {
cmd.await()
if cmd.err != nil {
return 0, cmd.err
}
return strconv.ParseFloat(cmd.val, 64)
}
func (cmd *StringCmd) Time() (time.Time, error) {
cmd.await()
if cmd.err != nil {
return time.Time{}, cmd.err
}
return time.Parse(time.RFC3339Nano, cmd.val)
}
func (cmd *StringCmd) Scan(val interface{}) error {
cmd.await()
if cmd.err != nil {
return cmd.err
}
return proto.Scan(util.StringToBytes(cmd.val), val)
}
func (cmd *StringCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *StringCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadString()
return err
}
func (cmd *StringCmd) Clone() Cmder {
return &StringCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
type FloatCmd struct {
baseCmd
val float64
}
var _ Cmder = (*FloatCmd)(nil)
func NewFloatCmd(ctx context.Context, args ...interface{}) *FloatCmd {
return &FloatCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFloat,
},
}
}
func (cmd *FloatCmd) SetVal(val float64) {
cmd.val = val
}
func (cmd *FloatCmd) Val() float64 {
cmd.await()
return cmd.val
}
func (cmd *FloatCmd) Result() (float64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FloatCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FloatCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = rd.ReadFloat()
return err
}
func (cmd *FloatCmd) Clone() Cmder {
return &FloatCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
type FloatSliceCmd struct {
baseCmd
val []float64
}
var _ Cmder = (*FloatSliceCmd)(nil)
func NewFloatSliceCmd(ctx context.Context, args ...interface{}) *FloatSliceCmd {
return &FloatSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFloatSlice,
},
}
}
func (cmd *FloatSliceCmd) SetVal(val []float64) {
cmd.val = val
}
func (cmd *FloatSliceCmd) Val() []float64 {
cmd.await()
return cmd.val
}
func (cmd *FloatSliceCmd) Result() ([]float64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FloatSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FloatSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]float64, n)
for i := 0; i < len(cmd.val); i++ {
switch num, err := rd.ReadFloat(); {
case err == Nil:
cmd.val[i] = 0
case err != nil:
return err
default:
cmd.val[i] = num
}
}
return nil
}
func (cmd *FloatSliceCmd) Clone() Cmder {
var val []float64
if cmd.val != nil {
val = make([]float64, len(cmd.val))
copy(val, cmd.val)
}
return &FloatSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type StringSliceCmd struct {
baseCmd
val []string
}
var _ Cmder = (*StringSliceCmd)(nil)
func NewStringSliceCmd(ctx context.Context, args ...interface{}) *StringSliceCmd {
return &StringSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStringSlice,
},
}
}
func (cmd *StringSliceCmd) SetVal(val []string) {
cmd.val = val
}
func (cmd *StringSliceCmd) Val() []string {
cmd.await()
return cmd.val
}
func (cmd *StringSliceCmd) Result() ([]string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *StringSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *StringSliceCmd) ScanSlice(container interface{}) error {
cmd.await()
return proto.ScanSlice(cmd.val, container)
}
func (cmd *StringSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]string, 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 *StringSliceCmd) Clone() Cmder {
var val []string
if cmd.val != nil {
val = make([]string, len(cmd.val))
copy(val, cmd.val)
}
return &StringSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
// StringSliceSliceCmd returns a slice of string slices ([][]string).
// This is used for commands like VLINKS that return an array of arrays.
type StringSliceSliceCmd struct {
baseCmd
val [][]string
}
var _ Cmder = (*StringSliceSliceCmd)(nil)
func NewStringSliceSliceCmd(ctx context.Context, args ...any) *StringSliceSliceCmd {
return &StringSliceSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *StringSliceSliceCmd) SetVal(val [][]string) {
cmd.val = val
}
func (cmd *StringSliceSliceCmd) Val() [][]string {
cmd.await()
return cmd.val
}
func (cmd *StringSliceSliceCmd) Result() ([][]string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *StringSliceSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *StringSliceSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([][]string, n)
for i := range n {
// Read inner array
innerN, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val[i] = make([]string, innerN)
for j := range innerN {
switch s, err := rd.ReadString(); {
case err == Nil:
cmd.val[i][j] = ""
case err != nil:
return err
default:
cmd.val[i][j] = s
}
}
}
return nil
}
func (cmd *StringSliceSliceCmd) Clone() Cmder {
var val [][]string
if cmd.val != nil {
val = make([][]string, len(cmd.val))
for i, slice := range cmd.val {
if slice != nil {
val[i] = make([]string, len(slice))
copy(val[i], slice)
}
}
}
return &StringSliceSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type KeyValue struct {
Key string
Value string
}
type KeyValueSliceCmd struct {
baseCmd
val []KeyValue
}
var _ Cmder = (*KeyValueSliceCmd)(nil)
func NewKeyValueSliceCmd(ctx context.Context, args ...interface{}) *KeyValueSliceCmd {
return &KeyValueSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeKeyValueSlice,
},
}
}
func (cmd *KeyValueSliceCmd) SetVal(val []KeyValue) {
cmd.val = val
}
func (cmd *KeyValueSliceCmd) Val() []KeyValue {
cmd.await()
return cmd.val
}
func (cmd *KeyValueSliceCmd) Result() ([]KeyValue, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *KeyValueSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
// Many commands will respond to two formats:
// 1. 1) "one"
// 2. (double) 1
// 2. 1) "two"
// 2. (double) 2
//
// OR:
// 1. "two"
// 2. (double) 2
// 3. "one"
// 4. (double) 1
func (cmd *KeyValueSliceCmd) readReply(rd *proto.Reader) error { // nolint:dupl
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
// If the n is 0, can't continue reading.
if n == 0 {
cmd.val = make([]KeyValue, 0)
return nil
}
typ, err := rd.PeekReplyType()
if err != nil {
return err
}
array := typ == proto.RespArray
if array {
cmd.val = make([]KeyValue, n)
} else {
if n%2 != 0 {
return fmt.Errorf("redis: got %d elements in the key-value array, wanted a multiple of 2", n)
}
cmd.val = make([]KeyValue, n/2)
}
for i := 0; i < len(cmd.val); i++ {
if array {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
}
if cmd.val[i].Key, err = rd.ReadString(); err != nil {
return err
}
if cmd.val[i].Value, err = rd.ReadString(); err != nil {
return err
}
}
return nil
}
func (cmd *KeyValueSliceCmd) Clone() Cmder {
var val []KeyValue
if cmd.val != nil {
val = make([]KeyValue, len(cmd.val))
copy(val, cmd.val)
}
return &KeyValueSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type BoolSliceCmd struct {
baseCmd
val []bool
}
var _ Cmder = (*BoolSliceCmd)(nil)
func NewBoolSliceCmd(ctx context.Context, args ...interface{}) *BoolSliceCmd {
return &BoolSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeBoolSlice,
},
}
}
func (cmd *BoolSliceCmd) SetVal(val []bool) {
cmd.val = val
}
func (cmd *BoolSliceCmd) Val() []bool {
cmd.await()
return cmd.val
}
func (cmd *BoolSliceCmd) Result() ([]bool, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *BoolSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *BoolSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]bool, n)
for i := 0; i < len(cmd.val); i++ {
switch b, err := rd.ReadBool(); {
case err == Nil:
cmd.val[i] = false
case err != nil:
return err
default:
cmd.val[i] = b
}
}
return nil
}
func (cmd *BoolSliceCmd) Clone() Cmder {
var val []bool
if cmd.val != nil {
val = make([]bool, len(cmd.val))
copy(val, cmd.val)
}
return &BoolSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type MapStringStringCmd struct {
baseCmd
val map[string]string
}
var _ Cmder = (*MapStringStringCmd)(nil)
func NewMapStringStringCmd(ctx context.Context, args ...interface{}) *MapStringStringCmd {
return &MapStringStringCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringString,
},
}
}
func (cmd *MapStringStringCmd) Val() map[string]string {
cmd.await()
return cmd.val
}
func (cmd *MapStringStringCmd) SetVal(val map[string]string) {
cmd.val = val
}
func (cmd *MapStringStringCmd) Result() (map[string]string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringStringCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
// Scan scans the results from the map into a destination struct. The map keys
// are matched in the Redis struct fields by the `redis:"field"` tag.
func (cmd *MapStringStringCmd) Scan(dest interface{}) error {
cmd.await()
if cmd.err != nil {
return cmd.err
}
strct, err := hscan.Struct(dest)
if err != nil {
return err
}
for k, v := range cmd.val {
if err := strct.Scan(k, v); err != nil {
return err
}
}
return nil
}
func (cmd *MapStringStringCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = make(map[string]string, n)
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
value, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[key] = value
}
return nil
}
func (cmd *MapStringStringCmd) Clone() Cmder {
var val map[string]string
if cmd.val != nil {
val = make(map[string]string, len(cmd.val))
for k, v := range cmd.val {
val[k] = v
}
}
return &MapStringStringCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type MapStringIntCmd struct {
baseCmd
val map[string]int64
}
var _ Cmder = (*MapStringIntCmd)(nil)
func NewMapStringIntCmd(ctx context.Context, args ...interface{}) *MapStringIntCmd {
return &MapStringIntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringInt,
},
}
}
func (cmd *MapStringIntCmd) SetVal(val map[string]int64) {
cmd.val = val
}
func (cmd *MapStringIntCmd) Val() map[string]int64 {
cmd.await()
return cmd.val
}
func (cmd *MapStringIntCmd) Result() (map[string]int64, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringIntCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapStringIntCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = make(map[string]int64, n)
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
nn, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[key] = nn
}
return nil
}
func (cmd *MapStringIntCmd) Clone() Cmder {
var val map[string]int64
if cmd.val != nil {
val = make(map[string]int64, len(cmd.val))
for k, v := range cmd.val {
val[k] = v
}
}
return &MapStringIntCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// ------------------------------------------------------------------------------
type MapStringSliceInterfaceCmd struct {
baseCmd
val map[string][]interface{}
}
func NewMapStringSliceInterfaceCmd(ctx context.Context, args ...interface{}) *MapStringSliceInterfaceCmd {
return &MapStringSliceInterfaceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringInterfaceSlice,
},
}
}
func (cmd *MapStringSliceInterfaceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapStringSliceInterfaceCmd) SetVal(val map[string][]interface{}) {
cmd.val = val
}
func (cmd *MapStringSliceInterfaceCmd) Result() (map[string][]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringSliceInterfaceCmd) Val() map[string][]interface{} {
cmd.await()
return cmd.val
}
func (cmd *MapStringSliceInterfaceCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
cmd.val = make(map[string][]interface{})
switch readType {
case proto.RespMap:
n, err := rd.ReadMapLen()
if err != nil {
return err
}
for i := 0; i < n; i++ {
k, err := rd.ReadString()
if err != nil {
return err
}
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val[k] = make([]interface{}, nn)
for j := 0; j < nn; j++ {
value, err := rd.ReadReply()
if err != nil {
return err
}
cmd.val[k][j] = value
}
}
case proto.RespArray:
// RESP2 response
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
for i := 0; i < n; i++ {
// Each entry in this array is itself an array with key details
itemLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
if itemLen < 1 {
return fmt.Errorf("redis: got %d elements in map-string-slice-interface entry, expected at least 1", itemLen)
}
key, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[key] = make([]interface{}, 0, itemLen-1)
for j := 1; j < itemLen; j++ {
// Read the inner array for timestamp-value pairs
data, err := rd.ReadReply()
if err != nil {
return err
}
cmd.val[key] = append(cmd.val[key], data)
}
}
default:
// Any other reply type leaves the peeked frame unread. Returning nil
// here would put the connection back in the pool with those bytes
// buffered, so the next command reads them as its own reply.
return fmt.Errorf("redis: can't parse map-string-slice-interface reply: unexpected type %c", readType)
}
return nil
}
func (cmd *MapStringSliceInterfaceCmd) Clone() Cmder {
var val map[string][]interface{}
if cmd.val != nil {
val = make(map[string][]interface{}, len(cmd.val))
for k, v := range cmd.val {
if v != nil {
newSlice := make([]interface{}, len(v))
copy(newSlice, v)
val[k] = newSlice
}
}
}
return &MapStringSliceInterfaceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type StringStructMapCmd struct {
baseCmd
val map[string]struct{}
}
var _ Cmder = (*StringStructMapCmd)(nil)
func NewStringStructMapCmd(ctx context.Context, args ...interface{}) *StringStructMapCmd {
return &StringStructMapCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStringStructMap,
},
}
}
func (cmd *StringStructMapCmd) SetVal(val map[string]struct{}) {
cmd.val = val
}
func (cmd *StringStructMapCmd) Val() map[string]struct{} {
cmd.await()
return cmd.val
}
func (cmd *StringStructMapCmd) Result() (map[string]struct{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *StringStructMapCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *StringStructMapCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make(map[string]struct{}, n)
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[key] = struct{}{}
}
return nil
}
func (cmd *StringStructMapCmd) Clone() Cmder {
var val map[string]struct{}
if cmd.val != nil {
val = maps.Clone(cmd.val)
}
return &StringStructMapCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XMessage struct {
ID string
Values map[string]interface{}
// MillisElapsedFromDelivery is the number of milliseconds since the entry was last delivered.
// Only populated when using XREADGROUP with CLAIM argument for claimed entries.
MillisElapsedFromDelivery int64
// DeliveredCount is the number of times the entry was delivered.
// Only populated when using XREADGROUP with CLAIM argument for claimed entries.
DeliveredCount int64
}
type XMessageSliceCmd struct {
baseCmd
val []XMessage
}
var _ Cmder = (*XMessageSliceCmd)(nil)
func NewXMessageSliceCmd(ctx context.Context, args ...interface{}) *XMessageSliceCmd {
return &XMessageSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXMessageSlice,
},
}
}
func (cmd *XMessageSliceCmd) SetVal(val []XMessage) {
cmd.val = val
}
func (cmd *XMessageSliceCmd) Val() []XMessage {
cmd.await()
return cmd.val
}
func (cmd *XMessageSliceCmd) Result() ([]XMessage, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XMessageSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XMessageSliceCmd) readReply(rd *proto.Reader) (err error) {
cmd.val, err = readXMessageSlice(rd)
return err
}
func (cmd *XMessageSliceCmd) Clone() Cmder {
var val []XMessage
if cmd.val != nil {
val = make([]XMessage, len(cmd.val))
for i, msg := range cmd.val {
val[i] = XMessage{
ID: msg.ID,
}
if msg.Values != nil {
val[i].Values = maps.Clone(msg.Values)
}
}
}
return &XMessageSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
func readXMessageSlice(rd *proto.Reader) ([]XMessage, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
msgs := make([]XMessage, n)
for i := 0; i < len(msgs); i++ {
if msgs[i], err = readXMessage(rd); err != nil {
return nil, err
}
}
return msgs, nil
}
func readXMessage(rd *proto.Reader) (XMessage, error) {
// Read array length can be 2 or 4 (with CLAIM metadata)
n, err := rd.ReadArrayLen()
if err != nil {
return XMessage{}, err
}
if n != 2 && n != 4 {
return XMessage{}, fmt.Errorf("redis: got %d elements in the XMessage array, expected 2 or 4", n)
}
id, err := rd.ReadString()
if err != nil {
return XMessage{}, err
}
v, err := stringInterfaceMapParser(rd)
if err != nil {
if err != proto.Nil {
return XMessage{}, err
}
}
msg := XMessage{
ID: id,
Values: v,
}
if n == 4 {
msg.MillisElapsedFromDelivery, err = rd.ReadInt()
if err != nil {
return XMessage{}, err
}
msg.DeliveredCount, err = rd.ReadInt()
if err != nil {
return XMessage{}, err
}
}
return msg, nil
}
func stringInterfaceMapParser(rd *proto.Reader) (map[string]interface{}, error) {
n, err := rd.ReadMapLen()
if err != nil {
return nil, err
}
m := make(map[string]interface{}, n)
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return nil, err
}
value, err := rd.ReadString()
if err != nil {
return nil, err
}
m[key] = value
}
return m, nil
}
//------------------------------------------------------------------------------
type XStream struct {
Stream string
Messages []XMessage
}
type XStreamSliceCmd struct {
baseCmd
val []XStream
}
var _ Cmder = (*XStreamSliceCmd)(nil)
func NewXStreamSliceCmd(ctx context.Context, args ...interface{}) *XStreamSliceCmd {
return &XStreamSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXStreamSlice,
},
}
}
func (cmd *XStreamSliceCmd) SetVal(val []XStream) {
cmd.val = val
}
func (cmd *XStreamSliceCmd) Val() []XStream {
cmd.await()
return cmd.val
}
func (cmd *XStreamSliceCmd) Result() ([]XStream, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XStreamSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XStreamSliceCmd) readReply(rd *proto.Reader) error {
typ, err := rd.PeekReplyType()
if err != nil {
return err
}
var n int
if typ == proto.RespMap {
n, err = rd.ReadMapLen()
} else {
n, err = rd.ReadArrayLen()
}
if err != nil {
return err
}
cmd.val = make([]XStream, n)
for i := 0; i < len(cmd.val); i++ {
if typ != proto.RespMap {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
}
if cmd.val[i].Stream, err = rd.ReadString(); err != nil {
return err
}
if cmd.val[i].Messages, err = readXMessageSlice(rd); err != nil {
return err
}
}
return nil
}
func (cmd *XStreamSliceCmd) Clone() Cmder {
var val []XStream
if cmd.val != nil {
val = make([]XStream, len(cmd.val))
for i, stream := range cmd.val {
val[i] = XStream{
Stream: stream.Stream,
}
if stream.Messages != nil {
val[i].Messages = make([]XMessage, len(stream.Messages))
for j, msg := range stream.Messages {
val[i].Messages[j] = XMessage{
ID: msg.ID,
}
if msg.Values != nil {
val[i].Messages[j].Values = make(map[string]interface{}, len(msg.Values))
for k, v := range msg.Values {
val[i].Messages[j].Values[k] = v
}
}
}
}
}
}
return &XStreamSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XPending struct {
Count int64
Lower string
Higher string
Consumers map[string]int64
}
type XPendingCmd struct {
baseCmd
val *XPending
}
var _ Cmder = (*XPendingCmd)(nil)
func NewXPendingCmd(ctx context.Context, args ...interface{}) *XPendingCmd {
return &XPendingCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXPending,
},
}
}
func (cmd *XPendingCmd) SetVal(val *XPending) {
cmd.val = val
}
func (cmd *XPendingCmd) Val() *XPending {
cmd.await()
return cmd.val
}
func (cmd *XPendingCmd) Result() (*XPending, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XPendingCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XPendingCmd) readReply(rd *proto.Reader) error {
var err error
if err = rd.ReadFixedArrayLen(4); err != nil {
return err
}
cmd.val = &XPending{}
if cmd.val.Count, err = rd.ReadInt(); err != nil {
return err
}
if cmd.val.Lower, err = rd.ReadString(); err != nil && err != Nil {
return err
}
if cmd.val.Higher, err = rd.ReadString(); err != nil && err != Nil {
return err
}
n, err := rd.ReadArrayLen()
if err != nil && err != Nil {
return err
}
cmd.val.Consumers = make(map[string]int64, n)
for i := 0; i < n; i++ {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
consumerName, err := rd.ReadString()
if err != nil {
return err
}
consumerPending, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val.Consumers[consumerName] = consumerPending
}
return nil
}
func (cmd *XPendingCmd) Clone() Cmder {
var val *XPending
if cmd.val != nil {
val = &XPending{
Count: cmd.val.Count,
Lower: cmd.val.Lower,
Higher: cmd.val.Higher,
}
if cmd.val.Consumers != nil {
val.Consumers = make(map[string]int64, len(cmd.val.Consumers))
for k, v := range cmd.val.Consumers {
val.Consumers[k] = v
}
}
}
return &XPendingCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XPendingExt struct {
ID string
Consumer string
Idle time.Duration
RetryCount int64
}
type XPendingExtCmd struct {
baseCmd
val []XPendingExt
}
var _ Cmder = (*XPendingExtCmd)(nil)
func NewXPendingExtCmd(ctx context.Context, args ...interface{}) *XPendingExtCmd {
return &XPendingExtCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXPendingExt,
},
}
}
func (cmd *XPendingExtCmd) SetVal(val []XPendingExt) {
cmd.val = val
}
func (cmd *XPendingExtCmd) Val() []XPendingExt {
cmd.await()
return cmd.val
}
func (cmd *XPendingExtCmd) Result() ([]XPendingExt, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XPendingExtCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XPendingExtCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]XPendingExt, n)
for i := 0; i < len(cmd.val); i++ {
if err = rd.ReadFixedArrayLen(4); err != nil {
return err
}
if cmd.val[i].ID, err = rd.ReadString(); err != nil {
return err
}
if cmd.val[i].Consumer, err = rd.ReadString(); err != nil && err != Nil {
return err
}
idle, err := rd.ReadInt()
if err != nil && err != Nil {
return err
}
cmd.val[i].Idle = time.Duration(idle) * time.Millisecond
if cmd.val[i].RetryCount, err = rd.ReadInt(); err != nil && err != Nil {
return err
}
}
return nil
}
func (cmd *XPendingExtCmd) Clone() Cmder {
var val []XPendingExt
if cmd.val != nil {
val = make([]XPendingExt, len(cmd.val))
copy(val, cmd.val)
}
return &XPendingExtCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XAutoClaimCmd struct {
baseCmd
start string
val []XMessage
}
var _ Cmder = (*XAutoClaimCmd)(nil)
func NewXAutoClaimCmd(ctx context.Context, args ...interface{}) *XAutoClaimCmd {
return &XAutoClaimCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXAutoClaim,
},
}
}
func (cmd *XAutoClaimCmd) SetVal(val []XMessage, start string) {
cmd.val = val
cmd.start = start
}
func (cmd *XAutoClaimCmd) Val() (messages []XMessage, start string) {
cmd.await()
return cmd.val, cmd.start
}
func (cmd *XAutoClaimCmd) Result() (messages []XMessage, start string, err error) {
cmd.await()
return cmd.val, cmd.start, cmd.err
}
func (cmd *XAutoClaimCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XAutoClaimCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
switch n {
case 2, // Redis 6
3: // Redis 7:
// ok
default:
return fmt.Errorf("redis: got %d elements in XAutoClaim reply, wanted 2/3", n)
}
cmd.start, err = rd.ReadString()
if err != nil {
return err
}
cmd.val, err = readXMessageSlice(rd)
if err != nil {
return err
}
if n >= 3 {
return rd.DiscardNext()
}
return nil
}
func (cmd *XAutoClaimCmd) Clone() Cmder {
var val []XMessage
if cmd.val != nil {
val = make([]XMessage, len(cmd.val))
for i, msg := range cmd.val {
val[i] = XMessage{
ID: msg.ID,
}
if msg.Values != nil {
val[i].Values = make(map[string]interface{}, len(msg.Values))
for k, v := range msg.Values {
val[i].Values[k] = v
}
}
}
}
return &XAutoClaimCmd{
baseCmd: cmd.cloneBaseCmd(),
start: cmd.start,
val: val,
}
}
//------------------------------------------------------------------------------
type XAutoClaimWithDeletedCmd struct {
baseCmd
start string
val []XMessage
deletedIDs []string
}
var _ Cmder = (*XAutoClaimWithDeletedCmd)(nil)
func NewXAutoClaimWithDeletedCmd(ctx context.Context, args ...interface{}) *XAutoClaimWithDeletedCmd {
return &XAutoClaimWithDeletedCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXAutoClaimWithDeleted,
},
}
}
func (cmd *XAutoClaimWithDeletedCmd) SetVal(val []XMessage, start string, deletedIDs []string) {
cmd.val = val
cmd.start = start
cmd.deletedIDs = deletedIDs
}
func (cmd *XAutoClaimWithDeletedCmd) Val() (messages []XMessage, start string, deletedIDs []string) {
cmd.await()
return cmd.val, cmd.start, cmd.deletedIDs
}
func (cmd *XAutoClaimWithDeletedCmd) Result() (messages []XMessage, start string, deletedIDs []string, err error) {
cmd.await()
return cmd.val, cmd.start, cmd.deletedIDs, cmd.err
}
func (cmd *XAutoClaimWithDeletedCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XAutoClaimWithDeletedCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
switch n {
case 2, // Redis 6
3: // Redis 7:
// ok
default:
return fmt.Errorf("redis: got %d elements in XAutoClaim reply, wanted 2/3", n)
}
cmd.start, err = rd.ReadString()
if err != nil {
return err
}
cmd.val, err = readXMessageSlice(rd)
if err != nil {
return err
}
if n < 3 {
return nil
}
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.deletedIDs = make([]string, nn)
for i := 0; i < nn; i++ {
cmd.deletedIDs[i], err = rd.ReadString()
if err != nil {
return err
}
}
return nil
}
func (cmd *XAutoClaimWithDeletedCmd) Clone() Cmder {
var val []XMessage
if cmd.val != nil {
val = make([]XMessage, len(cmd.val))
for i, msg := range cmd.val {
val[i] = XMessage{
ID: msg.ID,
}
if msg.Values != nil {
val[i].Values = make(map[string]interface{}, len(msg.Values))
for k, v := range msg.Values {
val[i].Values[k] = v
}
}
}
}
var deletedIDs []string
if cmd.deletedIDs != nil {
deletedIDs = make([]string, len(cmd.deletedIDs))
copy(deletedIDs, cmd.deletedIDs)
}
return &XAutoClaimWithDeletedCmd{
baseCmd: cmd.cloneBaseCmd(),
start: cmd.start,
val: val,
deletedIDs: deletedIDs,
}
}
//------------------------------------------------------------------------------
type XAutoClaimJustIDCmd struct {
baseCmd
start string
val []string
}
var _ Cmder = (*XAutoClaimJustIDCmd)(nil)
func NewXAutoClaimJustIDCmd(ctx context.Context, args ...interface{}) *XAutoClaimJustIDCmd {
return &XAutoClaimJustIDCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXAutoClaimJustID,
},
}
}
func (cmd *XAutoClaimJustIDCmd) SetVal(val []string, start string) {
cmd.val = val
cmd.start = start
}
func (cmd *XAutoClaimJustIDCmd) Val() (ids []string, start string) {
cmd.await()
return cmd.val, cmd.start
}
func (cmd *XAutoClaimJustIDCmd) Result() (ids []string, start string, err error) {
cmd.await()
return cmd.val, cmd.start, cmd.err
}
func (cmd *XAutoClaimJustIDCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XAutoClaimJustIDCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
switch n {
case 2, // Redis 6
3: // Redis 7:
// ok
default:
return fmt.Errorf("redis: got %d elements in XAutoClaimJustID reply, wanted 2/3", n)
}
cmd.start, err = rd.ReadString()
if err != nil {
return err
}
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]string, nn)
for i := 0; i < nn; i++ {
cmd.val[i], err = rd.ReadString()
if err != nil {
return err
}
}
if n >= 3 {
if err := rd.DiscardNext(); err != nil {
return err
}
}
return nil
}
func (cmd *XAutoClaimJustIDCmd) Clone() Cmder {
var val []string
if cmd.val != nil {
val = make([]string, len(cmd.val))
copy(val, cmd.val)
}
return &XAutoClaimJustIDCmd{
baseCmd: cmd.cloneBaseCmd(),
start: cmd.start,
val: val,
}
}
//------------------------------------------------------------------------------
type XInfoConsumersCmd struct {
baseCmd
val []XInfoConsumer
}
type XInfoConsumer struct {
Name string
Pending int64
Idle time.Duration
Inactive time.Duration
}
var _ Cmder = (*XInfoConsumersCmd)(nil)
func NewXInfoConsumersCmd(ctx context.Context, stream string, group string) *XInfoConsumersCmd {
return &XInfoConsumersCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"xinfo", "consumers", stream, group},
cmdType: CmdTypeXInfoConsumers,
},
}
}
func (cmd *XInfoConsumersCmd) SetVal(val []XInfoConsumer) {
cmd.val = val
}
func (cmd *XInfoConsumersCmd) Val() []XInfoConsumer {
cmd.await()
return cmd.val
}
func (cmd *XInfoConsumersCmd) Result() ([]XInfoConsumer, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XInfoConsumersCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XInfoConsumersCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]XInfoConsumer, n)
for i := 0; i < len(cmd.val); i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
for f := 0; f < nn; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "name":
cmd.val[i].Name, err = rd.ReadString()
case "pending":
cmd.val[i].Pending, err = rd.ReadInt()
case "idle":
var idle int64
idle, err = rd.ReadInt()
cmd.val[i].Idle = time.Duration(idle) * time.Millisecond
case "inactive":
var inactive int64
inactive, err = rd.ReadInt()
cmd.val[i].Inactive = time.Duration(inactive) * time.Millisecond
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return err
}
}
if err != nil {
return err
}
}
}
return nil
}
func (cmd *XInfoConsumersCmd) Clone() Cmder {
var val []XInfoConsumer
if cmd.val != nil {
val = make([]XInfoConsumer, len(cmd.val))
copy(val, cmd.val)
}
return &XInfoConsumersCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XInfoGroupsCmd struct {
baseCmd
val []XInfoGroup
}
type XInfoGroup struct {
Name string
Consumers int64
Pending int64
LastDeliveredID string
EntriesRead int64
// Lag represents the number of pending messages in the stream not yet
// delivered to this consumer group. Returns -1 when the lag cannot be determined.
Lag int64
}
var _ Cmder = (*XInfoGroupsCmd)(nil)
func NewXInfoGroupsCmd(ctx context.Context, stream string) *XInfoGroupsCmd {
return &XInfoGroupsCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"xinfo", "groups", stream},
cmdType: CmdTypeXInfoGroups,
},
}
}
func (cmd *XInfoGroupsCmd) SetVal(val []XInfoGroup) {
cmd.val = val
}
func (cmd *XInfoGroupsCmd) Val() []XInfoGroup {
cmd.await()
return cmd.val
}
func (cmd *XInfoGroupsCmd) Result() ([]XInfoGroup, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XInfoGroupsCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XInfoGroupsCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]XInfoGroup, n)
for i := 0; i < len(cmd.val); i++ {
group := &cmd.val[i]
nn, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
for j := 0; j < nn; j++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "name":
group.Name, err = rd.ReadString()
if err != nil {
return err
}
case "consumers":
group.Consumers, err = rd.ReadInt()
if err != nil {
return err
}
case "pending":
group.Pending, err = rd.ReadInt()
if err != nil {
return err
}
case "last-delivered-id":
group.LastDeliveredID, err = rd.ReadString()
if err != nil {
return err
}
case "entries-read":
group.EntriesRead, err = rd.ReadInt()
if err != nil && err != Nil {
return err
}
case "lag":
group.Lag, err = rd.ReadInt()
// lag: the number of entries in the stream that are still waiting to be delivered
// to the group's consumers, or a NULL(Nil) when that number can't be determined.
// In that case, we return -1.
if err != nil && err != Nil {
return err
} else if err == Nil {
group.Lag = -1
}
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
}
return nil
}
func (cmd *XInfoGroupsCmd) Clone() Cmder {
var val []XInfoGroup
if cmd.val != nil {
val = make([]XInfoGroup, len(cmd.val))
copy(val, cmd.val)
}
return &XInfoGroupsCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XInfoStreamCmd struct {
baseCmd
val *XInfoStream
}
type XInfoStream struct {
Length int64
RadixTreeKeys int64
RadixTreeNodes int64
Groups int64
LastGeneratedID string
MaxDeletedEntryID string
EntriesAdded int64
FirstEntry XMessage
LastEntry XMessage
RecordedFirstEntryID string
IDMPDuration int64
IDMPMaxSize int64
PIDsTracked int64
IIDsTracked int64
IIDsAdded int64
IIDsDuplicates int64
}
var _ Cmder = (*XInfoStreamCmd)(nil)
func NewXInfoStreamCmd(ctx context.Context, stream string) *XInfoStreamCmd {
return &XInfoStreamCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"xinfo", "stream", stream},
cmdType: CmdTypeXInfoStream,
},
}
}
func (cmd *XInfoStreamCmd) SetVal(val *XInfoStream) {
cmd.val = val
}
func (cmd *XInfoStreamCmd) Val() *XInfoStream {
cmd.await()
return cmd.val
}
func (cmd *XInfoStreamCmd) Result() (*XInfoStream, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XInfoStreamCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XInfoStreamCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = &XInfoStream{}
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "length":
cmd.val.Length, err = rd.ReadInt()
if err != nil {
return err
}
case "radix-tree-keys":
cmd.val.RadixTreeKeys, err = rd.ReadInt()
if err != nil {
return err
}
case "radix-tree-nodes":
cmd.val.RadixTreeNodes, err = rd.ReadInt()
if err != nil {
return err
}
case "groups":
cmd.val.Groups, err = rd.ReadInt()
if err != nil {
return err
}
case "last-generated-id":
cmd.val.LastGeneratedID, err = rd.ReadString()
if err != nil {
return err
}
case "max-deleted-entry-id":
cmd.val.MaxDeletedEntryID, err = rd.ReadString()
if err != nil {
return err
}
case "entries-added":
cmd.val.EntriesAdded, err = rd.ReadInt()
if err != nil {
return err
}
case "first-entry":
cmd.val.FirstEntry, err = readXMessage(rd)
if err != nil && err != Nil {
return err
}
case "last-entry":
cmd.val.LastEntry, err = readXMessage(rd)
if err != nil && err != Nil {
return err
}
case "recorded-first-entry-id":
cmd.val.RecordedFirstEntryID, err = rd.ReadString()
if err != nil {
return err
}
case "idmp-duration":
cmd.val.IDMPDuration, err = rd.ReadInt()
if err != nil {
return err
}
case "idmp-maxsize":
cmd.val.IDMPMaxSize, err = rd.ReadInt()
if err != nil {
return err
}
case "pids-tracked":
cmd.val.PIDsTracked, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-tracked":
cmd.val.IIDsTracked, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-added":
cmd.val.IIDsAdded, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-duplicates":
cmd.val.IIDsDuplicates, err = rd.ReadInt()
if err != nil {
return err
}
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
return nil
}
func (cmd *XInfoStreamCmd) Clone() Cmder {
var val *XInfoStream
if cmd.val != nil {
val = &XInfoStream{
Length: cmd.val.Length,
RadixTreeKeys: cmd.val.RadixTreeKeys,
RadixTreeNodes: cmd.val.RadixTreeNodes,
Groups: cmd.val.Groups,
LastGeneratedID: cmd.val.LastGeneratedID,
MaxDeletedEntryID: cmd.val.MaxDeletedEntryID,
EntriesAdded: cmd.val.EntriesAdded,
RecordedFirstEntryID: cmd.val.RecordedFirstEntryID,
}
// Clone XMessage fields
val.FirstEntry = XMessage{
ID: cmd.val.FirstEntry.ID,
}
if cmd.val.FirstEntry.Values != nil {
val.FirstEntry.Values = make(map[string]interface{}, len(cmd.val.FirstEntry.Values))
for k, v := range cmd.val.FirstEntry.Values {
val.FirstEntry.Values[k] = v
}
}
val.LastEntry = XMessage{
ID: cmd.val.LastEntry.ID,
}
if cmd.val.LastEntry.Values != nil {
val.LastEntry.Values = make(map[string]interface{}, len(cmd.val.LastEntry.Values))
for k, v := range cmd.val.LastEntry.Values {
val.LastEntry.Values[k] = v
}
}
}
return &XInfoStreamCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type XInfoStreamFullCmd struct {
baseCmd
val *XInfoStreamFull
}
type XInfoStreamFull struct {
Length int64
RadixTreeKeys int64
RadixTreeNodes int64
LastGeneratedID string
MaxDeletedEntryID string
EntriesAdded int64
Entries []XMessage
Groups []XInfoStreamGroup
RecordedFirstEntryID string
IDMPDuration int64
IDMPMaxSize int64
PIDsTracked int64
IIDsTracked int64
IIDsAdded int64
IIDsDuplicates int64
}
type XInfoStreamGroup struct {
Name string
LastDeliveredID string
EntriesRead int64
Lag int64
PelCount int64
NackedCount uint64 // redis version 8.8, number of NACK'd messages in the group
Pending []XInfoStreamGroupPending
Consumers []XInfoStreamConsumer
}
type XInfoStreamGroupPending struct {
ID string
Consumer string
DeliveryTime time.Time
DeliveryCount int64
}
type XInfoStreamConsumer struct {
Name string
SeenTime time.Time
ActiveTime time.Time
PelCount int64
Pending []XInfoStreamConsumerPending
}
type XInfoStreamConsumerPending struct {
ID string
DeliveryTime time.Time
DeliveryCount int64
}
var _ Cmder = (*XInfoStreamFullCmd)(nil)
func NewXInfoStreamFullCmd(ctx context.Context, args ...interface{}) *XInfoStreamFullCmd {
return &XInfoStreamFullCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeXInfoStreamFull,
},
}
}
func (cmd *XInfoStreamFullCmd) SetVal(val *XInfoStreamFull) {
cmd.val = val
}
func (cmd *XInfoStreamFullCmd) Val() *XInfoStreamFull {
cmd.await()
return cmd.val
}
func (cmd *XInfoStreamFullCmd) Result() (*XInfoStreamFull, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *XInfoStreamFullCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *XInfoStreamFullCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = &XInfoStreamFull{}
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "length":
cmd.val.Length, err = rd.ReadInt()
if err != nil {
return err
}
case "radix-tree-keys":
cmd.val.RadixTreeKeys, err = rd.ReadInt()
if err != nil {
return err
}
case "radix-tree-nodes":
cmd.val.RadixTreeNodes, err = rd.ReadInt()
if err != nil {
return err
}
case "last-generated-id":
cmd.val.LastGeneratedID, err = rd.ReadString()
if err != nil {
return err
}
case "entries-added":
cmd.val.EntriesAdded, err = rd.ReadInt()
if err != nil {
return err
}
case "entries":
cmd.val.Entries, err = readXMessageSlice(rd)
if err != nil {
return err
}
case "groups":
cmd.val.Groups, err = readStreamGroups(rd)
if err != nil {
return err
}
case "max-deleted-entry-id":
cmd.val.MaxDeletedEntryID, err = rd.ReadString()
if err != nil {
return err
}
case "recorded-first-entry-id":
cmd.val.RecordedFirstEntryID, err = rd.ReadString()
if err != nil {
return err
}
case "idmp-duration":
cmd.val.IDMPDuration, err = rd.ReadInt()
if err != nil {
return err
}
case "idmp-maxsize":
cmd.val.IDMPMaxSize, err = rd.ReadInt()
if err != nil {
return err
}
case "pids-tracked":
cmd.val.PIDsTracked, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-tracked":
cmd.val.IIDsTracked, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-added":
cmd.val.IIDsAdded, err = rd.ReadInt()
if err != nil {
return err
}
case "iids-duplicates":
cmd.val.IIDsDuplicates, err = rd.ReadInt()
if err != nil {
return err
}
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
return nil
}
func readStreamGroups(rd *proto.Reader) ([]XInfoStreamGroup, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
groups := make([]XInfoStreamGroup, 0, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return nil, err
}
group := XInfoStreamGroup{}
for j := 0; j < nn; j++ {
key, err := rd.ReadString()
if err != nil {
return nil, err
}
switch key {
case "name":
group.Name, err = rd.ReadString()
if err != nil {
return nil, err
}
case "last-delivered-id":
group.LastDeliveredID, err = rd.ReadString()
if err != nil {
return nil, err
}
case "entries-read":
group.EntriesRead, err = rd.ReadInt()
if err != nil && err != Nil {
return nil, err
}
case "lag":
// lag: the number of entries in the stream that are still waiting to be delivered
// to the group's consumers, or a NULL(Nil) when that number can't be determined.
group.Lag, err = rd.ReadInt()
if err != nil && err != Nil {
return nil, err
}
case "pel-count":
group.PelCount, err = rd.ReadInt()
if err != nil {
return nil, err
}
case "nacked-count":
group.NackedCount, err = rd.ReadUint()
if err != nil {
return nil, err
}
case "pending":
group.Pending, err = readXInfoStreamGroupPending(rd)
if err != nil {
return nil, err
}
case "consumers":
group.Consumers, err = readXInfoStreamConsumers(rd)
if err != nil {
return nil, err
}
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return nil, err
}
}
}
groups = append(groups, group)
}
return groups, nil
}
func readXInfoStreamGroupPending(rd *proto.Reader) ([]XInfoStreamGroupPending, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
pending := make([]XInfoStreamGroupPending, 0, n)
for i := 0; i < n; i++ {
if err = rd.ReadFixedArrayLen(4); err != nil {
return nil, err
}
p := XInfoStreamGroupPending{}
p.ID, err = rd.ReadString()
if err != nil {
return nil, err
}
p.Consumer, err = rd.ReadString()
if err != nil {
return nil, err
}
delivery, err := rd.ReadInt()
if err != nil {
return nil, err
}
p.DeliveryTime = time.Unix(delivery/1000, delivery%1000*int64(time.Millisecond))
p.DeliveryCount, err = rd.ReadInt()
if err != nil {
return nil, err
}
pending = append(pending, p)
}
return pending, nil
}
func readXInfoStreamConsumers(rd *proto.Reader) ([]XInfoStreamConsumer, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
consumers := make([]XInfoStreamConsumer, 0, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return nil, err
}
c := XInfoStreamConsumer{}
for f := 0; f < nn; f++ {
cKey, err := rd.ReadString()
if err != nil {
return nil, err
}
switch cKey {
case "name":
c.Name, err = rd.ReadString()
case "seen-time":
seen, err := rd.ReadInt()
if err != nil {
return nil, err
}
c.SeenTime = time.UnixMilli(seen)
case "active-time":
active, err := rd.ReadInt()
if err != nil {
return nil, err
}
c.ActiveTime = time.UnixMilli(active)
case "pel-count":
c.PelCount, err = rd.ReadInt()
case "pending":
pendingNumber, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
c.Pending = make([]XInfoStreamConsumerPending, 0, pendingNumber)
for pn := 0; pn < pendingNumber; pn++ {
if err = rd.ReadFixedArrayLen(3); err != nil {
return nil, err
}
p := XInfoStreamConsumerPending{}
p.ID, err = rd.ReadString()
if err != nil {
return nil, err
}
delivery, err := rd.ReadInt()
if err != nil {
return nil, err
}
p.DeliveryTime = time.Unix(delivery/1000, delivery%1000*int64(time.Millisecond))
p.DeliveryCount, err = rd.ReadInt()
if err != nil {
return nil, err
}
c.Pending = append(c.Pending, p)
}
default:
// skip unknown fields
if err = rd.DiscardNext(); err != nil {
return nil, err
}
}
if err != nil {
return nil, err
}
}
consumers = append(consumers, c)
}
return consumers, nil
}
func (cmd *XInfoStreamFullCmd) Clone() Cmder {
var val *XInfoStreamFull
if cmd.val != nil {
val = &XInfoStreamFull{
Length: cmd.val.Length,
RadixTreeKeys: cmd.val.RadixTreeKeys,
RadixTreeNodes: cmd.val.RadixTreeNodes,
LastGeneratedID: cmd.val.LastGeneratedID,
MaxDeletedEntryID: cmd.val.MaxDeletedEntryID,
EntriesAdded: cmd.val.EntriesAdded,
RecordedFirstEntryID: cmd.val.RecordedFirstEntryID,
}
// Clone Entries
if cmd.val.Entries != nil {
val.Entries = make([]XMessage, len(cmd.val.Entries))
for i, msg := range cmd.val.Entries {
val.Entries[i] = XMessage{
ID: msg.ID,
}
if msg.Values != nil {
val.Entries[i].Values = make(map[string]interface{}, len(msg.Values))
for k, v := range msg.Values {
val.Entries[i].Values[k] = v
}
}
}
}
// Clone Groups - simplified copy for now due to complexity
if cmd.val.Groups != nil {
val.Groups = make([]XInfoStreamGroup, len(cmd.val.Groups))
copy(val.Groups, cmd.val.Groups)
}
}
return &XInfoStreamFullCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type ZSliceCmd struct {
baseCmd
val []Z
}
var _ Cmder = (*ZSliceCmd)(nil)
func NewZSliceCmd(ctx context.Context, args ...interface{}) *ZSliceCmd {
return &ZSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeZSlice,
},
}
}
func (cmd *ZSliceCmd) SetVal(val []Z) {
cmd.val = val
}
func (cmd *ZSliceCmd) Val() []Z {
cmd.await()
return cmd.val
}
func (cmd *ZSliceCmd) Result() ([]Z, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ZSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ZSliceCmd) readReply(rd *proto.Reader) error { // nolint:dupl
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
// If the n is 0, can't continue reading.
if n == 0 {
cmd.val = make([]Z, 0)
return nil
}
typ, err := rd.PeekReplyType()
if err != nil {
return err
}
array := typ == proto.RespArray
if array {
cmd.val = make([]Z, n)
} else {
if n%2 != 0 {
return fmt.Errorf("redis: got %d elements in the sorted set array, wanted a multiple of 2", n)
}
cmd.val = make([]Z, n/2)
}
for i := 0; i < len(cmd.val); i++ {
if array {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
}
if cmd.val[i].Member, err = rd.ReadString(); err != nil {
return err
}
if cmd.val[i].Score, err = rd.ReadFloat(); err != nil {
return err
}
}
return nil
}
func (cmd *ZSliceCmd) Clone() Cmder {
var val []Z
if cmd.val != nil {
val = make([]Z, len(cmd.val))
copy(val, cmd.val)
}
return &ZSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type ZWithKeyCmd struct {
baseCmd
val *ZWithKey
}
var _ Cmder = (*ZWithKeyCmd)(nil)
func NewZWithKeyCmd(ctx context.Context, args ...interface{}) *ZWithKeyCmd {
return &ZWithKeyCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeZWithKey,
},
}
}
func (cmd *ZWithKeyCmd) SetVal(val *ZWithKey) {
cmd.val = val
}
func (cmd *ZWithKeyCmd) Val() *ZWithKey {
cmd.await()
return cmd.val
}
func (cmd *ZWithKeyCmd) Result() (*ZWithKey, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ZWithKeyCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ZWithKeyCmd) readReply(rd *proto.Reader) (err error) {
if err = rd.ReadFixedArrayLen(3); err != nil {
return err
}
cmd.val = &ZWithKey{}
if cmd.val.Key, err = rd.ReadString(); err != nil {
return err
}
if cmd.val.Member, err = rd.ReadString(); err != nil {
return err
}
if cmd.val.Score, err = rd.ReadFloat(); err != nil {
return err
}
return nil
}
func (cmd *ZWithKeyCmd) Clone() Cmder {
var val *ZWithKey
if cmd.val != nil {
val = &ZWithKey{
Z: Z{
Score: cmd.val.Score,
Member: cmd.val.Member,
},
Key: cmd.val.Key,
}
}
return &ZWithKeyCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type ScanCmd struct {
baseCmd
page []string
cursor uint64
process cmdable
}
var _ Cmder = (*ScanCmd)(nil)
func NewScanCmd(ctx context.Context, process cmdable, args ...interface{}) *ScanCmd {
return &ScanCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeScan,
},
process: process,
}
}
func (cmd *ScanCmd) SetVal(page []string, cursor uint64) {
cmd.page = page
cmd.cursor = cursor
}
func (cmd *ScanCmd) Val() (keys []string, cursor uint64) {
cmd.await()
return cmd.page, cmd.cursor
}
func (cmd *ScanCmd) Result() (keys []string, cursor uint64, err error) {
cmd.await()
return cmd.page, cmd.cursor, cmd.err
}
func (cmd *ScanCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.page)
}
func (cmd *ScanCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
cursor, err := rd.ReadUint()
if err != nil {
return err
}
cmd.cursor = cursor
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.page = make([]string, n)
for i := 0; i < len(cmd.page); i++ {
if cmd.page[i], err = rd.ReadString(); err != nil {
return err
}
}
return nil
}
func (cmd *ScanCmd) Clone() Cmder {
var page []string
if cmd.page != nil {
page = make([]string, len(cmd.page))
copy(page, cmd.page)
}
return &ScanCmd{
baseCmd: cmd.cloneBaseCmd(),
page: page,
cursor: cmd.cursor,
process: cmd.process,
}
}
// Iterator creates a new ScanIterator.
func (cmd *ScanCmd) Iterator() *ScanIterator {
return &ScanIterator{
cmd: cmd,
}
}
//------------------------------------------------------------------------------
type ClusterNode struct {
ID string
Addr string
NetworkingMetadata map[string]string
}
type ClusterSlot struct {
Start int
End int
Nodes []ClusterNode
}
type ClusterSlotsCmd struct {
baseCmd
val []ClusterSlot
}
var _ Cmder = (*ClusterSlotsCmd)(nil)
func NewClusterSlotsCmd(ctx context.Context, args ...interface{}) *ClusterSlotsCmd {
return &ClusterSlotsCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeClusterSlots,
},
}
}
func (cmd *ClusterSlotsCmd) SetVal(val []ClusterSlot) {
cmd.val = val
}
func (cmd *ClusterSlotsCmd) Val() []ClusterSlot {
cmd.await()
return cmd.val
}
func (cmd *ClusterSlotsCmd) Result() ([]ClusterSlot, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ClusterSlotsCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ClusterSlotsCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]ClusterSlot, n)
for i := 0; i < len(cmd.val); i++ {
n, err = rd.ReadArrayLen()
if err != nil {
return err
}
if n < 2 {
return fmt.Errorf("redis: got %d elements in cluster info, expected at least 2", n)
}
start, err := rd.ReadInt()
if err != nil {
return err
}
end, err := rd.ReadInt()
if err != nil {
return err
}
// subtract start and end.
nodes := make([]ClusterNode, n-2)
for j := 0; j < len(nodes); j++ {
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
if nn < 2 || nn > 4 {
return fmt.Errorf("got %d elements in cluster info address, expected 2, 3, or 4", n)
}
ip, err := rd.ReadString()
if err != nil {
return err
}
port, err := rd.ReadString()
if err != nil {
return err
}
nodes[j].Addr = net.JoinHostPort(ip, port)
if nn >= 3 {
id, err := rd.ReadString()
if err != nil {
return err
}
nodes[j].ID = id
}
if nn >= 4 {
metadataLength, err := rd.ReadMapLen()
if err != nil {
return err
}
networkingMetadata := make(map[string]string, metadataLength)
for i := 0; i < metadataLength; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
value, err := rd.ReadString()
if err != nil {
return err
}
networkingMetadata[key] = value
}
nodes[j].NetworkingMetadata = networkingMetadata
}
}
cmd.val[i] = ClusterSlot{
Start: int(start),
End: int(end),
Nodes: nodes,
}
}
return nil
}
func (cmd *ClusterSlotsCmd) Clone() Cmder {
var val []ClusterSlot
if cmd.val != nil {
val = make([]ClusterSlot, len(cmd.val))
for i, slot := range cmd.val {
val[i] = ClusterSlot{
Start: slot.Start,
End: slot.End,
}
if slot.Nodes != nil {
val[i].Nodes = make([]ClusterNode, len(slot.Nodes))
for j, node := range slot.Nodes {
val[i].Nodes[j] = ClusterNode{
ID: node.ID,
Addr: node.Addr,
}
if node.NetworkingMetadata != nil {
val[i].Nodes[j].NetworkingMetadata = make(map[string]string, len(node.NetworkingMetadata))
for k, v := range node.NetworkingMetadata {
val[i].Nodes[j].NetworkingMetadata[k] = v
}
}
}
}
}
}
return &ClusterSlotsCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
// GeoLocation is used with GeoAdd to add geospatial location.
type GeoLocation struct {
Name string
Longitude, Latitude, Dist float64
GeoHash int64
}
// GeoRadiusQuery is used with GeoRadius to query geospatial index.
type GeoRadiusQuery struct {
Radius float64
// Can be m, km, ft, or mi. Default is km.
Unit string
WithCoord bool
WithDist bool
WithGeoHash bool
Count int
// Can be ASC or DESC. Default is no sort order.
Sort string
Store string
StoreDist string
// WithCoord+WithDist+WithGeoHash
withLen int
}
type GeoLocationCmd struct {
baseCmd
q *GeoRadiusQuery
locations []GeoLocation
}
var _ Cmder = (*GeoLocationCmd)(nil)
func NewGeoLocationCmd(ctx context.Context, q *GeoRadiusQuery, args ...interface{}) *GeoLocationCmd {
return &GeoLocationCmd{
baseCmd: baseCmd{
ctx: ctx,
args: geoLocationArgs(q, args...),
cmdType: CmdTypeGeoLocation,
},
q: q,
}
}
func geoLocationArgs(q *GeoRadiusQuery, args ...interface{}) []interface{} {
args = append(args, q.Radius)
if q.Unit != "" {
args = append(args, q.Unit)
} else {
args = append(args, "km")
}
if q.WithCoord {
args = append(args, "withcoord")
q.withLen++
}
if q.WithDist {
args = append(args, "withdist")
q.withLen++
}
if q.WithGeoHash {
args = append(args, "withhash")
q.withLen++
}
if q.Count > 0 {
args = append(args, "count", q.Count)
}
if q.Sort != "" {
args = append(args, q.Sort)
}
if q.Store != "" {
args = append(args, "store")
args = append(args, q.Store)
}
if q.StoreDist != "" {
args = append(args, "storedist")
args = append(args, q.StoreDist)
}
return args
}
func (cmd *GeoLocationCmd) SetVal(locations []GeoLocation) {
cmd.locations = locations
}
func (cmd *GeoLocationCmd) Val() []GeoLocation {
cmd.await()
return cmd.locations
}
func (cmd *GeoLocationCmd) Result() ([]GeoLocation, error) {
cmd.await()
return cmd.locations, cmd.err
}
func (cmd *GeoLocationCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.locations)
}
func (cmd *GeoLocationCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.locations = make([]GeoLocation, n)
for i := 0; i < len(cmd.locations); i++ {
// only name
if cmd.q.withLen == 0 {
if cmd.locations[i].Name, err = rd.ReadString(); err != nil {
return err
}
continue
}
// +name
if err = rd.ReadFixedArrayLen(cmd.q.withLen + 1); err != nil {
return err
}
if cmd.locations[i].Name, err = rd.ReadString(); err != nil {
return err
}
if cmd.q.WithDist {
if cmd.locations[i].Dist, err = rd.ReadFloat(); err != nil {
return err
}
}
if cmd.q.WithGeoHash {
if cmd.locations[i].GeoHash, err = rd.ReadInt(); err != nil {
return err
}
}
if cmd.q.WithCoord {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
if cmd.locations[i].Longitude, err = rd.ReadFloat(); err != nil {
return err
}
if cmd.locations[i].Latitude, err = rd.ReadFloat(); err != nil {
return err
}
}
}
return nil
}
func (cmd *GeoLocationCmd) Clone() Cmder {
var q *GeoRadiusQuery
if cmd.q != nil {
q = &GeoRadiusQuery{
Radius: cmd.q.Radius,
Unit: cmd.q.Unit,
WithCoord: cmd.q.WithCoord,
WithDist: cmd.q.WithDist,
WithGeoHash: cmd.q.WithGeoHash,
Count: cmd.q.Count,
Sort: cmd.q.Sort,
Store: cmd.q.Store,
StoreDist: cmd.q.StoreDist,
withLen: cmd.q.withLen,
}
}
var locations []GeoLocation
if cmd.locations != nil {
locations = make([]GeoLocation, len(cmd.locations))
copy(locations, cmd.locations)
}
return &GeoLocationCmd{
baseCmd: cmd.cloneBaseCmd(),
q: q,
locations: locations,
}
}
//------------------------------------------------------------------------------
// GeoSearchQuery is used for GEOSearch/GEOSearchStore command query.
type GeoSearchQuery struct {
Member string
// Latitude and Longitude when using FromLonLat option.
Longitude float64
Latitude float64
// Distance and unit when using ByRadius option.
// Can use m, km, ft, or mi. Default is km.
Radius float64
RadiusUnit string
// Height, width and unit when using ByBox option.
// Can be m, km, ft, or mi. Default is km.
BoxWidth float64
BoxHeight float64
BoxUnit string
// Can be ASC or DESC. Default is no sort order.
Sort string
Count int
CountAny bool
}
type GeoSearchLocationQuery struct {
GeoSearchQuery
WithCoord bool
WithDist bool
WithHash bool
}
type GeoSearchStoreQuery struct {
GeoSearchQuery
// When using the StoreDist option, the command stores the items in a
// sorted set populated with their distance from the center of the circle or box,
// as a floating-point number, in the same unit specified for that shape.
StoreDist bool
}
func geoSearchLocationArgs(q *GeoSearchLocationQuery, args []interface{}) []interface{} {
args = geoSearchArgs(&q.GeoSearchQuery, args)
if q.WithCoord {
args = append(args, "withcoord")
}
if q.WithDist {
args = append(args, "withdist")
}
if q.WithHash {
args = append(args, "withhash")
}
return args
}
func geoSearchArgs(q *GeoSearchQuery, args []interface{}) []interface{} {
if q.Member != "" {
args = append(args, "frommember", q.Member)
} else {
args = append(args, "fromlonlat", q.Longitude, q.Latitude)
}
if q.Radius > 0 {
if q.RadiusUnit == "" {
q.RadiusUnit = "km"
}
args = append(args, "byradius", q.Radius, q.RadiusUnit)
} else {
if q.BoxUnit == "" {
q.BoxUnit = "km"
}
args = append(args, "bybox", q.BoxWidth, q.BoxHeight, q.BoxUnit)
}
if q.Sort != "" {
args = append(args, q.Sort)
}
if q.Count > 0 {
args = append(args, "count", q.Count)
if q.CountAny {
args = append(args, "any")
}
}
return args
}
type GeoSearchLocationCmd struct {
baseCmd
opt *GeoSearchLocationQuery
val []GeoLocation
}
var _ Cmder = (*GeoSearchLocationCmd)(nil)
func NewGeoSearchLocationCmd(
ctx context.Context, opt *GeoSearchLocationQuery, args ...interface{},
) *GeoSearchLocationCmd {
return &GeoSearchLocationCmd{
baseCmd: baseCmd{
ctx: ctx,
args: geoSearchLocationArgs(opt, args),
cmdType: CmdTypeGeoSearchLocation,
},
opt: opt,
}
}
func (cmd *GeoSearchLocationCmd) SetVal(val []GeoLocation) {
cmd.val = val
}
func (cmd *GeoSearchLocationCmd) Val() []GeoLocation {
cmd.await()
return cmd.val
}
func (cmd *GeoSearchLocationCmd) Result() ([]GeoLocation, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *GeoSearchLocationCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *GeoSearchLocationCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]GeoLocation, n)
// Each element is an array of [name, ...] whose minimum length is set by
// the requested WITH flags. Entries shorter than that would make the
// parser read into the next reply; extra elements are drained below so a
// longer entry (e.g. from a newer server) can't leave frames on the wire.
withLen := 1
if cmd.opt.WithDist {
withLen++
}
if cmd.opt.WithHash {
withLen++
}
if cmd.opt.WithCoord {
withLen++
}
for i := 0; i < n; i++ {
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
if nn < withLen {
return fmt.Errorf("redis: got %d elements in GEOSEARCH reply, expected at least %d", nn, withLen)
}
var loc GeoLocation
loc.Name, err = rd.ReadString()
if err != nil {
return err
}
if cmd.opt.WithDist {
loc.Dist, err = rd.ReadFloat()
if err != nil {
return err
}
}
if cmd.opt.WithHash {
loc.GeoHash, err = rd.ReadInt()
if err != nil {
return err
}
}
if cmd.opt.WithCoord {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
loc.Longitude, err = rd.ReadFloat()
if err != nil {
return err
}
loc.Latitude, err = rd.ReadFloat()
if err != nil {
return err
}
}
for j := withLen; j < nn; j++ {
if err := rd.DiscardNext(); err != nil {
return err
}
}
cmd.val[i] = loc
}
return nil
}
func (cmd *GeoSearchLocationCmd) Clone() Cmder {
var opt *GeoSearchLocationQuery
if cmd.opt != nil {
opt = &GeoSearchLocationQuery{
GeoSearchQuery: GeoSearchQuery{
Member: cmd.opt.Member,
Longitude: cmd.opt.Longitude,
Latitude: cmd.opt.Latitude,
Radius: cmd.opt.Radius,
RadiusUnit: cmd.opt.RadiusUnit,
BoxWidth: cmd.opt.BoxWidth,
BoxHeight: cmd.opt.BoxHeight,
BoxUnit: cmd.opt.BoxUnit,
Sort: cmd.opt.Sort,
Count: cmd.opt.Count,
CountAny: cmd.opt.CountAny,
},
WithCoord: cmd.opt.WithCoord,
WithDist: cmd.opt.WithDist,
WithHash: cmd.opt.WithHash,
}
}
var val []GeoLocation
if cmd.val != nil {
val = make([]GeoLocation, len(cmd.val))
copy(val, cmd.val)
}
return &GeoSearchLocationCmd{
baseCmd: cmd.cloneBaseCmd(),
opt: opt,
val: val,
}
}
//------------------------------------------------------------------------------
type GeoPos struct {
Longitude, Latitude float64
}
type GeoPosCmd struct {
baseCmd
val []*GeoPos
}
var _ Cmder = (*GeoPosCmd)(nil)
func NewGeoPosCmd(ctx context.Context, args ...interface{}) *GeoPosCmd {
return &GeoPosCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeGeoPos,
},
}
}
func (cmd *GeoPosCmd) SetVal(val []*GeoPos) {
cmd.val = val
}
func (cmd *GeoPosCmd) Val() []*GeoPos {
cmd.await()
return cmd.val
}
func (cmd *GeoPosCmd) Result() ([]*GeoPos, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *GeoPosCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *GeoPosCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]*GeoPos, n)
for i := 0; i < len(cmd.val); i++ {
err = rd.ReadFixedArrayLen(2)
if err != nil {
if err == Nil {
cmd.val[i] = nil
continue
}
return err
}
longitude, err := rd.ReadFloat()
if err != nil {
return err
}
latitude, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val[i] = &GeoPos{
Longitude: longitude,
Latitude: latitude,
}
}
return nil
}
func (cmd *GeoPosCmd) Clone() Cmder {
var val []*GeoPos
if cmd.val != nil {
val = make([]*GeoPos, len(cmd.val))
for i, pos := range cmd.val {
if pos != nil {
val[i] = &GeoPos{
Longitude: pos.Longitude,
Latitude: pos.Latitude,
}
}
}
}
return &GeoPosCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type CommandInfo struct {
Name string
Arity int8
Flags []string
ACLFlags []string
FirstKeyPos int8
LastKeyPos int8
StepCount int8
ReadOnly bool
CommandPolicy *routing.CommandPolicy
}
type CommandsInfoCmd struct {
baseCmd
val map[string]*CommandInfo
}
var _ Cmder = (*CommandsInfoCmd)(nil)
func NewCommandsInfoCmd(ctx context.Context, args ...interface{}) *CommandsInfoCmd {
return &CommandsInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeCommandsInfo,
},
}
}
func (cmd *CommandsInfoCmd) SetVal(val map[string]*CommandInfo) {
cmd.val = val
}
func (cmd *CommandsInfoCmd) Val() map[string]*CommandInfo {
cmd.await()
return cmd.val
}
func (cmd *CommandsInfoCmd) Result() (map[string]*CommandInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *CommandsInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *CommandsInfoCmd) readReply(rd *proto.Reader) error {
const numArgRedis5 = 6
const numArgRedis6 = 7
const numArgRedis7 = 10 // Also matches redis 8
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make(map[string]*CommandInfo, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
switch nn {
case numArgRedis5, numArgRedis6, numArgRedis7:
// ok
default:
return fmt.Errorf("redis: got %d elements in COMMAND reply, wanted 6/7/10", nn)
}
cmdInfo := &CommandInfo{}
if cmdInfo.Name, err = rd.ReadString(); err != nil {
return err
}
arity, err := rd.ReadInt()
if err != nil {
return err
}
cmdInfo.Arity = int8(arity)
flagLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmdInfo.Flags = make([]string, flagLen)
for f := 0; f < len(cmdInfo.Flags); f++ {
switch s, err := rd.ReadString(); {
case err == Nil:
cmdInfo.Flags[f] = ""
case err != nil:
return err
default:
if !cmdInfo.ReadOnly && s == "readonly" {
cmdInfo.ReadOnly = true
}
cmdInfo.Flags[f] = s
}
}
firstKeyPos, err := rd.ReadInt()
if err != nil {
return err
}
cmdInfo.FirstKeyPos = int8(firstKeyPos)
lastKeyPos, err := rd.ReadInt()
if err != nil {
return err
}
cmdInfo.LastKeyPos = int8(lastKeyPos)
stepCount, err := rd.ReadInt()
if err != nil {
return err
}
cmdInfo.StepCount = int8(stepCount)
if nn >= numArgRedis6 {
aclFlagLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmdInfo.ACLFlags = make([]string, aclFlagLen)
for f := 0; f < len(cmdInfo.ACLFlags); f++ {
switch s, err := rd.ReadString(); {
case err == Nil:
cmdInfo.ACLFlags[f] = ""
case err != nil:
return err
default:
cmdInfo.ACLFlags[f] = s
}
}
}
if nn >= numArgRedis7 {
// The 8th argument is an array of tips.
tipsLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
rawTips := make(map[string]string, tipsLen)
if cmdInfo.ReadOnly {
rawTips[routing.ReadOnlyCMD] = ""
}
for f := 0; f < tipsLen; f++ {
tip, err := rd.ReadString()
if err != nil {
return err
}
k, v, ok := strings.Cut(tip, ":")
if !ok {
// Handle tips that don't have a colon (like "nondeterministic_output")
rawTips[tip] = ""
} else {
// Handle normal key:value tips
rawTips[k] = v
}
}
cmdInfo.CommandPolicy = parseCommandPolicies(rawTips, cmdInfo.FirstKeyPos)
if err := rd.DiscardNext(); err != nil {
return err
}
if err := rd.DiscardNext(); err != nil {
return err
}
}
cmd.val[cmdInfo.Name] = cmdInfo
}
return nil
}
func (cmd *CommandsInfoCmd) Clone() Cmder {
var val map[string]*CommandInfo
if cmd.val != nil {
val = make(map[string]*CommandInfo, len(cmd.val))
for k, v := range cmd.val {
if v != nil {
newInfo := &CommandInfo{
Name: v.Name,
Arity: v.Arity,
FirstKeyPos: v.FirstKeyPos,
LastKeyPos: v.LastKeyPos,
StepCount: v.StepCount,
ReadOnly: v.ReadOnly,
CommandPolicy: v.CommandPolicy, // CommandPolicy can be shared as it's immutable
}
if v.Flags != nil {
newInfo.Flags = make([]string, len(v.Flags))
copy(newInfo.Flags, v.Flags)
}
if v.ACLFlags != nil {
newInfo.ACLFlags = make([]string, len(v.ACLFlags))
copy(newInfo.ACLFlags, v.ACLFlags)
}
val[k] = newInfo
}
}
}
return &CommandsInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type cmdsInfoCache struct {
fn func(ctx context.Context) (map[string]*CommandInfo, error)
once internal.Once
refreshLock sync.RWMutex
cmds map[string]*CommandInfo
// cmdsAtomic mirrors cmds for lock-free reads via Peek. cmds is only ever
// replaced wholesale (never mutated in place), so an atomic pointer load is a
// safe, contention-free read — Peek is on the hot per-command cluster routing
// path where the RWMutex.RLock showed up as a bottleneck under heavy load.
cmdsAtomic atomic.Pointer[map[string]*CommandInfo]
}
func newCmdsInfoCache(fn func(ctx context.Context) (map[string]*CommandInfo, error)) *cmdsInfoCache {
return &cmdsInfoCache{
fn: fn,
}
}
func (c *cmdsInfoCache) Get(ctx context.Context) (map[string]*CommandInfo, error) {
c.refreshLock.Lock()
defer c.refreshLock.Unlock()
err := c.once.Do(func() error {
cmds, err := c.fn(ctx)
if err != nil {
return err
}
lowerCmds := make(map[string]*CommandInfo, len(cmds))
// Extensions have cmd names in upper case. Convert them to lower case.
for k, v := range cmds {
lowerCmds[internal.ToLower(k)] = v
}
c.cmds = lowerCmds
c.cmdsAtomic.Store(&lowerCmds)
return nil
})
return c.cmds, err
}
func (c *cmdsInfoCache) Refresh() {
c.refreshLock.Lock()
defer c.refreshLock.Unlock()
c.once = internal.Once{}
}
// Peek returns the cached CommandInfo map without triggering a Redis round-trip.
// Returns nil when the cache is cold; callers should fall back to other heuristics.
// The read is lock-free (a single atomic load) and never blocks, even while a
// concurrent Get() is populating the cache — it simply returns nil until the
// first population publishes the map.
// The returned map and its entries MUST NOT be mutated by the caller.
func (c *cmdsInfoCache) Peek() map[string]*CommandInfo {
if c == nil {
return nil
}
// Lock-free read: cmds is replaced wholesale, never mutated in place.
if p := c.cmdsAtomic.Load(); p != nil {
return *p
}
return nil
}
// ------------------------------------------------------------------------------
const (
requestPolicy = "request_policy"
responsePolicy = "response_policy"
)
func parseCommandPolicies(commandInfoTips map[string]string, firstKeyPos int8) *routing.CommandPolicy {
req := routing.ReqDefault
resp := routing.RespDefaultKeyless
if firstKeyPos > 0 {
resp = routing.RespDefaultHashSlot
}
tips := make(map[string]string, len(commandInfoTips))
for k, v := range commandInfoTips {
if k == requestPolicy {
if p, err := routing.ParseRequestPolicy(v); err == nil {
req = p
}
continue
}
if k == responsePolicy {
if p, err := routing.ParseResponsePolicy(v); err == nil {
resp = p
}
continue
}
tips[k] = v
}
return &routing.CommandPolicy{Request: req, Response: resp, Tips: tips}
}
//------------------------------------------------------------------------------
type SlowLog struct {
ID int64
Time time.Time
Duration time.Duration
Args []string
// These are also optional fields emitted only by Redis 4.0 or greater:
// https://redis.io/commands/slowlog#output-format
ClientAddr string
ClientName string
// CommandArgc is the command's total argument count (including the command
// name), emitted only by Redis 8.10 or greater. It may exceed len(Args) when
// the slow log truncates the stored arguments (slowlog-max-argc, default 32).
CommandArgc int64
}
type SlowLogCmd struct {
baseCmd
val []SlowLog
}
var _ Cmder = (*SlowLogCmd)(nil)
func NewSlowLogCmd(ctx context.Context, args ...interface{}) *SlowLogCmd {
return &SlowLogCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeSlowLog,
},
}
}
func (cmd *SlowLogCmd) SetVal(val []SlowLog) {
cmd.val = val
}
func (cmd *SlowLogCmd) Val() []SlowLog {
cmd.await()
return cmd.val
}
func (cmd *SlowLogCmd) Result() ([]SlowLog, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *SlowLogCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *SlowLogCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]SlowLog, n)
for i := 0; i < len(cmd.val); i++ {
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
if nn < 4 {
return fmt.Errorf("redis: got %d elements in slowlog get, expected at least 4", nn)
}
if cmd.val[i].ID, err = rd.ReadInt(); err != nil {
return err
}
createdAt, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Time = time.Unix(createdAt, 0)
costs, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Duration = time.Duration(costs) * time.Microsecond
cmdLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
if cmdLen < 1 {
return fmt.Errorf("redis: got %d elements commands reply in slowlog get, expected at least 1", cmdLen)
}
cmd.val[i].Args = make([]string, cmdLen)
for f := 0; f < len(cmd.val[i].Args); f++ {
cmd.val[i].Args[f], err = rd.ReadString()
if err != nil {
return err
}
}
if nn >= 5 {
if cmd.val[i].ClientAddr, err = rd.ReadString(); err != nil {
return err
}
}
if nn >= 6 {
if cmd.val[i].ClientName, err = rd.ReadString(); err != nil {
return err
}
}
// Redis 8.10+ appends a 7th field: the command's total argument count.
if nn >= 7 {
if cmd.val[i].CommandArgc, err = rd.ReadInt(); err != nil {
return err
}
}
// Drain any elements past the 7 this parser knows about so a server
// that declares a longer entry array doesn't leave frames on the wire.
for j := 7; j < nn; j++ {
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
return nil
}
func (cmd *SlowLogCmd) Clone() Cmder {
var val []SlowLog
if cmd.val != nil {
val = make([]SlowLog, len(cmd.val))
for i, log := range cmd.val {
val[i] = SlowLog{
ID: log.ID,
Time: log.Time,
Duration: log.Duration,
ClientAddr: log.ClientAddr,
ClientName: log.ClientName,
CommandArgc: log.CommandArgc,
}
if log.Args != nil {
val[i].Args = make([]string, len(log.Args))
copy(val[i].Args, log.Args)
}
}
}
return &SlowLogCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//-----------------------------------------------------------------------
type Latency struct {
Name string
Time time.Time
Latest time.Duration
Max time.Duration
}
type LatencyCmd struct {
baseCmd
val []Latency
}
var _ Cmder = (*LatencyCmd)(nil)
func NewLatencyCmd(ctx context.Context, args ...interface{}) *LatencyCmd {
return &LatencyCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *LatencyCmd) SetVal(val []Latency) {
cmd.val = val
}
func (cmd *LatencyCmd) Val() []Latency {
cmd.await()
return cmd.val
}
func (cmd *LatencyCmd) Result() ([]Latency, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *LatencyCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *LatencyCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]Latency, n)
for i := 0; i < len(cmd.val); i++ {
nn, err := rd.ReadArrayLen()
if err != nil {
return err
}
if nn < 4 {
return fmt.Errorf("redis: got %d elements in latency get, expected at least 4", nn)
}
if cmd.val[i].Name, err = rd.ReadString(); err != nil {
return err
}
createdAt, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Time = time.Unix(createdAt, 0)
latest, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Latest = time.Duration(latest) * time.Millisecond
maximum, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Max = time.Duration(maximum) * time.Millisecond
// Drain any elements beyond the 4 this parser reads so a server that
// declares a longer entry array can't leave frames on the wire.
for j := 4; j < nn; j++ {
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
return nil
}
func (cmd *LatencyCmd) Clone() Cmder {
var val []Latency
if cmd.val != nil {
val = make([]Latency, len(cmd.val))
copy(val, cmd.val)
}
return &LatencyCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//-----------------------------------------------------------------------
// HotKeysSlotRange represents a slot or slot range in the response.
// Single element slice = individual slot, two element slice = slot range [start, end].
type HotKeysSlotRange []int64
// HotKeysKeyEntry represents a hot key entry with its metric value.
type HotKeysKeyEntry struct {
Key string
Value interface{} // Can be int64 or string
}
// HotKeysResult represents the response data from HOTKEYS GET command.
// Field names match the Redis response format.
type HotKeysResult struct {
TrackingActive bool
SampleRatio uint8
SelectedSlots []HotKeysSlotRange
SampledCommandsSelectedSlots time.Duration // Present when sample-ratio > 1 and selected-slots is not empty
AllCommandsSelectedSlots time.Duration // Present when selected-slots is not empty
AllCommandsAllSlots time.Duration
NetBytesSampledCommandsSelectedSlots int64 // Present when sample-ratio > 1 and selected-slots is not empty
NetBytesAllCommandsSelectedSlots int64 // Present when selected-slots is not empty
NetBytesAllCommandsAllSlots int64
CollectionStartTime time.Time
CollectionDuration time.Duration
UsedCPUSys time.Duration
UsedCPUUser time.Duration
TotalNetBytes int64
ByCPUTime []HotKeysKeyEntry
ByNetBytes []HotKeysKeyEntry
}
type HotKeysCmd struct {
baseCmd
val *HotKeysResult
}
var _ Cmder = (*HotKeysCmd)(nil)
func NewHotKeysCmd(ctx context.Context, args ...interface{}) *HotKeysCmd {
return &HotKeysCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeHotKeys,
},
}
}
func (cmd *HotKeysCmd) SetVal(val *HotKeysResult) {
cmd.val = val
}
func (cmd *HotKeysCmd) Val() *HotKeysResult {
cmd.await()
return cmd.val
}
func (cmd *HotKeysCmd) Result() (*HotKeysResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *HotKeysCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *HotKeysCmd) readReply(rd *proto.Reader) error {
// HOTKEYS GET response is wrapped in an array for aggregation support
arrayLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
if arrayLen == 0 {
// Empty array means no tracking was started or after reset
cmd.val = nil
return nil
}
// Read the first (and typically only) element which is a map
n, err := rd.ReadMapLen()
if err != nil {
return err
}
result := &HotKeysResult{}
data := make(map[string]interface{}, n)
for i := 0; i < n; i++ {
k, err := rd.ReadString()
if err != nil {
return err
}
v, err := rd.ReadReply()
if err != nil {
if err == Nil {
data[k] = Nil
continue
}
if err, ok := err.(proto.RedisError); ok {
data[k] = err
continue
}
return err
}
data[k] = v
}
if v, ok := data["tracking-active"].(int64); ok {
result.TrackingActive = v == 1
}
if v, ok := data["sample-ratio"].(int64); ok {
result.SampleRatio = uint8(v)
}
if v, ok := data["selected-slots"].([]interface{}); ok {
result.SelectedSlots = make([]HotKeysSlotRange, 0, len(v))
for _, slot := range v {
switch s := slot.(type) {
case int64:
// Single slot
result.SelectedSlots = append(result.SelectedSlots, HotKeysSlotRange{s})
case []interface{}:
// Slot range
slotRange := make(HotKeysSlotRange, 0, len(s))
for _, sr := range s {
if val, ok := sr.(int64); ok {
slotRange = append(slotRange, val)
}
}
result.SelectedSlots = append(result.SelectedSlots, slotRange)
}
}
}
if v, ok := data["sampled-commands-selected-slots-us"].(int64); ok {
result.SampledCommandsSelectedSlots = time.Duration(v) * time.Microsecond
}
if v, ok := data["all-commands-selected-slots-us"].(int64); ok {
result.AllCommandsSelectedSlots = time.Duration(v) * time.Microsecond
}
if v, ok := data["all-commands-all-slots-us"].(int64); ok {
result.AllCommandsAllSlots = time.Duration(v) * time.Microsecond
}
if v, ok := data["net-bytes-sampled-commands-selected-slots"].(int64); ok {
result.NetBytesSampledCommandsSelectedSlots = v
}
if v, ok := data["net-bytes-all-commands-selected-slots"].(int64); ok {
result.NetBytesAllCommandsSelectedSlots = v
}
if v, ok := data["net-bytes-all-commands-all-slots"].(int64); ok {
result.NetBytesAllCommandsAllSlots = v
}
if v, ok := data["collection-start-time-unix-ms"].(int64); ok {
result.CollectionStartTime = time.UnixMilli(v)
}
if v, ok := data["collection-duration-ms"].(int64); ok {
result.CollectionDuration = time.Duration(v) * time.Millisecond
}
if v, ok := data["used-cpu-sys-ms"].(int64); ok {
result.UsedCPUSys = time.Duration(v) * time.Millisecond
}
if v, ok := data["used-cpu-user-ms"].(int64); ok {
result.UsedCPUUser = time.Duration(v) * time.Millisecond
}
if v, ok := data["total-net-bytes"].(int64); ok {
result.TotalNetBytes = v
}
if v, ok := data["by-cpu-time-us"].([]interface{}); ok {
result.ByCPUTime = parseHotKeysKeyEntries(v)
}
if v, ok := data["by-net-bytes"].([]interface{}); ok {
result.ByNetBytes = parseHotKeysKeyEntries(v)
}
// Only the first element of the outer array is parsed; drain the rest so a
// server that wraps more than one element doesn't leave frames on the wire.
for i := 1; i < arrayLen; i++ {
if err := rd.DiscardNext(); err != nil {
return err
}
}
cmd.val = result
return nil
}
// parseHotKeysKeyEntries parses the key-value pairs from HOTKEYS GET response.
func parseHotKeysKeyEntries(v []interface{}) []HotKeysKeyEntry {
entries := make([]HotKeysKeyEntry, 0, len(v)/2)
for i := 0; i < len(v); i += 2 {
if i+1 < len(v) {
key, keyOk := v[i].(string)
if keyOk {
entries = append(entries, HotKeysKeyEntry{
Key: key,
Value: v[i+1], // Can be int64 or string
})
}
}
}
return entries
}
func (cmd *HotKeysCmd) Clone() Cmder {
var val *HotKeysResult
if cmd.val != nil {
val = &HotKeysResult{
TrackingActive: cmd.val.TrackingActive,
SampleRatio: cmd.val.SampleRatio,
SampledCommandsSelectedSlots: cmd.val.SampledCommandsSelectedSlots,
AllCommandsSelectedSlots: cmd.val.AllCommandsSelectedSlots,
AllCommandsAllSlots: cmd.val.AllCommandsAllSlots,
NetBytesSampledCommandsSelectedSlots: cmd.val.NetBytesSampledCommandsSelectedSlots,
NetBytesAllCommandsSelectedSlots: cmd.val.NetBytesAllCommandsSelectedSlots,
NetBytesAllCommandsAllSlots: cmd.val.NetBytesAllCommandsAllSlots,
CollectionStartTime: cmd.val.CollectionStartTime,
CollectionDuration: cmd.val.CollectionDuration,
UsedCPUSys: cmd.val.UsedCPUSys,
UsedCPUUser: cmd.val.UsedCPUUser,
TotalNetBytes: cmd.val.TotalNetBytes,
}
if cmd.val.SelectedSlots != nil {
val.SelectedSlots = make([]HotKeysSlotRange, len(cmd.val.SelectedSlots))
for i, sr := range cmd.val.SelectedSlots {
val.SelectedSlots[i] = make(HotKeysSlotRange, len(sr))
copy(val.SelectedSlots[i], sr)
}
}
if cmd.val.ByCPUTime != nil {
val.ByCPUTime = make([]HotKeysKeyEntry, len(cmd.val.ByCPUTime))
copy(val.ByCPUTime, cmd.val.ByCPUTime)
}
if cmd.val.ByNetBytes != nil {
val.ByNetBytes = make([]HotKeysKeyEntry, len(cmd.val.ByNetBytes))
copy(val.ByNetBytes, cmd.val.ByNetBytes)
}
}
return &HotKeysCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//-----------------------------------------------------------------------
type MapStringInterfaceCmd struct {
baseCmd
val map[string]interface{}
}
var _ Cmder = (*MapStringInterfaceCmd)(nil)
func NewMapStringInterfaceCmd(ctx context.Context, args ...interface{}) *MapStringInterfaceCmd {
return &MapStringInterfaceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringInterface,
},
}
}
func (cmd *MapStringInterfaceCmd) SetVal(val map[string]interface{}) {
cmd.val = val
}
func (cmd *MapStringInterfaceCmd) Val() map[string]interface{} {
cmd.await()
return cmd.val
}
func (cmd *MapStringInterfaceCmd) Result() (map[string]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringInterfaceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapStringInterfaceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = make(map[string]interface{}, n)
for i := 0; i < n; i++ {
k, err := rd.ReadString()
if err != nil {
return err
}
v, err := rd.ReadReply()
if err != nil {
if err == Nil {
cmd.val[k] = Nil
continue
}
if err, ok := err.(proto.RedisError); ok {
cmd.val[k] = err
continue
}
return err
}
cmd.val[k] = v
}
return nil
}
func (cmd *MapStringInterfaceCmd) Clone() Cmder {
var val map[string]interface{}
if cmd.val != nil {
val = make(map[string]interface{}, len(cmd.val))
for k, v := range cmd.val {
val[k] = v
}
}
return &MapStringInterfaceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//-----------------------------------------------------------------------
type MapStringStringSliceCmd struct {
baseCmd
val []map[string]string
}
var _ Cmder = (*MapStringStringSliceCmd)(nil)
func NewMapStringStringSliceCmd(ctx context.Context, args ...interface{}) *MapStringStringSliceCmd {
return &MapStringStringSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringStringSlice,
},
}
}
func (cmd *MapStringStringSliceCmd) SetVal(val []map[string]string) {
cmd.val = val
}
func (cmd *MapStringStringSliceCmd) Val() []map[string]string {
cmd.await()
return cmd.val
}
func (cmd *MapStringStringSliceCmd) Result() ([]map[string]string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringStringSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapStringStringSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]map[string]string, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val[i] = make(map[string]string, nn)
for f := 0; f < nn; f++ {
k, err := rd.ReadString()
if err != nil {
return err
}
v, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[i][k] = v
}
}
return nil
}
func (cmd *MapStringStringSliceCmd) Clone() Cmder {
var val []map[string]string
if cmd.val != nil {
val = make([]map[string]string, len(cmd.val))
for i, m := range cmd.val {
if m != nil {
val[i] = make(map[string]string, len(m))
for k, v := range m {
val[i][k] = v
}
}
}
}
return &MapStringStringSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// -----------------------------------------------------------------------
// MapMapStringInterfaceCmd represents a command that returns a map of strings to interface{}.
type MapMapStringInterfaceCmd struct {
baseCmd
val map[string]interface{}
}
func NewMapMapStringInterfaceCmd(ctx context.Context, args ...interface{}) *MapMapStringInterfaceCmd {
return &MapMapStringInterfaceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapMapStringInterface,
},
}
}
func (cmd *MapMapStringInterfaceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapMapStringInterfaceCmd) SetVal(val map[string]interface{}) {
cmd.val = val
}
func (cmd *MapMapStringInterfaceCmd) Result() (map[string]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapMapStringInterfaceCmd) Val() map[string]interface{} {
cmd.await()
return cmd.val
}
// readReply will try to parse the reply from the proto.Reader for both resp2 and resp3
func (cmd *MapMapStringInterfaceCmd) readReply(rd *proto.Reader) (err error) {
data, err := rd.ReadReply()
if err != nil {
return err
}
resultMap := map[string]interface{}{}
switch midResponse := data.(type) {
case map[interface{}]interface{}: // resp3 will return map
for k, v := range midResponse {
stringKey, ok := k.(string)
if !ok {
return fmt.Errorf("redis: invalid map key %#v", k)
}
resultMap[stringKey] = v
}
case []interface{}: // resp2 will return array of arrays
n := len(midResponse)
for i := 0; i < n; i++ {
finalArr, ok := midResponse[i].([]interface{}) // final array that we need to transform to map
if !ok {
return fmt.Errorf("redis: unexpected response %#v", data)
}
m := len(finalArr)
if m%2 != 0 { // since this should be map, keys should be even number
return fmt.Errorf("redis: unexpected response %#v", data)
}
for j := 0; j < m; j += 2 {
stringKey, ok := finalArr[j].(string) // the first one
if !ok {
return fmt.Errorf("redis: invalid map key %#v", finalArr[i])
}
resultMap[stringKey] = finalArr[j+1] // second one is value
}
}
default:
return fmt.Errorf("redis: unexpected response %#v", data)
}
cmd.val = resultMap
return nil
}
func (cmd *MapMapStringInterfaceCmd) Clone() Cmder {
var val map[string]interface{}
if cmd.val != nil {
val = make(map[string]interface{}, len(cmd.val))
for k, v := range cmd.val {
val[k] = v
}
}
return &MapMapStringInterfaceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//-----------------------------------------------------------------------
type MapStringInterfaceSliceCmd struct {
baseCmd
val []map[string]interface{}
}
var _ Cmder = (*MapStringInterfaceSliceCmd)(nil)
func NewMapStringInterfaceSliceCmd(ctx context.Context, args ...interface{}) *MapStringInterfaceSliceCmd {
return &MapStringInterfaceSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeMapStringInterfaceSlice,
},
}
}
func (cmd *MapStringInterfaceSliceCmd) SetVal(val []map[string]interface{}) {
cmd.val = val
}
func (cmd *MapStringInterfaceSliceCmd) Val() []map[string]interface{} {
cmd.await()
return cmd.val
}
func (cmd *MapStringInterfaceSliceCmd) Result() ([]map[string]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *MapStringInterfaceSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *MapStringInterfaceSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]map[string]interface{}, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val[i] = make(map[string]interface{}, nn)
for f := 0; f < nn; f++ {
k, err := rd.ReadString()
if err != nil {
return err
}
v, err := rd.ReadReply()
if err != nil {
if err != Nil {
return err
}
}
cmd.val[i][k] = v
}
}
return nil
}
func (cmd *MapStringInterfaceSliceCmd) Clone() Cmder {
var val []map[string]interface{}
if cmd.val != nil {
val = make([]map[string]interface{}, len(cmd.val))
for i, m := range cmd.val {
if m != nil {
val[i] = make(map[string]interface{}, len(m))
for k, v := range m {
val[i][k] = v
}
}
}
}
return &MapStringInterfaceSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
type KeyValuesCmd struct {
baseCmd
key string
val []string
}
var _ Cmder = (*KeyValuesCmd)(nil)
func NewKeyValuesCmd(ctx context.Context, args ...interface{}) *KeyValuesCmd {
return &KeyValuesCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeKeyValues,
},
}
}
func (cmd *KeyValuesCmd) SetVal(key string, val []string) {
cmd.key = key
cmd.val = val
}
func (cmd *KeyValuesCmd) Val() (string, []string) {
cmd.await()
return cmd.key, cmd.val
}
func (cmd *KeyValuesCmd) Result() (string, []string, error) {
cmd.await()
return cmd.key, cmd.val, cmd.err
}
func (cmd *KeyValuesCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *KeyValuesCmd) readReply(rd *proto.Reader) (err error) {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
cmd.key, err = rd.ReadString()
if err != nil {
return err
}
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]string, n)
for i := 0; i < n; i++ {
cmd.val[i], err = rd.ReadString()
if err != nil {
return err
}
}
return nil
}
func (cmd *KeyValuesCmd) Clone() Cmder {
var val []string
if cmd.val != nil {
val = make([]string, len(cmd.val))
copy(val, cmd.val)
}
return &KeyValuesCmd{
baseCmd: cmd.cloneBaseCmd(),
key: cmd.key,
val: val,
}
}
//------------------------------------------------------------------------------
type ZSliceWithKeyCmd struct {
baseCmd
key string
val []Z
}
var _ Cmder = (*ZSliceWithKeyCmd)(nil)
func NewZSliceWithKeyCmd(ctx context.Context, args ...interface{}) *ZSliceWithKeyCmd {
return &ZSliceWithKeyCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeZSliceWithKey,
},
}
}
func (cmd *ZSliceWithKeyCmd) SetVal(key string, val []Z) {
cmd.key = key
cmd.val = val
}
func (cmd *ZSliceWithKeyCmd) Val() (string, []Z) {
cmd.await()
return cmd.key, cmd.val
}
func (cmd *ZSliceWithKeyCmd) Result() (string, []Z, error) {
cmd.await()
return cmd.key, cmd.val, cmd.err
}
func (cmd *ZSliceWithKeyCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ZSliceWithKeyCmd) readReply(rd *proto.Reader) (err error) {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
cmd.key, err = rd.ReadString()
if err != nil {
return err
}
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
typ, err := rd.PeekReplyType()
if err != nil {
return err
}
array := typ == proto.RespArray
if array {
cmd.val = make([]Z, n)
} else {
if n%2 != 0 {
return fmt.Errorf("redis: got %d elements in the sorted set array, wanted a multiple of 2", n)
}
cmd.val = make([]Z, n/2)
}
for i := 0; i < len(cmd.val); i++ {
if array {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
}
if cmd.val[i].Member, err = rd.ReadString(); err != nil {
return err
}
if cmd.val[i].Score, err = rd.ReadFloat(); err != nil {
return err
}
}
return nil
}
func (cmd *ZSliceWithKeyCmd) Clone() Cmder {
var val []Z
if cmd.val != nil {
val = make([]Z, len(cmd.val))
copy(val, cmd.val)
}
return &ZSliceWithKeyCmd{
baseCmd: cmd.cloneBaseCmd(),
key: cmd.key,
val: val,
}
}
type Function struct {
Name string
Description string
Flags []string
}
type Library struct {
Name string
Engine string
Functions []Function
Code string
}
type FunctionListCmd struct {
baseCmd
val []Library
}
var _ Cmder = (*FunctionListCmd)(nil)
func NewFunctionListCmd(ctx context.Context, args ...interface{}) *FunctionListCmd {
return &FunctionListCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFunctionList,
},
}
}
func (cmd *FunctionListCmd) SetVal(val []Library) {
cmd.val = val
}
func (cmd *FunctionListCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FunctionListCmd) Val() []Library {
cmd.await()
return cmd.val
}
func (cmd *FunctionListCmd) Result() ([]Library, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FunctionListCmd) First() (*Library, error) {
cmd.await()
if cmd.err != nil {
return nil, cmd.err
}
if len(cmd.val) > 0 {
return &cmd.val[0], nil
}
return nil, Nil
}
func (cmd *FunctionListCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
libraries := make([]Library, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return err
}
library := Library{}
for f := 0; f < nn; f++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "library_name":
library.Name, err = rd.ReadString()
case "engine":
library.Engine, err = rd.ReadString()
case "functions":
library.Functions, err = cmd.readFunctions(rd)
case "library_code":
library.Code, err = rd.ReadString()
default:
return fmt.Errorf("redis: function list unexpected key %s", key)
}
if err != nil {
return err
}
}
libraries[i] = library
}
cmd.val = libraries
return nil
}
func (cmd *FunctionListCmd) readFunctions(rd *proto.Reader) ([]Function, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
functions := make([]Function, n)
for i := 0; i < n; i++ {
nn, err := rd.ReadMapLen()
if err != nil {
return nil, err
}
function := Function{}
for f := 0; f < nn; f++ {
key, err := rd.ReadString()
if err != nil {
return nil, err
}
switch key {
case "name":
if function.Name, err = rd.ReadString(); err != nil {
return nil, err
}
case "description":
if function.Description, err = rd.ReadString(); err != nil && err != Nil {
return nil, err
}
case "flags":
// resp set
nx, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
function.Flags = make([]string, nx)
for j := 0; j < nx; j++ {
if function.Flags[j], err = rd.ReadString(); err != nil {
return nil, err
}
}
default:
return nil, fmt.Errorf("redis: function list unexpected key %s", key)
}
}
functions[i] = function
}
return functions, nil
}
func (cmd *FunctionListCmd) Clone() Cmder {
var val []Library
if cmd.val != nil {
val = make([]Library, len(cmd.val))
for i, lib := range cmd.val {
val[i] = Library{
Name: lib.Name,
Engine: lib.Engine,
Code: lib.Code,
}
if lib.Functions != nil {
val[i].Functions = make([]Function, len(lib.Functions))
for j, fn := range lib.Functions {
val[i].Functions[j] = Function{
Name: fn.Name,
Description: fn.Description,
}
if fn.Flags != nil {
val[i].Functions[j].Flags = make([]string, len(fn.Flags))
copy(val[i].Functions[j].Flags, fn.Flags)
}
}
}
}
}
return &FunctionListCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// FunctionStats contains information about the scripts currently executing on the server, and the available engines
// - Engines:
// Statistics about the engine like number of functions and number of libraries
// - RunningScript:
// The script currently running on the shard we're connecting to.
// For Redis Enterprise and Redis Cloud, this represents the
// function with the longest running time, across all the running functions, on all shards
// - RunningScripts
// All scripts currently running in a Redis Enterprise clustered database.
// Only available on Redis Enterprise
type FunctionStats struct {
Engines []Engine
isRunning bool
rs RunningScript
allrs []RunningScript
}
func (fs *FunctionStats) Running() bool {
return fs.isRunning
}
func (fs *FunctionStats) RunningScript() (RunningScript, bool) {
return fs.rs, fs.isRunning
}
// AllRunningScripts returns all scripts currently running in a Redis Enterprise clustered database.
// Only available on Redis Enterprise
func (fs *FunctionStats) AllRunningScripts() []RunningScript {
return fs.allrs
}
type RunningScript struct {
Name string
Command []string
Duration time.Duration
}
type Engine struct {
Language string
LibrariesCount int64
FunctionsCount int64
}
type FunctionStatsCmd struct {
baseCmd
val FunctionStats
}
var _ Cmder = (*FunctionStatsCmd)(nil)
func NewFunctionStatsCmd(ctx context.Context, args ...interface{}) *FunctionStatsCmd {
return &FunctionStatsCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFunctionStats,
},
}
}
func (cmd *FunctionStatsCmd) SetVal(val FunctionStats) {
cmd.val = val
}
func (cmd *FunctionStatsCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FunctionStatsCmd) Val() FunctionStats {
cmd.await()
return cmd.val
}
func (cmd *FunctionStatsCmd) Result() (FunctionStats, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FunctionStatsCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
var result FunctionStats
for f := 0; f < n; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "running_script":
result.rs, result.isRunning, err = cmd.readRunningScript(rd)
case "engines":
result.Engines, err = cmd.readEngines(rd)
case "all_running_scripts": // Redis Enterprise only
result.allrs, result.isRunning, err = cmd.readRunningScripts(rd)
default:
return fmt.Errorf("redis: function stats unexpected key %s", key)
}
if err != nil {
return err
}
}
cmd.val = result
return nil
}
func (cmd *FunctionStatsCmd) readRunningScript(rd *proto.Reader) (RunningScript, bool, error) {
err := rd.ReadFixedMapLen(3)
if err != nil {
if err == Nil {
return RunningScript{}, false, nil
}
return RunningScript{}, false, err
}
var runningScript RunningScript
for i := 0; i < 3; i++ {
key, err := rd.ReadString()
if err != nil {
return RunningScript{}, false, err
}
switch key {
case "name":
runningScript.Name, err = rd.ReadString()
case "duration_ms":
runningScript.Duration, err = cmd.readDuration(rd)
case "command":
runningScript.Command, err = cmd.readCommand(rd)
default:
return RunningScript{}, false, fmt.Errorf("redis: function stats unexpected running_script key %s", key)
}
if err != nil {
return RunningScript{}, false, err
}
}
return runningScript, true, nil
}
func (cmd *FunctionStatsCmd) readEngines(rd *proto.Reader) ([]Engine, error) {
n, err := rd.ReadMapLen()
if err != nil {
return nil, err
}
engines := make([]Engine, 0, n)
for i := 0; i < n; i++ {
engine := Engine{}
engine.Language, err = rd.ReadString()
if err != nil {
return nil, err
}
err = rd.ReadFixedMapLen(2)
if err != nil {
return nil, fmt.Errorf("redis: function stats unexpected %s engine map length", engine.Language)
}
for i := 0; i < 2; i++ {
key, err := rd.ReadString()
if err != nil {
return nil, err
}
switch key {
case "libraries_count":
engine.LibrariesCount, err = rd.ReadInt()
case "functions_count":
engine.FunctionsCount, err = rd.ReadInt()
default:
// Unknown field: drain its value so the reader stays aligned
// with the rest of the reply.
err = rd.DiscardNext()
}
if err != nil {
return nil, err
}
}
engines = append(engines, engine)
}
return engines, nil
}
func (cmd *FunctionStatsCmd) readDuration(rd *proto.Reader) (time.Duration, error) {
t, err := rd.ReadInt()
if err != nil {
return time.Duration(0), err
}
return time.Duration(t) * time.Millisecond, nil
}
func (cmd *FunctionStatsCmd) readCommand(rd *proto.Reader) ([]string, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
command := make([]string, 0, n)
for i := 0; i < n; i++ {
x, err := rd.ReadString()
if err != nil {
return nil, err
}
command = append(command, x)
}
return command, nil
}
func (cmd *FunctionStatsCmd) readRunningScripts(rd *proto.Reader) ([]RunningScript, bool, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, false, err
}
runningScripts := make([]RunningScript, 0, n)
for i := 0; i < n; i++ {
rs, _, err := cmd.readRunningScript(rd)
if err != nil {
return nil, false, err
}
runningScripts = append(runningScripts, rs)
}
return runningScripts, len(runningScripts) > 0, nil
}
func (cmd *FunctionStatsCmd) Clone() Cmder {
val := FunctionStats{
isRunning: cmd.val.isRunning,
rs: cmd.val.rs, // RunningScript is a simple struct, can be copied directly
}
if cmd.val.Engines != nil {
val.Engines = make([]Engine, len(cmd.val.Engines))
copy(val.Engines, cmd.val.Engines)
}
if cmd.val.allrs != nil {
val.allrs = make([]RunningScript, len(cmd.val.allrs))
for i, rs := range cmd.val.allrs {
val.allrs[i] = RunningScript{
Name: rs.Name,
Duration: rs.Duration,
}
if rs.Command != nil {
val.allrs[i].Command = make([]string, len(rs.Command))
copy(val.allrs[i].Command, rs.Command)
}
}
}
return &FunctionStatsCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
//------------------------------------------------------------------------------
// LCSQuery is a parameter used for the LCS command
type LCSQuery struct {
Key1 string
Key2 string
Len bool
Idx bool
MinMatchLen int
WithMatchLen bool
}
// LCSMatch is the result set of the LCS command.
type LCSMatch struct {
MatchString string
Matches []LCSMatchedPosition
Len int64
}
type LCSMatchedPosition struct {
Key1 LCSPosition
Key2 LCSPosition
// only for withMatchLen is true
MatchLen int64
}
type LCSPosition struct {
Start int64
End int64
}
type LCSCmd struct {
baseCmd
// 1: match string
// 2: match len
// 3: match idx LCSMatch
readType uint8
val *LCSMatch
}
func NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd {
args := make([]interface{}, 3, 7)
args[0] = "lcs"
args[1] = q.Key1
args[2] = q.Key2
cmd := &LCSCmd{readType: 1}
if q.Len {
cmd.readType = 2
args = append(args, "len")
} else if q.Idx {
cmd.readType = 3
args = append(args, "idx")
if q.MinMatchLen != 0 {
args = append(args, "minmatchlen", q.MinMatchLen)
}
if q.WithMatchLen {
args = append(args, "withmatchlen")
}
}
cmd.baseCmd = baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeLCS,
}
return cmd
}
func (cmd *LCSCmd) SetVal(val *LCSMatch) {
cmd.val = val
}
func (cmd *LCSCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *LCSCmd) Val() *LCSMatch {
cmd.await()
return cmd.val
}
func (cmd *LCSCmd) Result() (*LCSMatch, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *LCSCmd) readReply(rd *proto.Reader) (err error) {
lcs := &LCSMatch{}
switch cmd.readType {
case 1:
// match string
if lcs.MatchString, err = rd.ReadString(); err != nil {
return err
}
case 2:
// match len
if lcs.Len, err = rd.ReadInt(); err != nil {
return err
}
case 3:
// read LCSMatch
if err = rd.ReadFixedMapLen(2); err != nil {
return err
}
// read matches or len field
for i := 0; i < 2; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "matches":
// read array of matched positions
if lcs.Matches, err = cmd.readMatchedPositions(rd); err != nil {
return err
}
case "len":
// read match length
if lcs.Len, err = rd.ReadInt(); err != nil {
return err
}
default:
// Unknown field: drain its value so the reader stays aligned
// with the rest of the reply.
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
}
cmd.val = lcs
return nil
}
func (cmd *LCSCmd) readMatchedPositions(rd *proto.Reader) ([]LCSMatchedPosition, error) {
n, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
positions := make([]LCSMatchedPosition, n)
for i := 0; i < n; i++ {
pn, err := rd.ReadArrayLen()
if err != nil {
return nil, err
}
if positions[i].Key1, err = cmd.readPosition(rd); err != nil {
return nil, err
}
if positions[i].Key2, err = cmd.readPosition(rd); err != nil {
return nil, err
}
// read match length if WithMatchLen is true
if pn > 2 {
if positions[i].MatchLen, err = rd.ReadInt(); err != nil {
return nil, err
}
}
}
return positions, nil
}
func (cmd *LCSCmd) readPosition(rd *proto.Reader) (pos LCSPosition, err error) {
if err = rd.ReadFixedArrayLen(2); err != nil {
return pos, err
}
if pos.Start, err = rd.ReadInt(); err != nil {
return pos, err
}
if pos.End, err = rd.ReadInt(); err != nil {
return pos, err
}
return pos, nil
}
func (cmd *LCSCmd) Clone() Cmder {
var val *LCSMatch
if cmd.val != nil {
val = &LCSMatch{
MatchString: cmd.val.MatchString,
Len: cmd.val.Len,
}
if cmd.val.Matches != nil {
val.Matches = make([]LCSMatchedPosition, len(cmd.val.Matches))
copy(val.Matches, cmd.val.Matches)
}
}
return &LCSCmd{
baseCmd: cmd.cloneBaseCmd(),
readType: cmd.readType,
val: val,
}
}
// ------------------------------------------------------------------------
type KeyFlags struct {
Key string
Flags []string
}
type KeyFlagsCmd struct {
baseCmd
val []KeyFlags
}
var _ Cmder = (*KeyFlagsCmd)(nil)
func NewKeyFlagsCmd(ctx context.Context, args ...interface{}) *KeyFlagsCmd {
return &KeyFlagsCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeKeyFlags,
},
}
}
func (cmd *KeyFlagsCmd) SetVal(val []KeyFlags) {
cmd.val = val
}
func (cmd *KeyFlagsCmd) Val() []KeyFlags {
cmd.await()
return cmd.val
}
func (cmd *KeyFlagsCmd) Result() ([]KeyFlags, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *KeyFlagsCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *KeyFlagsCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
if n == 0 {
cmd.val = make([]KeyFlags, 0)
return nil
}
cmd.val = make([]KeyFlags, n)
for i := 0; i < len(cmd.val); i++ {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
if cmd.val[i].Key, err = rd.ReadString(); err != nil {
return err
}
flagsLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val[i].Flags = make([]string, flagsLen)
for j := 0; j < flagsLen; j++ {
if cmd.val[i].Flags[j], err = rd.ReadString(); err != nil {
return err
}
}
}
return nil
}
func (cmd *KeyFlagsCmd) Clone() Cmder {
var val []KeyFlags
if cmd.val != nil {
val = make([]KeyFlags, len(cmd.val))
for i, kf := range cmd.val {
val[i] = KeyFlags{
Key: kf.Key,
}
if kf.Flags != nil {
val[i].Flags = make([]string, len(kf.Flags))
copy(val[i].Flags, kf.Flags)
}
}
}
return &KeyFlagsCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// ---------------------------------------------------------------------------------------------------
type ClusterLink struct {
Direction string
Node string
CreateTime int64
Events string
SendBufferAllocated int64
SendBufferUsed int64
}
type ClusterLinksCmd struct {
baseCmd
val []ClusterLink
}
var _ Cmder = (*ClusterLinksCmd)(nil)
func NewClusterLinksCmd(ctx context.Context, args ...interface{}) *ClusterLinksCmd {
return &ClusterLinksCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeClusterLinks,
},
}
}
func (cmd *ClusterLinksCmd) SetVal(val []ClusterLink) {
cmd.val = val
}
func (cmd *ClusterLinksCmd) Val() []ClusterLink {
cmd.await()
return cmd.val
}
func (cmd *ClusterLinksCmd) Result() ([]ClusterLink, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ClusterLinksCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ClusterLinksCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]ClusterLink, n)
for i := 0; i < len(cmd.val); i++ {
m, err := rd.ReadMapLen()
if err != nil {
return err
}
for j := 0; j < m; j++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "direction":
cmd.val[i].Direction, err = rd.ReadString()
case "node":
cmd.val[i].Node, err = rd.ReadString()
case "create-time":
cmd.val[i].CreateTime, err = rd.ReadInt()
case "events":
cmd.val[i].Events, err = rd.ReadString()
case "send-buffer-allocated":
cmd.val[i].SendBufferAllocated, err = rd.ReadInt()
case "send-buffer-used":
cmd.val[i].SendBufferUsed, err = rd.ReadInt()
default:
return fmt.Errorf("redis: unexpected key %q in CLUSTER LINKS reply", key)
}
if err != nil {
return err
}
}
}
return nil
}
func (cmd *ClusterLinksCmd) Clone() Cmder {
var val []ClusterLink
if cmd.val != nil {
val = make([]ClusterLink, len(cmd.val))
copy(val, cmd.val)
}
return &ClusterLinksCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// ------------------------------------------------------------------------------------------------------------------
type SlotRange struct {
Start int64
End int64
}
type Node struct {
ID string
Endpoint string
IP string
Hostname string
Port int64
TLSPort int64
Role string
ReplicationOffset int64
Health string
}
type ClusterShard struct {
Slots []SlotRange
Nodes []Node
}
type ClusterShardsCmd struct {
baseCmd
val []ClusterShard
}
var _ Cmder = (*ClusterShardsCmd)(nil)
func NewClusterShardsCmd(ctx context.Context, args ...interface{}) *ClusterShardsCmd {
return &ClusterShardsCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeClusterShards,
},
}
}
func (cmd *ClusterShardsCmd) SetVal(val []ClusterShard) {
cmd.val = val
}
func (cmd *ClusterShardsCmd) Val() []ClusterShard {
cmd.await()
return cmd.val
}
func (cmd *ClusterShardsCmd) Result() ([]ClusterShard, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ClusterShardsCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ClusterShardsCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]ClusterShard, n)
for i := 0; i < n; i++ {
m, err := rd.ReadMapLen()
if err != nil {
return err
}
for j := 0; j < m; j++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "slots":
l, err := rd.ReadArrayLen()
if err != nil {
return err
}
for k := 0; k < l; k += 2 {
start, err := rd.ReadInt()
if err != nil {
return err
}
end, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Slots = append(cmd.val[i].Slots, SlotRange{Start: start, End: end})
}
case "nodes":
nodesLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val[i].Nodes = make([]Node, nodesLen)
for k := 0; k < nodesLen; k++ {
nodeMapLen, err := rd.ReadMapLen()
if err != nil {
return err
}
for l := 0; l < nodeMapLen; l++ {
nodeKey, err := rd.ReadString()
if err != nil {
return err
}
switch nodeKey {
case "id":
cmd.val[i].Nodes[k].ID, err = rd.ReadString()
case "endpoint":
cmd.val[i].Nodes[k].Endpoint, err = rd.ReadString()
case "ip":
cmd.val[i].Nodes[k].IP, err = rd.ReadString()
case "hostname":
cmd.val[i].Nodes[k].Hostname, err = rd.ReadString()
case "port":
cmd.val[i].Nodes[k].Port, err = rd.ReadInt()
case "tls-port":
cmd.val[i].Nodes[k].TLSPort, err = rd.ReadInt()
case "role":
cmd.val[i].Nodes[k].Role, err = rd.ReadString()
case "replication-offset":
cmd.val[i].Nodes[k].ReplicationOffset, err = rd.ReadInt()
case "health":
cmd.val[i].Nodes[k].Health, err = rd.ReadString()
default:
if err = rd.DiscardNext(); err != nil {
return err
}
}
if err != nil {
return err
}
}
}
default:
if err = rd.DiscardNext(); err != nil {
return err
}
}
}
}
return nil
}
func (cmd *ClusterShardsCmd) Clone() Cmder {
var val []ClusterShard
if cmd.val != nil {
val = make([]ClusterShard, len(cmd.val))
for i, shard := range cmd.val {
val[i] = ClusterShard{}
if shard.Slots != nil {
val[i].Slots = make([]SlotRange, len(shard.Slots))
copy(val[i].Slots, shard.Slots)
}
if shard.Nodes != nil {
val[i].Nodes = make([]Node, len(shard.Nodes))
copy(val[i].Nodes, shard.Nodes)
}
}
}
return &ClusterShardsCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// -----------------------------------------
type RankScore struct {
Rank int64
Score float64
}
type RankWithScoreCmd struct {
baseCmd
val RankScore
}
var _ Cmder = (*RankWithScoreCmd)(nil)
func NewRankWithScoreCmd(ctx context.Context, args ...interface{}) *RankWithScoreCmd {
return &RankWithScoreCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeRankWithScore,
},
}
}
func (cmd *RankWithScoreCmd) SetVal(val RankScore) {
cmd.val = val
}
func (cmd *RankWithScoreCmd) Val() RankScore {
cmd.await()
return cmd.val
}
func (cmd *RankWithScoreCmd) Result() (RankScore, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *RankWithScoreCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *RankWithScoreCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
rank, err := rd.ReadInt()
if err != nil {
return err
}
score, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val = RankScore{Rank: rank, Score: score}
return nil
}
func (cmd *RankWithScoreCmd) Clone() Cmder {
return &RankWithScoreCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // RankScore is a simple struct, can be copied directly
}
}
// --------------------------------------------------------------------------------------------------
// ClientFlags is redis-server client flags, copy from redis/src/server.h (redis 7.0)
type ClientFlags uint64
const (
ClientSlave ClientFlags = 1 << 0 /* This client is a replica */
ClientMaster ClientFlags = 1 << 1 /* This client is a master */
ClientMonitor ClientFlags = 1 << 2 /* This client is a slave monitor, see MONITOR */
ClientMulti ClientFlags = 1 << 3 /* This client is in a MULTI context */
ClientBlocked ClientFlags = 1 << 4 /* The client is waiting in a blocking operation */
ClientDirtyCAS ClientFlags = 1 << 5 /* Watched keys modified. EXEC will fail. */
ClientCloseAfterReply ClientFlags = 1 << 6 /* Close after writing entire reply. */
ClientUnBlocked ClientFlags = 1 << 7 /* This client was unblocked and is stored in server.unblocked_clients */
ClientScript ClientFlags = 1 << 8 /* This is a non-connected client used by Lua */
ClientAsking ClientFlags = 1 << 9 /* Client issued the ASKING command */
ClientCloseASAP ClientFlags = 1 << 10 /* Close this client ASAP */
ClientUnixSocket ClientFlags = 1 << 11 /* Client connected via Unix domain socket */
ClientDirtyExec ClientFlags = 1 << 12 /* EXEC will fail for errors while queueing */
ClientMasterForceReply ClientFlags = 1 << 13 /* Queue replies even if is master */
ClientForceAOF ClientFlags = 1 << 14 /* Force AOF propagation of current cmd. */
ClientForceRepl ClientFlags = 1 << 15 /* Force replication of current cmd. */
ClientPrePSync ClientFlags = 1 << 16 /* Instance don't understand PSYNC. */
ClientReadOnly ClientFlags = 1 << 17 /* Cluster client is in read-only state. */
ClientPubSub ClientFlags = 1 << 18 /* Client is in Pub/Sub mode. */
ClientPreventAOFProp ClientFlags = 1 << 19 /* Don't propagate to AOF. */
ClientPreventReplProp ClientFlags = 1 << 20 /* Don't propagate to slaves. */
ClientPreventProp ClientFlags = ClientPreventAOFProp | ClientPreventReplProp
ClientPendingWrite ClientFlags = 1 << 21 /* Client has output to send but a-write handler is yet not installed. */
ClientReplyOff ClientFlags = 1 << 22 /* Don't send replies to client. */
ClientReplySkipNext ClientFlags = 1 << 23 /* Set ClientREPLY_SKIP for next cmd */
ClientReplySkip ClientFlags = 1 << 24 /* Don't send just this reply. */
ClientLuaDebug ClientFlags = 1 << 25 /* Run EVAL in debug mode. */
ClientLuaDebugSync ClientFlags = 1 << 26 /* EVAL debugging without fork() */
ClientModule ClientFlags = 1 << 27 /* Non connected client used by some module. */
ClientProtected ClientFlags = 1 << 28 /* Client should not be freed for now. */
ClientExecutingCommand ClientFlags = 1 << 29 /* Indicates that the client is currently in the process of handling
a command. usually this will be marked only during call()
however, blocked clients might have this flag kept until they
will try to reprocess the command. */
ClientPendingCommand ClientFlags = 1 << 30 /* Indicates the client has a fully * parsed command ready for execution. */
ClientTracking ClientFlags = 1 << 31 /* Client enabled keys tracking in order to perform client side caching. */
ClientTrackingBrokenRedir ClientFlags = 1 << 32 /* Target client is invalid. */
ClientTrackingBCAST ClientFlags = 1 << 33 /* Tracking in BCAST mode. */
ClientTrackingOptIn ClientFlags = 1 << 34 /* Tracking in opt-in mode. */
ClientTrackingOptOut ClientFlags = 1 << 35 /* Tracking in opt-out mode. */
ClientTrackingCaching ClientFlags = 1 << 36 /* CACHING yes/no was given, depending on optin/optout mode. */
ClientTrackingNoLoop ClientFlags = 1 << 37 /* Don't send invalidation messages about writes performed by myself.*/
ClientInTimeoutTable ClientFlags = 1 << 38 /* This client is in the timeout table. */
ClientProtocolError ClientFlags = 1 << 39 /* Protocol error chatting with it. */
ClientCloseAfterCommand ClientFlags = 1 << 40 /* Close after executing commands * and writing entire reply. */
ClientDenyBlocking ClientFlags = 1 << 41 /* Indicate that the client should not be blocked. currently, turned on inside MULTI, Lua, RM_Call, and AOF client */
ClientReplRDBOnly ClientFlags = 1 << 42 /* This client is a replica that only wants RDB without replication buffer. */
ClientNoEvict ClientFlags = 1 << 43 /* This client is protected against client memory eviction. */
ClientAllowOOM ClientFlags = 1 << 44 /* Client used by RM_Call is allowed to fully execute scripts even when in OOM */
ClientNoTouch ClientFlags = 1 << 45 /* This client will not touch LFU/LRU stats. */
ClientPushing ClientFlags = 1 << 46 /* This client is pushing notifications. */
)
// ClientInfo is redis-server ClientInfo, not go-redis *Client
type ClientInfo struct {
ID int64 // redis version 2.8.12, a unique 64-bit client ID
Addr string // address/port of the client
LAddr string // address/port of local address client connected to (bind address)
FD int64 // file descriptor corresponding to the socket
Name string // the name set by the client with CLIENT SETNAME
Age time.Duration // total duration of the connection in seconds
Idle time.Duration // idle time of the connection in seconds
Flags ClientFlags // client flags (see below)
DB int // current database ID
Sub int // number of channel subscriptions
PSub int // number of pattern matching subscriptions
SSub int // redis version 7.0.3, number of shard channel subscriptions
Multi int // number of commands in a MULTI/EXEC context
Watch int // redis version 7.4 RC1, number of keys this client is currently watching.
QueryBuf int // qbuf, query buffer length (0 means no query pending)
QueryBufFree int // qbuf-free, free space of the query buffer (0 means the buffer is full)
ArgvMem int // incomplete arguments for the next command (already extracted from query buffer)
MultiMem int // redis version 7.0, memory is used up by buffered multi commands
BufferSize int // rbs, usable size of buffer
BufferPeak int // rbp, peak used size of buffer in last 5 sec interval
OutputBufferLength int // obl, output buffer length
OutputListLength int // oll, output list length (replies are queued in this list when the buffer is full)
OutputMemory int // omem, output buffer memory usage
TotalMemory int // tot-mem, total memory consumed by this client in its various buffers
TotalNetIn int // tot-net-in, total network input
TotalNetOut int // tot-net-out, total network output
TotalCmds int // tot-cmds, total number of commands processed
IoThread int // io-thread id
Events string // file descriptor events (see below)
LastCmd string // cmd, last command played
User string // the authenticated username of the client
Redir int64 // client id of current client tracking redirection
Resp int // redis version 7.0, client RESP protocol version
LibName string // redis version 7.2, client library name
LibVer string // redis version 7.2, client library version
ReadEvents uint64 // redis version 8.8, number of read events processed
AvgPipelineLenSum uint64 // redis version 8.8, sum of pipeline lengths
AvgPipelineLenCnt uint64 // redis version 8.8, count of pipeline operations
}
type ClientInfoCmd struct {
baseCmd
val *ClientInfo
}
var _ Cmder = (*ClientInfoCmd)(nil)
func NewClientInfoCmd(ctx context.Context, args ...interface{}) *ClientInfoCmd {
return &ClientInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeClientInfo,
},
}
}
func (cmd *ClientInfoCmd) SetVal(val *ClientInfo) {
cmd.val = val
}
func (cmd *ClientInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ClientInfoCmd) Val() *ClientInfo {
cmd.await()
return cmd.val
}
func (cmd *ClientInfoCmd) Result() (*ClientInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ClientInfoCmd) readReply(rd *proto.Reader) (err error) {
txt, err := rd.ReadString()
if err != nil {
return err
}
// sds o = catClientInfoString(sdsempty(), c);
// o = sdscatlen(o,"\n",1);
// addReplyVerbatim(c,o,sdslen(o),"txt");
// sdsfree(o);
cmd.val, err = parseClientInfo(strings.TrimSpace(txt))
return err
}
// fmt.Sscanf() cannot handle null values
func parseClientInfo(txt string) (info *ClientInfo, err error) {
info = &ClientInfo{}
for _, s := range strings.Split(txt, " ") {
kv := strings.Split(s, "=")
if len(kv) != 2 {
return nil, fmt.Errorf("redis: unexpected client info data (%s)", s)
}
key, val := kv[0], kv[1]
switch key {
case "id":
info.ID, err = strconv.ParseInt(val, 10, 64)
case "addr":
info.Addr = val
case "laddr":
info.LAddr = val
case "fd":
info.FD, err = strconv.ParseInt(val, 10, 64)
case "name":
info.Name = val
case "age":
var age int
if age, err = strconv.Atoi(val); err == nil {
info.Age = time.Duration(age) * time.Second
}
case "idle":
var idle int
if idle, err = strconv.Atoi(val); err == nil {
info.Idle = time.Duration(idle) * time.Second
}
case "flags":
if val == "N" {
break
}
for i := 0; i < len(val); i++ {
switch val[i] {
case 'S':
info.Flags |= ClientSlave
case 'O':
info.Flags |= ClientSlave | ClientMonitor
case 'M':
info.Flags |= ClientMaster
case 'P':
info.Flags |= ClientPubSub
case 'x':
info.Flags |= ClientMulti
case 'b':
info.Flags |= ClientBlocked
case 't':
info.Flags |= ClientTracking
case 'R':
info.Flags |= ClientTrackingBrokenRedir
case 'B':
info.Flags |= ClientTrackingBCAST
case 'd':
info.Flags |= ClientDirtyCAS
case 'c':
info.Flags |= ClientCloseAfterCommand
case 'u':
info.Flags |= ClientUnBlocked
case 'A':
info.Flags |= ClientCloseASAP
case 'U':
info.Flags |= ClientUnixSocket
case 'r':
info.Flags |= ClientReadOnly
case 'e':
info.Flags |= ClientNoEvict
case 'T':
info.Flags |= ClientNoTouch
default:
// Forward compatibility: servers can return client-flag
// characters this client does not recognize (new flags are
// added over time). Skip them instead of failing, as the
// CLIENT LIST/INFO docs advise for version-safe parsing, and
// matching the "skip unknown fields" behaviour of the field
// switch below.
}
}
case "db":
info.DB, err = strconv.Atoi(val)
case "sub":
info.Sub, err = strconv.Atoi(val)
case "psub":
info.PSub, err = strconv.Atoi(val)
case "ssub":
info.SSub, err = strconv.Atoi(val)
case "multi":
info.Multi, err = strconv.Atoi(val)
case "watch":
info.Watch, err = strconv.Atoi(val)
case "qbuf":
info.QueryBuf, err = strconv.Atoi(val)
case "qbuf-free":
info.QueryBufFree, err = strconv.Atoi(val)
case "argv-mem":
info.ArgvMem, err = strconv.Atoi(val)
case "multi-mem":
info.MultiMem, err = strconv.Atoi(val)
case "rbs":
info.BufferSize, err = strconv.Atoi(val)
case "rbp":
info.BufferPeak, err = strconv.Atoi(val)
case "obl":
info.OutputBufferLength, err = strconv.Atoi(val)
case "oll":
info.OutputListLength, err = strconv.Atoi(val)
case "omem":
info.OutputMemory, err = strconv.Atoi(val)
case "tot-mem":
info.TotalMemory, err = strconv.Atoi(val)
case "tot-net-in":
info.TotalNetIn, err = strconv.Atoi(val)
case "tot-net-out":
info.TotalNetOut, err = strconv.Atoi(val)
case "tot-cmds":
info.TotalCmds, err = strconv.Atoi(val)
case "events":
info.Events = val
case "cmd":
info.LastCmd = val
case "user":
info.User = val
case "redir":
info.Redir, err = strconv.ParseInt(val, 10, 64)
case "resp":
info.Resp, err = strconv.Atoi(val)
case "lib-name":
info.LibName = val
case "lib-ver":
info.LibVer = val
case "io-thread":
info.IoThread, err = strconv.Atoi(val)
case "read-events":
info.ReadEvents, err = strconv.ParseUint(val, 10, 64)
case "avg-pipeline-len-sum":
info.AvgPipelineLenSum, err = strconv.ParseUint(val, 10, 64)
case "avg-pipeline-len-cnt":
info.AvgPipelineLenCnt, err = strconv.ParseUint(val, 10, 64)
default:
// skip unknown fields
}
if err != nil {
return nil, err
}
}
return info, nil
}
func (cmd *ClientInfoCmd) Clone() Cmder {
var val *ClientInfo
if cmd.val != nil {
val = &ClientInfo{
ID: cmd.val.ID,
Addr: cmd.val.Addr,
LAddr: cmd.val.LAddr,
FD: cmd.val.FD,
Name: cmd.val.Name,
Age: cmd.val.Age,
Idle: cmd.val.Idle,
Flags: cmd.val.Flags,
DB: cmd.val.DB,
Sub: cmd.val.Sub,
PSub: cmd.val.PSub,
SSub: cmd.val.SSub,
Multi: cmd.val.Multi,
Watch: cmd.val.Watch,
QueryBuf: cmd.val.QueryBuf,
QueryBufFree: cmd.val.QueryBufFree,
ArgvMem: cmd.val.ArgvMem,
MultiMem: cmd.val.MultiMem,
BufferSize: cmd.val.BufferSize,
BufferPeak: cmd.val.BufferPeak,
OutputBufferLength: cmd.val.OutputBufferLength,
OutputListLength: cmd.val.OutputListLength,
OutputMemory: cmd.val.OutputMemory,
TotalMemory: cmd.val.TotalMemory,
IoThread: cmd.val.IoThread,
Events: cmd.val.Events,
LastCmd: cmd.val.LastCmd,
User: cmd.val.User,
Redir: cmd.val.Redir,
Resp: cmd.val.Resp,
LibName: cmd.val.LibName,
LibVer: cmd.val.LibVer,
ReadEvents: cmd.val.ReadEvents,
AvgPipelineLenSum: cmd.val.AvgPipelineLenSum,
AvgPipelineLenCnt: cmd.val.AvgPipelineLenCnt,
}
}
return &ClientInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// -------------------------------------------
type ACLLogEntry struct {
Count int64
Reason string
Context string
Object string
Username string
AgeSeconds float64
ClientInfo *ClientInfo
EntryID int64
TimestampCreated int64
TimestampLastUpdated int64
}
type ACLLogCmd struct {
baseCmd
val []*ACLLogEntry
}
var _ Cmder = (*ACLLogCmd)(nil)
func NewACLLogCmd(ctx context.Context, args ...interface{}) *ACLLogCmd {
return &ACLLogCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeACLLog,
},
}
}
func (cmd *ACLLogCmd) SetVal(val []*ACLLogEntry) {
cmd.val = val
}
func (cmd *ACLLogCmd) Val() []*ACLLogEntry {
cmd.await()
return cmd.val
}
func (cmd *ACLLogCmd) Result() ([]*ACLLogEntry, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ACLLogCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ACLLogCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]*ACLLogEntry, n)
for i := 0; i < n; i++ {
cmd.val[i] = &ACLLogEntry{}
entry := cmd.val[i]
respLen, err := rd.ReadMapLen()
if err != nil {
return err
}
for j := 0; j < respLen; j++ {
key, err := rd.ReadString()
if err != nil {
return err
}
switch key {
case "count":
entry.Count, err = rd.ReadInt()
case "reason":
entry.Reason, err = rd.ReadString()
case "context":
entry.Context, err = rd.ReadString()
case "object":
entry.Object, err = rd.ReadString()
case "username":
entry.Username, err = rd.ReadString()
case "age-seconds":
entry.AgeSeconds, err = rd.ReadFloat()
case "client-info":
txt, err := rd.ReadString()
if err != nil {
return err
}
entry.ClientInfo, err = parseClientInfo(strings.TrimSpace(txt))
if err != nil {
return err
}
case "entry-id":
entry.EntryID, err = rd.ReadInt()
case "timestamp-created":
entry.TimestampCreated, err = rd.ReadInt()
case "timestamp-last-updated":
entry.TimestampLastUpdated, err = rd.ReadInt()
default:
// skip unknown fields
if err := rd.DiscardNext(); err != nil {
return err
}
}
if err != nil {
return err
}
}
}
return nil
}
func (cmd *ACLLogCmd) Clone() Cmder {
var val []*ACLLogEntry
if cmd.val != nil {
val = make([]*ACLLogEntry, len(cmd.val))
for i, entry := range cmd.val {
if entry != nil {
val[i] = &ACLLogEntry{
Count: entry.Count,
Reason: entry.Reason,
Context: entry.Context,
Object: entry.Object,
Username: entry.Username,
AgeSeconds: entry.AgeSeconds,
EntryID: entry.EntryID,
TimestampCreated: entry.TimestampCreated,
TimestampLastUpdated: entry.TimestampLastUpdated,
}
// Clone ClientInfo if present
if entry.ClientInfo != nil {
val[i].ClientInfo = &ClientInfo{
ID: entry.ClientInfo.ID,
Addr: entry.ClientInfo.Addr,
LAddr: entry.ClientInfo.LAddr,
FD: entry.ClientInfo.FD,
Name: entry.ClientInfo.Name,
Age: entry.ClientInfo.Age,
Idle: entry.ClientInfo.Idle,
Flags: entry.ClientInfo.Flags,
DB: entry.ClientInfo.DB,
Sub: entry.ClientInfo.Sub,
PSub: entry.ClientInfo.PSub,
SSub: entry.ClientInfo.SSub,
Multi: entry.ClientInfo.Multi,
Watch: entry.ClientInfo.Watch,
QueryBuf: entry.ClientInfo.QueryBuf,
QueryBufFree: entry.ClientInfo.QueryBufFree,
ArgvMem: entry.ClientInfo.ArgvMem,
MultiMem: entry.ClientInfo.MultiMem,
BufferSize: entry.ClientInfo.BufferSize,
BufferPeak: entry.ClientInfo.BufferPeak,
OutputBufferLength: entry.ClientInfo.OutputBufferLength,
OutputListLength: entry.ClientInfo.OutputListLength,
OutputMemory: entry.ClientInfo.OutputMemory,
TotalMemory: entry.ClientInfo.TotalMemory,
IoThread: entry.ClientInfo.IoThread,
Events: entry.ClientInfo.Events,
LastCmd: entry.ClientInfo.LastCmd,
User: entry.ClientInfo.User,
Redir: entry.ClientInfo.Redir,
Resp: entry.ClientInfo.Resp,
LibName: entry.ClientInfo.LibName,
LibVer: entry.ClientInfo.LibVer,
ReadEvents: entry.ClientInfo.ReadEvents,
AvgPipelineLenSum: entry.ClientInfo.AvgPipelineLenSum,
AvgPipelineLenCnt: entry.ClientInfo.AvgPipelineLenCnt,
}
}
}
}
}
return &ACLLogCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// LibraryInfo holds the library info.
type LibraryInfo struct {
LibName *string
LibVer *string
}
// WithLibraryName returns a valid LibraryInfo with library name only.
func WithLibraryName(libName string) LibraryInfo {
return LibraryInfo{LibName: &libName}
}
// WithLibraryVersion returns a valid LibraryInfo with library version only.
func WithLibraryVersion(libVer string) LibraryInfo {
return LibraryInfo{LibVer: &libVer}
}
// -------------------------------------------
type InfoCmd struct {
baseCmd
val map[string]map[string]string
}
var _ Cmder = (*InfoCmd)(nil)
func NewInfoCmd(ctx context.Context, args ...interface{}) *InfoCmd {
return &InfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeInfo,
},
}
}
func (cmd *InfoCmd) SetVal(val map[string]map[string]string) {
cmd.val = val
}
func (cmd *InfoCmd) Val() map[string]map[string]string {
cmd.await()
return cmd.val
}
func (cmd *InfoCmd) Result() (map[string]map[string]string, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *InfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *InfoCmd) readReply(rd *proto.Reader) error {
val, err := rd.ReadString()
if err != nil {
return err
}
section := ""
scanner := bufio.NewScanner(strings.NewReader(val))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") {
if cmd.val == nil {
cmd.val = make(map[string]map[string]string)
}
section = strings.TrimPrefix(line, "# ")
cmd.val[section] = make(map[string]string)
} else if line != "" {
if section == "Modules" {
moduleRe := regexp.MustCompile(`module:name=(.+?),(.+)$`)
kv := moduleRe.FindStringSubmatch(line)
if len(kv) == 3 {
cmd.val[section][kv[1]] = kv[2]
}
} else {
kv := strings.SplitN(line, ":", 2)
if len(kv) == 2 {
cmd.val[section][kv[0]] = kv[1]
}
}
}
}
return nil
}
func (cmd *InfoCmd) Item(section, key string) string {
cmd.await()
if cmd.val == nil {
return ""
} else if cmd.val[section] == nil {
return ""
} else {
return cmd.val[section][key]
}
}
func (cmd *InfoCmd) Clone() Cmder {
var val map[string]map[string]string
if cmd.val != nil {
val = make(map[string]map[string]string, len(cmd.val))
for section, sectionMap := range cmd.val {
if sectionMap != nil {
val[section] = make(map[string]string, len(sectionMap))
for k, v := range sectionMap {
val[section][k] = v
}
}
}
}
return &InfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
type MonitorStatus int
const (
monitorStatusIdle MonitorStatus = iota
monitorStatusStart
monitorStatusStop
)
type MonitorCmd struct {
baseCmd
ch chan string
status MonitorStatus
mu sync.Mutex
}
func newMonitorCmd(ctx context.Context, ch chan string) *MonitorCmd {
return &MonitorCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"monitor"},
cmdType: CmdTypeMonitor,
},
ch: ch,
status: monitorStatusIdle,
mu: sync.Mutex{},
}
}
func (cmd *MonitorCmd) String() string {
cmd.await()
return cmdString(cmd, nil)
}
func (cmd *MonitorCmd) readReply(rd *proto.Reader) error {
ctx, cancel := context.WithCancel(cmd.ctx)
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
err := cmd.readMonitor(rd, cancel)
if err != nil {
cmd.err = err
return
}
}
}
}(ctx)
return nil
}
func (cmd *MonitorCmd) readMonitor(rd *proto.Reader, cancel context.CancelFunc) error {
for {
cmd.mu.Lock()
st := cmd.status
pk, _ := rd.Peek(1)
cmd.mu.Unlock()
if len(pk) != 0 && st == monitorStatusStart {
cmd.mu.Lock()
line, err := rd.ReadString()
cmd.mu.Unlock()
if err != nil {
return err
}
cmd.ch <- line
}
if st == monitorStatusStop {
cancel()
break
}
}
return nil
}
func (cmd *MonitorCmd) Start() {
cmd.mu.Lock()
defer cmd.mu.Unlock()
cmd.status = monitorStatusStart
}
func (cmd *MonitorCmd) Stop() {
cmd.mu.Lock()
defer cmd.mu.Unlock()
cmd.status = monitorStatusStop
}
type VectorScoreSliceCmd struct {
baseCmd
val []VectorScore
}
var _ Cmder = (*VectorScoreSliceCmd)(nil)
func NewVectorScoreSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd {
return &VectorScoreSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
// NewVectorInfoSliceCmd is an alias for NewVectorScoreSliceCmd kept for backwards compatibility.
func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd {
return NewVectorScoreSliceCmd(ctx, args...)
}
func (cmd *VectorScoreSliceCmd) SetVal(val []VectorScore) {
cmd.val = val
}
func (cmd *VectorScoreSliceCmd) Val() []VectorScore {
cmd.await()
return cmd.val
}
func (cmd *VectorScoreSliceCmd) Result() ([]VectorScore, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *VectorScoreSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *VectorScoreSliceCmd) readReply(rd *proto.Reader) error {
typ, err := rd.PeekReplyType()
if err != nil {
return err
}
var n int
if typ == proto.RespMap {
n, err = rd.ReadMapLen()
if err != nil {
return err
}
} else {
// RESP2 returns a flat array [name, score, name, score, ...]
n, err = rd.ReadArrayLen()
if err != nil {
return err
}
if n%2 != 0 {
return fmt.Errorf("redis: VectorScoreSliceCmd expects even number of elements, got %d", n)
}
n /= 2
}
cmd.val = make([]VectorScore, n)
for i := 0; i < n; i++ {
name, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[i].Name = name
score, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val[i].Score = score
}
return nil
}
func (cmd *VectorScoreSliceCmd) Clone() Cmder {
return &VectorScoreSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
// VectorScoreSliceSliceCmd is used for VLINKS WITHSCORES which returns an array of arrays.
// In RESP3, each inner array contains maps of element -> score.
type VectorScoreSliceSliceCmd struct {
baseCmd
val [][]VectorScore
}
var _ Cmder = (*VectorScoreSliceSliceCmd)(nil)
func NewVectorScoreSliceSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceSliceCmd {
return &VectorScoreSliceSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *VectorScoreSliceSliceCmd) SetVal(val [][]VectorScore) {
cmd.val = val
}
func (cmd *VectorScoreSliceSliceCmd) Val() [][]VectorScore {
cmd.await()
return cmd.val
}
func (cmd *VectorScoreSliceSliceCmd) Result() ([][]VectorScore, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *VectorScoreSliceSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *VectorScoreSliceSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([][]VectorScore, n)
for i := range n {
// Each level can be either a map (RESP3) or an array (RESP2)
levelTyp, err := rd.PeekReplyType()
if err != nil {
return err
}
if levelTyp == proto.RespMap {
// RESP3 format: each level is a map {element: score, element: score, ...}
mapLen, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val[i] = make([]VectorScore, mapLen)
for j := range mapLen {
name, err := rd.ReadString()
if err != nil {
return err
}
score, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val[i][j] = VectorScore{Name: name, Score: score}
}
} else {
// RESP2 format: each level is an array of [element, score, element, score, ...] pairs
innerLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
if innerLen%2 != 0 {
return fmt.Errorf("redis: got %d elements in the VLINKS array, wanted a multiple of 2", innerLen)
}
cmd.val[i] = make([]VectorScore, innerLen/2)
for j := 0; j < innerLen; j += 2 {
name, err := rd.ReadString()
if err != nil {
return err
}
score, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val[i][j/2] = VectorScore{Name: name, Score: score}
}
}
}
return nil
}
func (cmd *VectorScoreSliceSliceCmd) Clone() Cmder {
var val [][]VectorScore
if cmd.val != nil {
val = make([][]VectorScore, len(cmd.val))
for i, slice := range cmd.val {
if slice != nil {
val[i] = make([]VectorScore, len(slice))
copy(val[i], slice)
}
}
}
return &VectorScoreSliceSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
func readVectorAttribStringOrNil(rd *proto.Reader) (*string, error) {
v, err := rd.ReadReply()
if err != nil {
if err == proto.Nil {
return nil, nil
}
return nil, err
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("redis: can't parse reply=%T reading string", v)
}
return &s, nil
}
type VectorAttribSliceCmd struct {
baseCmd
val []VectorAttrib
}
var _ Cmder = (*VectorAttribSliceCmd)(nil)
func NewVectorAttribSliceCmd(ctx context.Context, args ...any) *VectorAttribSliceCmd {
return &VectorAttribSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *VectorAttribSliceCmd) SetVal(val []VectorAttrib) {
cmd.val = val
}
func (cmd *VectorAttribSliceCmd) Val() []VectorAttrib {
cmd.await()
return cmd.val
}
func (cmd *VectorAttribSliceCmd) Result() ([]VectorAttrib, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *VectorAttribSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *VectorAttribSliceCmd) readReply(rd *proto.Reader) error {
replyType, err := rd.PeekReplyType()
if err != nil {
return err
}
if replyType == proto.RespMap {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = make([]VectorAttrib, n)
for i := 0; i < n; i++ {
name, err := rd.ReadString()
if err != nil {
return err
}
attrib, err := readVectorAttribStringOrNil(rd)
if err != nil {
return err
}
cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib}
}
return nil
}
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
if n%2 != 0 {
return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 2", n)
}
cmd.val = make([]VectorAttrib, n/2)
for i := range cmd.val {
name, err := rd.ReadString()
if err != nil {
return err
}
attrib, err := readVectorAttribStringOrNil(rd)
if err != nil {
return err
}
cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib}
}
return nil
}
func (cmd *VectorAttribSliceCmd) Clone() Cmder {
return &VectorAttribSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
type VectorScoreAttribSliceCmd struct {
baseCmd
val []VectorScoreAttrib
}
var _ Cmder = (*VectorScoreAttribSliceCmd)(nil)
func NewVectorScoreAttribSliceCmd(ctx context.Context, args ...any) *VectorScoreAttribSliceCmd {
return &VectorScoreAttribSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
}
}
func (cmd *VectorScoreAttribSliceCmd) SetVal(val []VectorScoreAttrib) {
cmd.val = val
}
func (cmd *VectorScoreAttribSliceCmd) Val() []VectorScoreAttrib {
cmd.await()
return cmd.val
}
func (cmd *VectorScoreAttribSliceCmd) Result() ([]VectorScoreAttrib, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *VectorScoreAttribSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *VectorScoreAttribSliceCmd) readReply(rd *proto.Reader) error {
replyType, err := rd.PeekReplyType()
if err != nil {
return err
}
if replyType == proto.RespMap {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = make([]VectorScoreAttrib, n)
for i := 0; i < n; i++ {
name, err := rd.ReadString()
if err != nil {
return err
}
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
score, err := rd.ReadFloat()
if err != nil {
return err
}
attrib, err := readVectorAttribStringOrNil(rd)
if err != nil {
return err
}
cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib}
}
return nil
}
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
if n%3 != 0 {
return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 3", n)
}
cmd.val = make([]VectorScoreAttrib, n/3)
for i := range cmd.val {
name, err := rd.ReadString()
if err != nil {
return err
}
score, err := rd.ReadFloat()
if err != nil {
return err
}
attrib, err := readVectorAttribStringOrNil(rd)
if err != nil {
return err
}
cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib}
}
return nil
}
func (cmd *VectorScoreAttribSliceCmd) Clone() Cmder {
return &VectorScoreAttribSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
func (cmd *MonitorCmd) Clone() Cmder {
// MonitorCmd cannot be safely cloned due to channels and goroutines
// Return a new MonitorCmd with the same channel
return newMonitorCmd(cmd.ctx, cmd.ch)
}
// ExtractCommandValue extracts the value from a command result using the fast enum-based approach
func ExtractCommandValue(cmd interface{}) (interface{}, error) {
// First try to get the command type using the interface
if cmdTypeGetter, ok := cmd.(CmdTypeGetter); ok {
cmdType := cmdTypeGetter.GetCmdType()
// Use fast type-based extraction
switch cmdType {
case CmdTypeGeneric:
if genericCmd, ok := cmd.(interface {
Val() interface{}
Err() error
}); ok {
return genericCmd.Val(), genericCmd.Err()
}
case CmdTypeString:
if stringCmd, ok := cmd.(interface {
Val() string
Err() error
}); ok {
return stringCmd.Val(), stringCmd.Err()
}
case CmdTypeInt:
if intCmd, ok := cmd.(interface {
Val() int64
Err() error
}); ok {
return intCmd.Val(), intCmd.Err()
}
case CmdTypeUint:
if uintCmd, ok := cmd.(interface {
Val() uint64
Err() error
}); ok {
return uintCmd.Val(), uintCmd.Err()
}
case CmdTypeBool:
if boolCmd, ok := cmd.(interface {
Val() bool
Err() error
}); ok {
return boolCmd.Val(), boolCmd.Err()
}
case CmdTypeFloat:
if floatCmd, ok := cmd.(interface {
Val() float64
Err() error
}); ok {
return floatCmd.Val(), floatCmd.Err()
}
case CmdTypeStatus:
if statusCmd, ok := cmd.(interface {
Val() string
Err() error
}); ok {
return statusCmd.Val(), statusCmd.Err()
}
case CmdTypeDuration:
if durationCmd, ok := cmd.(interface {
Val() time.Duration
Err() error
}); ok {
return durationCmd.Val(), durationCmd.Err()
}
case CmdTypeTime:
if timeCmd, ok := cmd.(interface {
Val() time.Time
Err() error
}); ok {
return timeCmd.Val(), timeCmd.Err()
}
case CmdTypeStringStructMap:
if structMapCmd, ok := cmd.(interface {
Val() map[string]struct{}
Err() error
}); ok {
return structMapCmd.Val(), structMapCmd.Err()
}
case CmdTypeXMessageSlice:
if xMessageSliceCmd, ok := cmd.(interface {
Val() []XMessage
Err() error
}); ok {
return xMessageSliceCmd.Val(), xMessageSliceCmd.Err()
}
case CmdTypeXStreamSlice:
if xStreamSliceCmd, ok := cmd.(interface {
Val() []XStream
Err() error
}); ok {
return xStreamSliceCmd.Val(), xStreamSliceCmd.Err()
}
case CmdTypeXPending:
if xPendingCmd, ok := cmd.(interface {
Val() *XPending
Err() error
}); ok {
return xPendingCmd.Val(), xPendingCmd.Err()
}
case CmdTypeXPendingExt:
if xPendingExtCmd, ok := cmd.(interface {
Val() []XPendingExt
Err() error
}); ok {
return xPendingExtCmd.Val(), xPendingExtCmd.Err()
}
case CmdTypeXAutoClaim:
if xAutoClaimCmd, ok := cmd.(interface {
Val() ([]XMessage, string)
Err() error
}); ok {
messages, start := xAutoClaimCmd.Val()
return CmdTypeXAutoClaimValue{messages: messages, start: start}, xAutoClaimCmd.Err()
}
case CmdTypeXAutoClaimWithDeleted:
if xAutoClaimWithDeletedCmd, ok := cmd.(interface {
Val() ([]XMessage, string, []string)
Err() error
}); ok {
messages, start, deletedIDs := xAutoClaimWithDeletedCmd.Val()
return CmdTypeXAutoClaimWithDeletedValue{messages: messages, start: start, deletedIDs: deletedIDs}, xAutoClaimWithDeletedCmd.Err()
}
case CmdTypeXAutoClaimJustID:
if xAutoClaimJustIDCmd, ok := cmd.(interface {
Val() ([]string, string)
Err() error
}); ok {
ids, start := xAutoClaimJustIDCmd.Val()
return CmdTypeXAutoClaimJustIDValue{ids: ids, start: start}, xAutoClaimJustIDCmd.Err()
}
case CmdTypeXInfoConsumers:
if xInfoConsumersCmd, ok := cmd.(interface {
Val() []XInfoConsumer
Err() error
}); ok {
return xInfoConsumersCmd.Val(), xInfoConsumersCmd.Err()
}
case CmdTypeXInfoGroups:
if xInfoGroupsCmd, ok := cmd.(interface {
Val() []XInfoGroup
Err() error
}); ok {
return xInfoGroupsCmd.Val(), xInfoGroupsCmd.Err()
}
case CmdTypeXInfoStream:
if xInfoStreamCmd, ok := cmd.(interface {
Val() *XInfoStream
Err() error
}); ok {
return xInfoStreamCmd.Val(), xInfoStreamCmd.Err()
}
case CmdTypeXInfoStreamFull:
if xInfoStreamFullCmd, ok := cmd.(interface {
Val() *XInfoStreamFull
Err() error
}); ok {
return xInfoStreamFullCmd.Val(), xInfoStreamFullCmd.Err()
}
case CmdTypeZSlice:
if zSliceCmd, ok := cmd.(interface {
Val() []Z
Err() error
}); ok {
return zSliceCmd.Val(), zSliceCmd.Err()
}
case CmdTypeZWithKey:
if zWithKeyCmd, ok := cmd.(interface {
Val() *ZWithKey
Err() error
}); ok {
return zWithKeyCmd.Val(), zWithKeyCmd.Err()
}
case CmdTypeScan:
if scanCmd, ok := cmd.(interface {
Val() ([]string, uint64)
Err() error
}); ok {
keys, cursor := scanCmd.Val()
return CmdTypeScanValue{keys: keys, cursor: cursor}, scanCmd.Err()
}
case CmdTypeClusterSlots:
if clusterSlotsCmd, ok := cmd.(interface {
Val() []ClusterSlot
Err() error
}); ok {
return clusterSlotsCmd.Val(), clusterSlotsCmd.Err()
}
case CmdTypeGeoLocation:
if geoLocationCmd, ok := cmd.(interface {
Val() []GeoLocation
Err() error
}); ok {
return geoLocationCmd.Val(), geoLocationCmd.Err()
}
case CmdTypeGeoSearchLocation:
if geoSearchLocationCmd, ok := cmd.(interface {
Val() []GeoLocation
Err() error
}); ok {
return geoSearchLocationCmd.Val(), geoSearchLocationCmd.Err()
}
case CmdTypeGeoPos:
if geoPosCmd, ok := cmd.(interface {
Val() []*GeoPos
Err() error
}); ok {
return geoPosCmd.Val(), geoPosCmd.Err()
}
case CmdTypeCommandsInfo:
if commandsInfoCmd, ok := cmd.(interface {
Val() map[string]*CommandInfo
Err() error
}); ok {
return commandsInfoCmd.Val(), commandsInfoCmd.Err()
}
case CmdTypeSlowLog:
if slowLogCmd, ok := cmd.(interface {
Val() []SlowLog
Err() error
}); ok {
return slowLogCmd.Val(), slowLogCmd.Err()
}
case CmdTypeHotKeys:
if hotKeysCmd, ok := cmd.(interface {
Val() *HotKeysResult
Err() error
}); ok {
return hotKeysCmd.Val(), hotKeysCmd.Err()
}
case CmdTypeIncrEXInt:
if incrEXCmd, ok := cmd.(interface {
Val() IncrEXIntResult
Err() error
}); ok {
return incrEXCmd.Val(), incrEXCmd.Err()
}
case CmdTypeIncrEXFloat:
if incrEXCmd, ok := cmd.(interface {
Val() IncrEXFloatResult
Err() error
}); ok {
return incrEXCmd.Val(), incrEXCmd.Err()
}
case CmdTypeKeyValues:
if keyValuesCmd, ok := cmd.(interface {
Val() (string, []string)
Err() error
}); ok {
key, values := keyValuesCmd.Val()
return CmdTypeKeyValuesValue{key: key, values: values}, keyValuesCmd.Err()
}
case CmdTypeZSliceWithKey:
if zSliceWithKeyCmd, ok := cmd.(interface {
Val() (string, []Z)
Err() error
}); ok {
key, zSlice := zSliceWithKeyCmd.Val()
return CmdTypeZSliceWithKeyValue{key: key, zSlice: zSlice}, zSliceWithKeyCmd.Err()
}
case CmdTypeFunctionList:
if functionListCmd, ok := cmd.(interface {
Val() []Library
Err() error
}); ok {
return functionListCmd.Val(), functionListCmd.Err()
}
case CmdTypeFunctionStats:
if functionStatsCmd, ok := cmd.(interface {
Val() FunctionStats
Err() error
}); ok {
return functionStatsCmd.Val(), functionStatsCmd.Err()
}
case CmdTypeLCS:
if lcsCmd, ok := cmd.(interface {
Val() *LCSMatch
Err() error
}); ok {
return lcsCmd.Val(), lcsCmd.Err()
}
case CmdTypeKeyFlags:
if keyFlagsCmd, ok := cmd.(interface {
Val() []KeyFlags
Err() error
}); ok {
return keyFlagsCmd.Val(), keyFlagsCmd.Err()
}
case CmdTypeClusterLinks:
if clusterLinksCmd, ok := cmd.(interface {
Val() []ClusterLink
Err() error
}); ok {
return clusterLinksCmd.Val(), clusterLinksCmd.Err()
}
case CmdTypeClusterShards:
if clusterShardsCmd, ok := cmd.(interface {
Val() []ClusterShard
Err() error
}); ok {
return clusterShardsCmd.Val(), clusterShardsCmd.Err()
}
case CmdTypeRankWithScore:
if rankWithScoreCmd, ok := cmd.(interface {
Val() RankScore
Err() error
}); ok {
return rankWithScoreCmd.Val(), rankWithScoreCmd.Err()
}
case CmdTypeClientInfo:
if clientInfoCmd, ok := cmd.(interface {
Val() *ClientInfo
Err() error
}); ok {
return clientInfoCmd.Val(), clientInfoCmd.Err()
}
case CmdTypeACLLog:
if aclLogCmd, ok := cmd.(interface {
Val() []*ACLLogEntry
Err() error
}); ok {
return aclLogCmd.Val(), aclLogCmd.Err()
}
case CmdTypeInfo:
if infoCmd, ok := cmd.(interface {
Val() string
Err() error
}); ok {
return infoCmd.Val(), infoCmd.Err()
}
case CmdTypeMonitor:
if monitorCmd, ok := cmd.(interface {
Val() string
Err() error
}); ok {
return monitorCmd.Val(), monitorCmd.Err()
}
case CmdTypeJSON:
if jsonCmd, ok := cmd.(interface {
Val() string
Err() error
}); ok {
return jsonCmd.Val(), jsonCmd.Err()
}
case CmdTypeJSONSlice:
if jsonSliceCmd, ok := cmd.(interface {
Val() []interface{}
Err() error
}); ok {
return jsonSliceCmd.Val(), jsonSliceCmd.Err()
}
case CmdTypeIntPointerSlice:
if intPointerSliceCmd, ok := cmd.(interface {
Val() []*int64
Err() error
}); ok {
return intPointerSliceCmd.Val(), intPointerSliceCmd.Err()
}
case CmdTypeScanDump:
if scanDumpCmd, ok := cmd.(interface {
Val() ScanDump
Err() error
}); ok {
return scanDumpCmd.Val(), scanDumpCmd.Err()
}
case CmdTypeBFInfo:
if bfInfoCmd, ok := cmd.(interface {
Val() BFInfo
Err() error
}); ok {
return bfInfoCmd.Val(), bfInfoCmd.Err()
}
case CmdTypeCFInfo:
if cfInfoCmd, ok := cmd.(interface {
Val() CFInfo
Err() error
}); ok {
return cfInfoCmd.Val(), cfInfoCmd.Err()
}
case CmdTypeCMSInfo:
if cmsInfoCmd, ok := cmd.(interface {
Val() CMSInfo
Err() error
}); ok {
return cmsInfoCmd.Val(), cmsInfoCmd.Err()
}
case CmdTypeTopKInfo:
if topKInfoCmd, ok := cmd.(interface {
Val() TopKInfo
Err() error
}); ok {
return topKInfoCmd.Val(), topKInfoCmd.Err()
}
case CmdTypeTDigestInfo:
if tDigestInfoCmd, ok := cmd.(interface {
Val() TDigestInfo
Err() error
}); ok {
return tDigestInfoCmd.Val(), tDigestInfoCmd.Err()
}
case CmdTypeFTSearch:
if ftSearchCmd, ok := cmd.(interface {
Val() FTSearchResult
Err() error
}); ok {
return ftSearchCmd.Val(), ftSearchCmd.Err()
}
case CmdTypeFTInfo:
if ftInfoCmd, ok := cmd.(interface {
Val() FTInfoResult
Err() error
}); ok {
return ftInfoCmd.Val(), ftInfoCmd.Err()
}
case CmdTypeFTSpellCheck:
if ftSpellCheckCmd, ok := cmd.(interface {
Val() []SpellCheckResult
Err() error
}); ok {
return ftSpellCheckCmd.Val(), ftSpellCheckCmd.Err()
}
case CmdTypeFTSynDump:
if ftSynDumpCmd, ok := cmd.(interface {
Val() []FTSynDumpResult
Err() error
}); ok {
return ftSynDumpCmd.Val(), ftSynDumpCmd.Err()
}
case CmdTypeAggregate:
if aggregateCmd, ok := cmd.(interface {
Val() *FTAggregateResult
Err() error
}); ok {
return aggregateCmd.Val(), aggregateCmd.Err()
}
case CmdTypeTSTimestampValue:
if tsTimestampValueCmd, ok := cmd.(interface {
Val() TSTimestampValue
Err() error
}); ok {
return tsTimestampValueCmd.Val(), tsTimestampValueCmd.Err()
}
case CmdTypeTSTimestampValueSlice:
if tsTimestampValueSliceCmd, ok := cmd.(interface {
Val() []TSTimestampValue
Err() error
}); ok {
return tsTimestampValueSliceCmd.Val(), tsTimestampValueSliceCmd.Err()
}
case CmdTypeTSNRangePivotRowSlice:
if tsNRangePivotRowSliceCmd, ok := cmd.(interface {
Val() []TSNRangePivotRow
Err() error
}); ok {
return tsNRangePivotRowSliceCmd.Val(), tsNRangePivotRowSliceCmd.Err()
}
case CmdTypeStringSlice:
if stringSliceCmd, ok := cmd.(interface {
Val() []string
Err() error
}); ok {
return stringSliceCmd.Val(), stringSliceCmd.Err()
}
case CmdTypeIntSlice:
if intSliceCmd, ok := cmd.(interface {
Val() []int64
Err() error
}); ok {
return intSliceCmd.Val(), intSliceCmd.Err()
}
case CmdTypeUintSlice:
if uintSliceCmd, ok := cmd.(interface {
Val() []uint64
Err() error
}); ok {
return uintSliceCmd.Val(), uintSliceCmd.Err()
}
case CmdTypeBoolSlice:
if boolSliceCmd, ok := cmd.(interface {
Val() []bool
Err() error
}); ok {
return boolSliceCmd.Val(), boolSliceCmd.Err()
}
case CmdTypeFloatSlice:
if floatSliceCmd, ok := cmd.(interface {
Val() []float64
Err() error
}); ok {
return floatSliceCmd.Val(), floatSliceCmd.Err()
}
case CmdTypeSlice:
if sliceCmd, ok := cmd.(interface {
Val() []interface{}
Err() error
}); ok {
return sliceCmd.Val(), sliceCmd.Err()
}
case CmdTypeKeyValueSlice:
if keyValueSliceCmd, ok := cmd.(interface {
Val() []KeyValue
Err() error
}); ok {
return keyValueSliceCmd.Val(), keyValueSliceCmd.Err()
}
case CmdTypeAREntrySlice:
if arEntrySliceCmd, ok := cmd.(interface {
Val() []AREntry
Err() error
}); ok {
return arEntrySliceCmd.Val(), arEntrySliceCmd.Err()
}
case CmdTypeMapStringString:
if mapCmd, ok := cmd.(interface {
Val() map[string]string
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
case CmdTypeMapStringInt:
if mapCmd, ok := cmd.(interface {
Val() map[string]int64
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
case CmdTypeMapStringInterfaceSlice:
if mapCmd, ok := cmd.(interface {
Val() []map[string]interface{}
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
case CmdTypeMapStringInterface:
if mapCmd, ok := cmd.(interface {
Val() map[string]interface{}
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
case CmdTypeMapStringStringSlice:
if mapCmd, ok := cmd.(interface {
Val() []map[string]string
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
case CmdTypeMapMapStringInterface:
if mapCmd, ok := cmd.(interface {
Val() map[string]interface{}
Err() error
}); ok {
return mapCmd.Val(), mapCmd.Err()
}
default:
// For unknown command types, return nil
return nil, nil
}
}
// If we can't get the command type, return nil
return nil, nil
}
//------------------------------------------------------------------------------
// IncrEXIntResult is the reply of an INCREX command issued via IncrEXInt.
// Value is the new value of the key; AppliedIncrement is the increment that
// the server actually applied (0 when an out-of-bounds operation was
// rejected, clamped when SATURATE was set).
type IncrEXIntResult struct {
Value int64
AppliedIncrement int64
}
type IncrEXIntCmd struct {
baseCmd
val IncrEXIntResult
}
var _ Cmder = (*IncrEXIntCmd)(nil)
func NewIncrEXIntCmd(ctx context.Context, args ...interface{}) *IncrEXIntCmd {
return &IncrEXIntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIncrEXInt,
},
}
}
func (cmd *IncrEXIntCmd) SetVal(val IncrEXIntResult) { cmd.val = val }
func (cmd *IncrEXIntCmd) Val() IncrEXIntResult {
cmd.await()
return cmd.val
}
func (cmd *IncrEXIntCmd) Result() (IncrEXIntResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *IncrEXIntCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *IncrEXIntCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
value, err := rd.ReadInt()
if err != nil {
return err
}
applied, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val = IncrEXIntResult{Value: value, AppliedIncrement: applied}
return nil
}
func (cmd *IncrEXIntCmd) Clone() Cmder {
return &IncrEXIntCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
// IncrEXFloatResult is the reply of an INCREX command issued via IncrEXFloat.
type IncrEXFloatResult struct {
Value float64
AppliedIncrement float64
}
type IncrEXFloatCmd struct {
baseCmd
val IncrEXFloatResult
}
var _ Cmder = (*IncrEXFloatCmd)(nil)
func NewIncrEXFloatCmd(ctx context.Context, args ...interface{}) *IncrEXFloatCmd {
return &IncrEXFloatCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIncrEXFloat,
},
}
}
func (cmd *IncrEXFloatCmd) SetVal(val IncrEXFloatResult) { cmd.val = val }
func (cmd *IncrEXFloatCmd) Val() IncrEXFloatResult {
cmd.await()
return cmd.val
}
func (cmd *IncrEXFloatCmd) Result() (IncrEXFloatResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *IncrEXFloatCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *IncrEXFloatCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
value, err := rd.ReadFloat()
if err != nil {
return err
}
applied, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val = IncrEXFloatResult{Value: value, AppliedIncrement: applied}
return nil
}
func (cmd *IncrEXFloatCmd) Clone() Cmder {
return &IncrEXFloatCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
//------------------------------------------------------------------------------
// AREntrySliceCmd is a command that returns index-value pairs from ARSCAN or ARGREP.
type AREntrySliceCmd struct {
baseCmd
val []AREntry
}
var _ Cmder = (*AREntrySliceCmd)(nil)
func NewAREntrySliceCmd(ctx context.Context, args ...any) *AREntrySliceCmd {
return &AREntrySliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeAREntrySlice,
},
}
}
func (cmd *AREntrySliceCmd) SetVal(val []AREntry) {
cmd.val = val
}
func (cmd *AREntrySliceCmd) Val() []AREntry {
cmd.await()
return cmd.val
}
func (cmd *AREntrySliceCmd) Result() ([]AREntry, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *AREntrySliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *AREntrySliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
if n == 0 {
cmd.val = make([]AREntry, 0)
return nil
}
cmd.val = make([]AREntry, n)
for i := range n {
if err = rd.ReadFixedArrayLen(2); err != nil {
return err
}
cmd.val[i].Index, err = rd.ReadUint()
if err != nil {
return err
}
cmd.val[i].Value, err = rd.ReadString()
if err != nil {
return err
}
}
return nil
}
func (cmd *AREntrySliceCmd) Clone() Cmder {
var val []AREntry
if cmd.val != nil {
val = make([]AREntry, len(cmd.val))
copy(val, cmd.val)
}
return &AREntrySliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
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,
},
"aliaslist": {
Request: routing.ReqDefault,
Response: routing.RespDefaultKeyless,
Tips: map[string]string{
routing.ReadOnlyCMD: "",
},
},
"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,
},
},
}
// defaultPolicyKeyless reports whether name (e.g. "ft.aliaslist") is registered
// in the static policy table as a plain keyless command: default request
// routing with a keyless response policy. Commands whose slot comes from a key
// (RespDefaultHashSlot, e.g. ft.suglen) or with special request routing
// (ReqSpecial, e.g. ft.cursor) are excluded — their key position must still be
// resolved. cmdFirstKeyPosWithInfo consults this so the initial slot
// computation on a cold command-info cache matches the policy the router
// applies once the command reaches routeAndRun.
func defaultPolicyKeyless(name string) bool {
i := strings.IndexByte(name, '.')
if i < 0 {
return false
}
policy, ok := defaultPolicies[name[:i]][name[i+1:]]
if !ok {
return false
}
return policy.Request == routing.ReqDefault && policy.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()
// Split on the first '.' without allocating (strings.Split allocates a slice
// on every call; this resolver runs on the hot per-command path — twice per
// command for the autopipeline cluster gates — so the allocation showed up in
// CPU profiles). Only a single module.command form is recognized, matching the
// prior len==2 check.
if dot := strings.IndexByte(command, '.'); dot >= 0 && strings.IndexByte(command[dot+1:], '.') < 0 {
module = command[:dot]
command = command[dot+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
}
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
ClientTracking(ctx context.Context, on bool, opt *ClientTrackingOptions) *StatusCmd
ClientTrackingOn(ctx context.Context, opt *ClientTrackingOptions) *StatusCmd
ClientTrackingOff(ctx context.Context) *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
InfoMap(ctx context.Context, section ...string) *InfoCmd
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
ArrayCmdable
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) *IntSliceCmd {
cmd := NewIntSliceCmd(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
}
// ClientTrackingOptions configures CLIENT TRACKING ON. See
// https://redis.io/commands/client-tracking/ for semantics.
type ClientTrackingOptions struct {
Redirect int64
Bcast bool
Prefixes []string
OptIn bool
OptOut bool
NoLoop bool
}
// ClientTracking enables or disables server-assisted client-side caching for
// the ONE connection that happens to serve this command. On a pooled client
// that connection is arbitrary, so this is only meaningful on a dedicated
// connection (see Client.Conn). When on is false, opt is ignored. Invalid
// option combinations are reported via the returned command's Err and nothing
// is sent to the server.
//
// Must not be combined with the built-in client-side cache: on a client
// configured with Options.ClientSideCache or ClientSideCacheConfig this
// command is rejected, because changing a pool connection's tracking state
// would silently break the cache's invalidation.
func (c cmdable) ClientTracking(ctx context.Context, on bool, opt *ClientTrackingOptions) *StatusCmd {
if !on {
return c.ClientTrackingOff(ctx)
}
return c.ClientTrackingOn(ctx, opt)
}
// ClientTrackingOn enables tracking on the serving connection. See
// ClientTracking for the pooled-client and built-in-CSC caveats.
func (c cmdable) ClientTrackingOn(ctx context.Context, opt *ClientTrackingOptions) *StatusCmd {
args := []interface{}{"client", "tracking", "on"}
if opt != nil {
if err := validateClientTrackingOptions(opt); err != nil {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
args = appendClientTrackingOptions(args, opt)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ClientTrackingOff disables tracking on the serving connection. See
// ClientTracking for the pooled-client and built-in-CSC caveats.
func (c cmdable) ClientTrackingOff(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "client", "tracking", "off")
_ = c(ctx, cmd)
return cmd
}
func validateClientTrackingOptions(opt *ClientTrackingOptions) error {
if opt.OptIn && opt.OptOut {
return errors.New("redis: CLIENT TRACKING OPTIN and OPTOUT are mutually exclusive")
}
if opt.Bcast && (opt.OptIn || opt.OptOut) {
return errors.New("redis: CLIENT TRACKING BCAST cannot be combined with OPTIN or OPTOUT")
}
if len(opt.Prefixes) > 0 && !opt.Bcast {
return errors.New("redis: CLIENT TRACKING PREFIX requires BCAST")
}
return nil
}
func appendClientTrackingOptions(args []interface{}, opt *ClientTrackingOptions) []interface{} {
if opt.Redirect != 0 {
args = append(args, "redirect", opt.Redirect)
}
if opt.Bcast {
args = append(args, "bcast")
}
for _, p := range opt.Prefixes {
args = append(args, "prefix", p)
}
if opt.OptIn {
args = append(args, "optin")
}
if opt.OptOut {
args = append(args, "optout")
}
if opt.NoLoop {
args = append(args, "noloop")
}
return args
}
// ------------------------------------------------------------------------------------------------
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(ctx, "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 {
if conf == nil {
cmd := NewStringCmd(ctx)
cmd.SetErr(errors.New("redis: ModuleLoadex nil config"))
return cmd
}
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
}
package redis
import (
"bytes"
"strconv"
"strings"
"github.com/redis/go-redis/v9/internal/proto"
)
// defaultCacheableCommands is the allow-list of read-only, deterministic
// commands whose responses may be stored in the client-side cache. Keys are
// lowercase to match baseCmd.Name() on the hot path.
var defaultCacheableCommands = map[string]struct{}{
// String commands
"get": {}, "mget": {}, "getbit": {}, "getrange": {},
"strlen": {}, "substr": {},
// Hash commands
"hget": {}, "hgetall": {}, "hmget": {},
"hkeys": {}, "hvals": {}, "hlen": {},
"hexists": {}, "hstrlen": {},
// List commands
"lindex": {}, "llen": {}, "lpos": {}, "lrange": {},
// Set commands
"scard": {}, "sismember": {}, "smembers": {}, "smismember": {},
"sdiff": {}, "sinter": {}, "sintercard": {}, "sunion": {},
// Sorted-set commands
"zcard": {}, "zcount": {}, "zlexcount": {}, "zmscore": {},
"zrange": {}, "zrangebylex": {}, "zrangebyscore": {},
"zrank": {}, "zrevrange": {}, "zrevrangebylex": {},
"zrevrangebyscore": {}, "zrevrank": {}, "zscore": {},
"zdiff": {}, "zinter": {}, "zunion": {},
// Bit commands
"bitcount": {}, "bitfield_ro": {}, "bitpos": {},
// Key/generic commands
"exists": {}, "type": {}, "sort_ro": {}, "lcs": {},
// Geo commands
"geodist": {}, "geohash": {}, "geopos": {}, "geosearch": {},
"georadiusbymember_ro": {}, "georadius_ro": {},
// Stream commands. XREAD is deliberately excluded: it supports BLOCK, and
// its $/+ IDs are state-relative, so identical args are not deterministic.
// XPENDING is excluded for the same class of reason: its extended form
// returns wall-clock-relative idle times and its IDLE filter is
// time-dependent, so identical args yield different correct results with
// no key modification (and therefore no invalidation).
"xlen": {}, "xrange": {}, "xrevrange": {},
// JSON (RedisJSON) commands
"json.get": {}, "json.mget": {}, "json.arrindex": {}, "json.arrlen": {},
"json.objkeys": {}, "json.objlen": {}, "json.resp": {},
"json.strlen": {}, "json.type": {},
// TimeSeries commands
"ts.get": {}, "ts.info": {}, "ts.range": {}, "ts.revrange": {},
}
// isCacheable reports whether cmd is eligible for client-side caching: its
// name is on the allow-list and it operates on at least one key.
func isCacheable(cmd Cmder) bool {
// Commands such as RawWriteToCmd stream replies directly to an io.Writer.
// Capturing their replies for CSC would buffer the entire response first,
// defeating their streaming and allocation guarantees.
if cmd.NoRetry() {
return false
}
if _, ok := defaultCacheableCommands[cmd.Name()]; !ok {
return false
}
// SORT_RO ... BY/GET reads pattern keys that extractRedisKeys can't
// enumerate, so its invalidations would be dropped and the result go stale.
// Plain SORT_RO is fine.
if cmd.Name() == "sort_ro" && sortROHasByGet(cmd) {
return false
}
return cmdFirstKeyPosWithInfo(cmd, nil) != 0
}
// sortROHasByGet reports whether a SORT_RO invocation uses BY or GET
// (case-insensitive), scanning past the command name and key. stringArg
// normalizes string, *string, and []byte tokens.
func sortROHasByGet(cmd Cmder) bool {
for i := 2; i < len(cmd.Args()); i++ {
if s := cmd.stringArg(i); strings.EqualFold(s, "by") || strings.EqualFold(s, "get") {
return true
}
}
return false
}
// isClientTrackingCmd reports whether cmd is a CLIENT TRACKING subcommand (any
// mode: ON, OFF, or with options). Name and stringArg normalize string,
// *string, and []byte arguments.
func isClientTrackingCmd(cmd Cmder) bool {
return cmd.Name() == "client" && strings.EqualFold(cmd.stringArg(1), "tracking")
}
// isSelectCmd reports whether cmd changes the selected database on its
// connection. CSC keys are namespaced with Options.DB, so a runtime SELECT
// would make the connection's actual database diverge from the cache namespace.
func isSelectCmd(cmd Cmder) bool {
return cmd.Name() == "select"
}
// isAuthCmd reports whether cmd changes the authenticated user on its
// connection. The cache namespace is fixed from Options.Username, so runtime
// authentication would make the connection identity diverge from it.
func isAuthCmd(cmd Cmder) bool {
return cmd.Name() == "auth"
}
// isProtocolChangingHelloCmd reports whether HELLO includes a protocol version
// (and can therefore switch a tracked RESP3 connection to RESP2). A bare HELLO
// only reports connection properties and is safe.
func isProtocolChangingHelloCmd(cmd Cmder) bool {
return cmd.Name() == "hello" && len(cmd.Args()) > 1
}
// isResetCmd reports whether cmd resets all server-side connection state.
// RESET disables tracking, switches to RESP2, deauthenticates, and changes
// other state that a pooled CSC connection relies on.
func isResetCmd(cmd Cmder) bool {
return cmd.Name() == "reset"
}
// isSubscribeCmd reports whether a raw command would turn an ordinary pooled
// connection into a Pub/Sub connection. Pub/Sub pushes are deliberately left
// for the dedicated PubSub reader, so the CSC drainer cannot safely own such a
// connection.
func isSubscribeCmd(cmd Cmder) bool {
switch cmd.Name() {
case "subscribe", "psubscribe", "ssubscribe":
return true
default:
return false
}
}
// buildCacheKey returns the RESP-encoded form of the command's argument list,
// used as a collision-free canonical cache key. ok is false when the writer
// cannot marshal the arguments, in which case the caller must skip caching
// rather than bucket the command under an empty key.
func buildCacheKey(cmd Cmder) (string, bool) {
args := cmd.Args()
if len(args) == 0 {
return "", false
}
var buf bytes.Buffer
if err := proto.NewWriter(&buf).WriteArgs(args); err != nil {
return "", false
}
return buf.String(), true
}
// keyArg renders the key argument at pos exactly as proto.Writer sends it to
// the server, so invalidation lookups match the key names in the server's
// "invalidate" pushes. Only types whose stringArg rendering is byte-identical
// to the wire encoding are accepted (fmt.Sprint of any integer matches the
// writer's base-10 strconv output); for anything else — pointers, bools,
// times, durations, floats, BinaryMarshaler values — the rendering can
// diverge, the invalidation would never match, and the entry would be served
// stale forever, so ok=false and the caller skips caching (see processCached).
func keyArg(cmd Cmder, pos int) (string, bool) {
args := cmd.Args()
if pos < 0 || pos >= len(args) {
return "", false
}
switch args[pos].(type) {
case string, []byte,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return cmd.stringArg(pos), true
}
return "", false
}
// extractRedisKeys returns the Redis key arguments from cmd. The result lets
// the cache map incoming invalidations back to affected entries. Returns nil
// (caller skips caching) when any key
// argument cannot be rendered in its wire form (see keyArg).
func extractRedisKeys(cmd Cmder) []string {
firstKey := cmdFirstKeyPosWithInfo(cmd, nil)
if firstKey == 0 {
return nil
}
argsLen := len(cmd.Args())
if firstKey >= argsLen {
return nil
}
switch cmd.Name() {
// All remaining args from firstKeyPos are keys.
case "mget", "exists", "sdiff", "sinter", "sunion":
keys := make([]string, 0, argsLen-firstKey)
for i := firstKey; i < argsLen; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
// Numkeys pattern: numkeys at args[1], keys from args[2].
case "sintercard", "zdiff", "zinter", "zunion":
if argsLen < 3 {
return nil
}
numKeys, err := strconv.Atoi(cmd.stringArg(1))
if err != nil || numKeys <= 0 {
return nil
}
keys := make([]string, 0, numKeys)
for i := 2; i < 2+numKeys && i < argsLen; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
// LCS: exactly two consecutive keys starting at firstKeyPos.
case "lcs":
if firstKey+1 >= argsLen {
return nil
}
k1, ok1 := keyArg(cmd, firstKey)
k2, ok2 := keyArg(cmd, firstKey+1)
if !ok1 || !ok2 {
return nil
}
return []string{k1, k2}
// JSON.MGET: keys from firstKeyPos to second-to-last (last arg is the
// JSON path, not a key).
case "json.mget":
lastKey := argsLen - 2
if lastKey < firstKey {
return nil
}
keys := make([]string, 0, lastKey-firstKey+1)
for i := firstKey; i <= lastKey; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
}
// Single key at firstKeyPos (GET, HGET, LRANGE, ...).
k, ok := keyArg(cmd, firstKey)
if !ok {
return nil
}
return []string{k}
}
package redis
import (
"bytes"
"context"
"errors"
"reflect"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/push"
)
// cscRegisterCleanups arranges for a client dropped without Close to stop its
// background CSC drainer. The drainer's exit path revokes its pool's cache
// coverage; the runtime cleanup itself stays non-blocking and never captures
// *Client, so the wrapper remains collectible.
func cscRegisterCleanups(c *Client) {
h := c.baseClient.cscDrainHandle
if h == nil {
return
}
// The weak back-reference (cscClientWeak) is published by the caller BEFORE
// attachCSC starts the drainer (see NewClient), so the push-handler adapter's
// canonical close reads it race-free. It is deliberately NOT set here: a write
// after the drainer is already live would race that read.
// Capture cscActive (a standalone *atomic.Bool, not *Client) so the cleanup
// also stops clones from serving once the drainer is gone. Capture the miss
// coalescer too (set during attach; nil when off): its sessions wait on
// their own stop channel, so a client dropped WITHOUT Close would leak them
// and everything they retain (cache, pools). stopWorkers is idempotent and
// signal-only, keeping the cleanup non-blocking; the drain-cancel then
// settles any queued request whose reservation would otherwise stay
// IN_PROGRESS and block a cache-sharing client until StaleTimeout (channel
// receive gives each request exactly one consumer).
active := c.baseClient.cscActive
mc := c.baseClient.cscMissCoalescer.Load()
// Capture the refresh handle too (set during attach; nil when refresh is off):
// runCSCRefresher parks on its ticker/queue and holds *baseClient, so a client
// dropped WITHOUT Close would leak the refresher goroutine and everything it
// retains (cache, pools) — the coalescer/drainer stops below would not reach it.
// signalStop is idempotent and non-blocking, matching the other stops here. The
// handle is channels only (no *Client), so capturing it keeps the wrapper
// collectible.
rh := c.baseClient.cscRefreshHandle
// Capture this client's refresh queue too: signalStop stops the refresher
// goroutine, but with a SHARED cache+processor the invalidate handler still
// holds this queue as (possibly) the active refresh binding. If a sibling
// client survives, invalidations would keep feeding a stopped queue and the
// shared cache's refresh-on-invalidate would silently degrade to plain
// eviction. clearRefreshQueue unbinds it and restores the sibling's binding,
// mirroring stopCSCRefresher on the clean Close path. A queue is only ever
// created inside attachSharedTrackingCSC, which also builds the drain handle
// (startBackgroundDrainer), so h.invalidateHandler is set whenever q is
// non-nil; a Conn() clone bails before startCSCRefresher and never has one.
q := c.baseClient.cscRefreshQueue
runtime.AddCleanup(c, func(h *cscDrainHandle) {
// Order mirrors stopBackgroundDrainer (the clean Close path) but stays
// fully NON-BLOCKING — a GC finalizer must never wait. Unbind this queue and
// signal the refresher stop while serving is STILL ON (active==true), then
// deactivate: refreshInvalidatedBatch bails once cscActive is false, so
// deactivating first would make the refresher's stop-drain flush a no-op and
// drop in-window hot keys for a surviving sibling on a shared cache.
// signalStop only closes a channel, so the final flush runs ASYNCHRONOUSLY;
// active.Store(false) lands nanoseconds later and the flush may still miss
// the window — the guaranteed final-flush is the Close path, which joins the
// refresher before deactivating. This reorder is the free, strictly-better
// best effort for the drop-without-Close case.
//
// clearRefreshQueue detaches+signals the batcher but the finalizer DISCARDS
// it (no join): the batcher's own run() drains and exits asynchronously. The
// clean Close/self-disable paths join it instead.
//
// This deliberately DIVERGES from stopCSCRefresherAndCoalescer's canonical
// order (coalescer-conn-release before the refresher flush): the finalizer is
// signal-only and never joins, so it never waits on a refresher flush and the
// connection-release ordering is moot here — the pool may already be gone. The
// blocking, correctly-ordered teardown is the Close / self-disable path.
if q != nil && h.invalidateHandler != nil {
h.invalidateHandler.clearRefreshQueue(q)
}
if rh != nil {
rh.signalStop()
}
if active != nil {
active.Store(false)
}
if mc != nil {
mc.stopWorkers()
// Retry-uncached, matching every other stop path: a clone still
// alive re-runs the read on the (possibly still open) pool instead
// of surfacing a spurious ErrClosed.
mc.drainQueueErr(errCSCRetryUncached)
}
h.signalStop()
}, h)
}
// ClientSideCacheConfig configures the built-in client-side cache. Pass a
// non-nil value to Options.ClientSideCacheConfig to enable caching on a RESP3
// client.
//
// Experimental: this API may change in a minor release.
type ClientSideCacheConfig = CacheConfig
const (
invalidatePushName = "invalidate"
// cscNamespaceSep separates fixed-width/logically-delimited namespace parts
// from the command or Redis key.
cscNamespaceSep = "\x00"
)
// cscNamespacePrefix scopes a shared cache by database and fixed ACL identity.
// Password rotation does not change identity; provider-backed identities are
// rejected before attachment.
func cscNamespacePrefix(db int, username string) string {
return strconv.Itoa(db) + cscNamespaceSep +
strconv.Itoa(len(username)) + ":" + username + cscNamespaceSep
}
func cscNamespacedKey(prefix, key string) string {
return prefix + key
}
// invalidateHandler propagates RESP3 "invalidate" push notifications into the
// shared client-side cache. keyPrefix scopes incoming key names so a shared
// cache cannot collide across databases or fixed ACL identities.
//
// The binding (cache, keyPrefix) is mutable under mu: the owning client's teardown
// RELEASES it (cache=nil) instead of unregistering the handler, so the handler
// can stay registered protected — application code holding the processor
// cannot silently unregister invalidation out from under a live client — while
// a successor client on the same processor can still rebind it (see
// registerInvalidateHandler).
type invalidateHandler struct {
mu sync.RWMutex
cache Cache
keyPrefix string
users int
// refresh, when set, receives evicted-but-hot entries for immediate refetch.
// Feeding it must never block the invalidation-delivery path. It is the TOP
// of refreshStack: clients sharing this handler each attach their own queue
// and the newest attachment is active; when it clears, the next-newest live
// binding is RESTORED (closing the newest owner must not sever an older
// sibling's still-running refresher).
refresh *cscRefreshQueue
// refreshStack holds every live refresh binding in attach order (older
// first). Guarded by mu. Small: one entry per client sharing the handler.
refreshStack []*cscRefreshQueue
// batcher offloads invalidation cache-deletes to a windowed background
// goroutine (Options.ClientSideCacheInvalidationBatchWindow). Lazily started
// (ensureBatcher) and nil when disabled; guarded by mu. Stopped and cleared
// when the last user releases (releaseLocked), so its goroutine does not live
// past the binding re-arming its timer forever; a later re-acquire starts a
// fresh one (picking up the successor's window).
batcher *cscInvalBatcher
// invalBatchWindow is the EFFECTIVE coalescing window for the batcher above:
// the strictest window folded in across attached clients (see
// setInvalBatchWindow — 0/inline strictest, then smaller nonzero). 0
// (default) deletes inline. invalBatchWindowSet distinguishes "no client has
// attached a window yet" from an explicit 0 (inline) that must win over any
// later nonzero window. Read under mu alongside cache/keyPrefix/refresh.
invalBatchWindow time.Duration
invalBatchWindowSet bool
}
// setInvalBatchWindow folds one client's window into the shared handler's
// effective window at attach time. Clients sharing a handler may configure
// different windows but the handler runs ONE batcher, so the effective window
// is the STRICTEST attached: smallest nonzero, with explicit zero (inline
// deletes) strictest of all — tightening can never violate a client's staleness
// bound, while taking the latest as-is could loosen an earlier stricter one.
// Not re-loosened on close (bindings carry no identity; staying stricter costs
// only efficiency). On a tighten the running batcher (window fixed at creation)
// is stopped — stop() flushes, so no queued delete is lost — and the next
// invalidation rebuilds via ensureBatcher.
func (h *invalidateHandler) setInvalBatchWindow(w time.Duration) {
h.mu.Lock()
if h.invalBatchWindowSet {
cur := h.invalBatchWindow
// Strictness: 0 (inline) is strictest; among nonzero, smaller is stricter.
stricter := (w == 0 && cur != 0) || (w != 0 && cur != 0 && w < cur)
if !stricter {
h.mu.Unlock()
return
}
}
h.invalBatchWindowSet = true
h.invalBatchWindow = w
// On a tighten drop the running batcher (window fixed at creation): the next
// invalidation rebuilds under the NEW (stricter) window. Detach+stop under the
// lock, then join OUTSIDE it. Joining under h.mu would stall a sibling in
// ensureBatcher/HandlePushNotification; the join keeps the old worker's
// stop-drain from applying after the rebuild — a late apply under the looser
// contract could evict an entry the stricter-window batcher just repopulated.
b := h.detachBatcherLocked()
h.mu.Unlock()
if b != nil {
b.join()
}
}
// detachBatcherLocked removes the running batcher from the handler and SIGNALS it
// to stop, under h.mu; returns the detached batcher (nil when none). It never
// JOINS: join() waits on the batcher's stop-drain, which must not run while h.mu is
// held (it would stall a sibling on the hot path — ensureBatcher/HandlePushNotification)
// and must never block the GC finalizer. Callers needing a SYNCHRONOUS teardown
// (the Close path) join the returned batcher AFTER releasing h.mu; the GC finalizer
// discards it (signal-only). A repointing caller (set/clearRefreshQueue) stores the
// new refresh binding on the batcher BEFORE calling this, so the stop-drain offers
// hot keys to the surviving refresher.
func (h *invalidateHandler) detachBatcherLocked() *cscInvalBatcher {
b := h.batcher
if b == nil {
return nil
}
h.batcher = nil
b.stop()
return b
}
// ensureBatcher lazily starts the windowed invalidation batcher. The common
// case (already started) is a shared RLock; only first-start takes the write
// lock, so the hot invalidation path stays cheap.
func (h *invalidateHandler) ensureBatcher() *cscInvalBatcher {
h.mu.RLock()
b := h.batcher
h.mu.RUnlock()
if b != nil {
return b
}
h.mu.Lock()
defer h.mu.Unlock()
// Do not start a batcher for a released binding. releaseLocked stops+nils the
// batcher under this same lock when users hits 0, so a push racing that last
// release must NOT resurrect a goroutine that nothing would ever stop (once
// users is 0, release() no longer runs). The caller falls back to the inline
// delete path when this returns nil.
if h.users == 0 {
return nil
}
// Read the window under this lock (not a caller snapshot a concurrent
// tighten could supersede). 0 = batching off: return nil, caller deletes
// inline.
w := h.invalBatchWindow
if w <= 0 {
return nil
}
if h.batcher == nil {
h.batcher = &cscInvalBatcher{
window: w,
// Snapshot the cache for the batcher's lifetime (⊂ the binding's:
// releaseLocked stops it before clearing it) so the release-time
// stop-drain still applies queued deletes after h.cache is nilled.
cache: h.cache,
ch: make(chan cscInvalItem, 8192),
wake: make(chan struct{}, 1),
stopCh: make(chan struct{}),
done: make(chan struct{}),
}
// refresh is atomic so set/clearRefreshQueue can repoint it before stop;
// seed it with the current binding before the worker starts.
h.batcher.refresh.Store(h.refresh)
go h.batcher.run()
}
return h.batcher
}
// clearRefreshQueue clears the handler's refresh binding ONLY while it still
// points at q: two clients sharing one cache and push processor each attach
// their own queue (last attach wins), and a closing client must not clobber
// the binding of a live sibling that re-attached after it — invalidations
// would keep deleting entries but silently stop feeding the survivor's
// refresher.
//
// It returns the detached batcher (nil when none) so the caller can join() it
// OUTSIDE h.mu for a synchronous teardown; the GC finalizer discards it (must not
// block). See detachBatcherLocked.
func (h *invalidateHandler) clearRefreshQueue(q *cscRefreshQueue) *cscInvalBatcher {
if q == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
for i, b := range h.refreshStack {
if b == q {
h.refreshStack = append(h.refreshStack[:i], h.refreshStack[i+1:]...)
break
}
}
if h.refresh != q {
return nil // a newer sibling owns the active binding; nothing else changes
}
// Restore the next-newest live binding (nil when none): the surviving
// sibling's refresher keeps getting fed after the newest owner closes.
h.refresh = nil
if n := len(h.refreshStack); n > 0 {
h.refresh = h.refreshStack[n-1]
}
// Repoint the batcher at the surviving binding (nil if none) BEFORE stopping it,
// so the stop-drain offers evicted-hot keys to the live survivor's refresher, not
// the closing owner's — whose drainer is already gone, so its offers would just be
// dropped. An in-flight apply already holding the old pointer feeds its current
// batch to the old queue: a bounded, benign residual.
if h.batcher != nil {
h.batcher.refresh.Store(h.refresh)
}
return h.detachBatcherLocked()
}
// setRefreshQueue binds q as the active refresh queue and returns the detached
// batcher (nil when none) so the caller can join() it OUTSIDE h.mu — the attach
// stays synchronous without holding the join under the handler lock.
func (h *invalidateHandler) setRefreshQueue(q *cscRefreshQueue) *cscInvalBatcher {
h.mu.Lock()
defer h.mu.Unlock()
if q != nil {
found := false
for _, b := range h.refreshStack {
if b == q {
found = true
break
}
}
if !found {
h.refreshStack = append(h.refreshStack, q)
}
}
if h.refresh == q {
return nil
}
h.refresh = q
// A running batcher was created with the previous binding; drop it so the next
// invalidation rebuilds with the new one (stop() flushes, so no queued delete
// is lost). Repoint it at the new binding first, so the stop-drain feeds hot
// keys to q rather than the superseded refresher.
if h.batcher != nil {
h.batcher.refresh.Store(h.refresh)
}
return h.detachBatcherLocked()
}
// HandlePushNotification decodes ["invalidate", <keys>] notifications. A nil
// <keys> payload is emitted on FLUSHDB/FLUSHALL and triggers a full cache flush.
func (h *invalidateHandler) HandlePushNotification(
_ context.Context, _ push.NotificationHandlerContext, notification []interface{},
) error {
h.mu.RLock()
cache, keyPrefix, refresh := h.cache, h.keyPrefix, h.refresh
window := h.invalBatchWindow
h.mu.RUnlock()
if cache == nil || len(notification) < 2 {
return nil
}
switch payload := notification[1].(type) {
case nil:
// FLUSHDB/FLUSHALL: supersede the batcher's queued per-key deletes, then wipe
// the snapshotted cache. See fullFlush for the drop/flush ordering and the
// binding-pairing invariant.
h.fullFlush(cache)
case []interface{}:
// Count incoming invalidations at the choke point: one per key named in the
// push, BEFORE batching/dedup/spill. Applied deletes are counted separately
// (DeleteByRedisKey / deleteByRedisKeyCollectingHot) and diverge from this
// under dedup — that gap is the signal (see CSCRefreshStats).
if lc, ok := cache.(*LocalCache); ok {
var n uint64
for _, k := range payload {
switch k.(type) {
case string, []byte:
n++
}
}
lc.invalidations.Add(n)
}
// Offload path: enqueue keys to the windowed background batcher instead of
// deleting inline, so invalidation work does not steal time from the
// coalescer's miss-reply reader (the low-concurrency churn p99 tail).
if window > 0 {
if _, ok := cache.(*LocalCache); ok {
// nil when the binding was just released (users==0) or when a
// concurrent window change turned batching off: fall through to
// the inline delete path below rather than enqueue on a nil
// batcher (which would panic).
// Pair the batcher with the SNAPSHOT cache: after a last-user release +
// rebind to a different cache (A->B) between the entry snapshot and here,
// ensureBatcher returns B's batcher, which would delete B's entries for a
// push meant for A (A would keep serving stale). fullFlush guards the same
// A->B pairing. sameCache also returns false for a non-comparable cache
// type, so such caches fall through to the inline delete path (correct,
// just not batched).
if b := h.ensureBatcher(); b != nil && sameCache(b.cache, cache) {
// Snapshot fetch-order ONCE for the whole push (mirrors the inline delete
// path below and cscInvalItem.fetchSnap): a per-key load inside enqueue
// would include a fetch reserved AFTER this push was observed but before
// the loop reached that key, so apply would not treat it as newer and
// could evict the fresh value / cancel its in-progress reservation.
fetchSnap := cscFetchSeq.Load()
for _, k := range payload {
var name string
switch v := k.(type) {
case string:
name = v
case []byte:
name = string(v)
default:
continue
}
b.enqueueAt(cscNamespacedKey(keyPrefix, name), fetchSnap)
}
return nil
}
}
}
var hot []cscRefreshTarget
lc, canRefresh := cache.(*LocalCache)
canRefresh = canRefresh && refresh != nil
// Snapshot the fetch-order sequence ONCE, at notification-observe time, and reuse
// it for every key in this push. A live per-key load would include a fetch
// reserved AFTER this push was observed but before the loop reached that key, so
// collectHotAndDelete would not treat it as newer and would evict the fresh value
// / cancel its in-progress reservation (mirrors cscInvalItem.fetchSnap, taken at
// enqueue; see cacheEntry.fetchSeq).
fetchSnap := cscFetchSeq.Load()
for _, k := range payload {
var name string
switch v := k.(type) {
case string:
name = v
case []byte:
name = string(v)
default:
continue
}
nsKey := cscNamespacedKey(keyPrefix, name)
if !canRefresh {
if lc != nil {
// *LocalCache with refresh OFF: still honor the fetch-order guard
// (mirrors the batcher's !canRefresh branch), or a delayed invalidation
// would delete/cancel a miss reserved AFTER this push was observed
// (fetchSeq > fetchSnap) and wake coalesced waiters as duplicate misses.
// cscInvalNoHorizon = no refresh horizon; discard collected targets.
_ = lc.deleteByRedisKeyCollectingHot(nsKey, cscInvalNoHorizon, fetchSnap, nil)
} else {
// Non-*LocalCache: no per-entry fetch sequence to compare; plain delete.
cache.DeleteByRedisKey(nsKey)
}
continue
}
// Inline (window==0) path: observe and delete synchronously. An entry with a
// fetchSeq greater than the observe-time snapshot was reserved by a refetch
// issued after this push and is correctly kept (see cacheEntry.fetchSeq).
hot = lc.deleteByRedisKeyCollectingHot(nsKey, refresh.sinceToken.Load(), fetchSnap, hot[:0])
for i := range hot {
refresh.offer(hot[i])
}
}
}
return nil
}
// fullFlush wipes the snapshotted cache for a FLUSHDB/FLUSHALL (nil-payload)
// invalidation and supersedes the batcher's queued per-key deletes.
//
// drop() before Flush() bumps the batcher epoch, so anything already enqueued
// (pre-flush, redundant by the flush) is skipped at apply, while an invalidation
// racing in from another tracked connection AFTER this point carries the new epoch
// and still applies — its post-flush delete must not be lost.
//
// Drop the batcher ONLY when it still belongs to the cache being flushed
// (sameCache(h.cache, cache)). A same-cache batcher rebuild (setInvalBatchWindow /
// set/clearRefreshQueue, all under h.mu.Lock) must have its FRESH batcher dropped, or
// the new batcher's queued deletes survive the flush and evict post-flush
// repopulations. But a last-user release + rebind to a DIFFERENT cache (A->B) between
// the caller's entry snapshot and this RLock leaves h.batcher = B's while cache = A;
// dropping B's batcher would bump B's epoch and skip B's queued deletes while only A is
// flushed, so B would serve stale (#3989). The sameCache guard drops the batcher only
// when the binding is unchanged. sameCache (not ==) also avoids a panic when the cache's
// dynamic type is non-comparable; for such a type it returns false, so the batcher is
// not dropped on flush — a bounded spurious miss, correct versus a crash.
//
// Flush the CACHE SNAPSHOT, not the live h.cache: a last-user releaseLocked can nil
// h.cache, and a guarded `if h.cache != nil` would then silently skip the wipe. RLock
// (not Lock) suffices — it blocks the write-locked rebuilds — and drop()/Flush() take
// their own locks, not h.mu, so there is no lock-order cycle.
func (h *invalidateHandler) fullFlush(cache Cache) {
h.mu.RLock()
defer h.mu.RUnlock()
if sameCache(h.cache, cache) && h.batcher != nil {
h.batcher.drop()
}
cache.Flush()
}
func (h *invalidateHandler) release() {
h.mu.Lock()
var b *cscInvalBatcher
if h.users > 0 {
b = h.releaseLocked()
}
h.mu.Unlock()
// Join OUTSIDE h.mu: the batcher's stop-drain must not run under the handler
// lock (it would stall a sibling on the hot path). release() is reached only from
// the drainer goroutine's exit (Close or self-disable), never the GC finalizer, so
// blocking here is fine and gives Close a synchronous, no-straggler teardown.
if b != nil {
b.join()
}
}
// releaseLocked drops one handler user and, on the last release, tears the binding
// down. It DETACHES and signals the batcher but does not join it (see
// detachBatcherLocked); it returns the detached batcher so release() can join
// OUTSIDE h.mu. Returns nil while other users remain.
func (h *invalidateHandler) releaseLocked() *cscInvalBatcher {
h.users--
if h.users != 0 {
return nil
}
h.cache = nil
h.keyPrefix = ""
// Stop the windowed batcher so its goroutine does not outlive the binding.
// Detached+signalled here; release() joins it outside the lock, so the last user
// closing gets a synchronous teardown (on a shared injected cache a late apply
// could otherwise evict an entry a successor client just repopulated). A later
// re-acquire starts a fresh batcher via ensureBatcher.
b := h.detachBatcherLocked()
// A fresh binding folds in its own window; do not inherit this one's.
h.invalBatchWindow = 0
h.invalBatchWindowSet = false
// Clear the refresh bindings with the binding itself: a client dropped
// without Close never runs clearRefreshQueue, and a successor reusing
// this handler must not inherit (or later restore) a dead queue whose
// consumer is gone — hot entries offered there would vanish silently.
h.refresh = nil
h.refreshStack = nil
return b
}
// sameCache compares Cache interface values without panicking when an
// implementation uses a non-comparable value type.
func sameCache(a, b Cache) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
typ := reflect.TypeOf(a)
return typ == reflect.TypeOf(b) && typ.Comparable() && a == b
}
func isNilCache(cache Cache) bool {
if cache == nil {
return true
}
v := reflect.ValueOf(cache)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// errInvalidateHandlerBound: piggybacking on a handler bound to a live
// different cache would leave the new cache uninvalidated.
var errInvalidateHandlerBound = errors.New(`csc: a different "invalidate" push handler is already registered`)
// bindTo binds the handler to (cache, keyPrefix). Success when that is already the
// binding (a derived Client.Conn sharing the parent's processor and cache) or
// when the handler was released by a previous owner's teardown (rebind);
// errInvalidateHandlerBound otherwise.
func (h *invalidateHandler) bindTo(cache Cache, keyPrefix string) error {
h.mu.Lock()
defer h.mu.Unlock()
switch {
case sameCache(h.cache, cache) && h.keyPrefix == keyPrefix:
h.users++
return nil
case h.cache == nil:
h.cache, h.keyPrefix = cache, keyPrefix
h.users = 1
return nil
default:
return errInvalidateHandlerBound
}
}
// lookupInvalidateHandler returns the processor's CSC invalidate handler, nil
// when absent or foreign.
func lookupInvalidateHandler(p push.NotificationProcessor) *invalidateHandler {
if p == nil {
return nil
}
h, _ := p.GetHandler(invalidatePushName).(*invalidateHandler)
return h
}
func registerInvalidateHandler(p push.NotificationProcessor, cache Cache, keyPrefix string) error {
if p == nil || cache == nil {
return nil
}
if existing := p.GetHandler(invalidatePushName); existing != nil {
h, ok := existing.(*invalidateHandler)
if !ok {
return errInvalidateHandlerBound
}
return h.bindTo(cache, keyPrefix)
}
// VoidProcessor (RESP2) returns an error here; the caller treats it as
// "CSC not available" rather than fatal. Registered PROTECTED: application
// code holding the processor must not be able to unregister invalidation
// under a live client (that would serve unbounded-stale hits with no
// signal); owner teardown releases the BINDING instead of the handler.
err := p.RegisterHandler(invalidatePushName, &invalidateHandler{
cache: cache,
keyPrefix: keyPrefix,
users: 1,
}, true)
if err == nil {
return nil
}
// Another client can register the same protected handler between GetHandler
// and RegisterHandler. Re-read it and accept the compatible binding.
if existing := p.GetHandler(invalidatePushName); existing != nil {
h, ok := existing.(*invalidateHandler)
if !ok {
return errInvalidateHandlerBound
}
return h.bindTo(cache, keyPrefix)
}
return err
}
// attachCSC dispatches to the invalidation strategy in
// Options.ClientSideCacheStrategy. Safe with a nil cache; on failure c.csc stays
// nil and commands fall back to normal round-trips. Adding a strategy: a new
// CSCStrategy constant plus cases in Options.init and here.
func (c *baseClient) attachCSC(ctx context.Context, cache Cache) {
if isNilCache(cache) || c.opt.Protocol != 3 {
return
}
// Credential providers may return a different ACL identity over the
// client's lifetime (or per context/connection), while the cache namespace
// is fixed when the client is created. Fixed credentials remain safe because
// the ACL username is included in the length-delimited namespace below.
if c.opt.StreamingCredentialsProvider != nil ||
c.opt.CredentialsProviderContext != nil ||
c.opt.CredentialsProvider != nil {
internal.Logger.Printf(ctx,
"redis: client-side caching is disabled with credential providers")
return
}
c.cscKeyPrefix = cscNamespacePrefix(c.opt.DB, c.opt.Username)
switch c.opt.ClientSideCacheStrategy {
case CSCStrategySharedTracking:
c.attachSharedTrackingCSC(ctx, cache)
default:
// Options.init clamps unknown strategies to SharedTracking; delegate anyway.
c.attachSharedTrackingCSC(ctx, cache)
}
}
// attachSharedTrackingCSC wires SharedTracking: one shared cache, per-conn CLIENT
// TRACKING, a background drainer, and the owning-conn eviction hook. DB-0 only:
// tracking is bound to the conn's DB and a runtime SELECT does not re-key it.
func (c *baseClient) attachSharedTrackingCSC(ctx context.Context, cache Cache) {
if c.opt.DB != 0 {
internal.Logger.Printf(ctx,
"csc: client-side caching is restricted to DB 0; disabling CSC for client configured with DB=%d. "+
"Use one client per DB if you need caching against non-zero databases.", c.opt.DB)
return
}
// A pooler without idle-conn draining (e.g. Client.Conn's StickyConnPool)
// can't apply buffered invalidations, so stay uncached.
if _, ok := c.connPool.(idleConnDrainer); !ok {
return
}
// The lifecycle hook serializes cache publication with connection removal
// and socket replacement. Without it, a reply can become visible after its
// tracking coverage is gone.
reg, ok := c.connPool.(poolHookSupport)
if !ok || !reg.SupportsPoolHooks() {
return
}
if err := registerInvalidateHandler(c.pushProcessor, cache, c.cscKeyPrefix); err != nil {
internal.Logger.Printf(ctx, "csc: failed to register invalidate handler: %v", err)
return
}
// Thread the invalidation-batch window from Options before any push can
// arrive, so the batcher (if enabled) sees the configured window on the very
// first invalidation rather than a zero default.
if ih := lookupInvalidateHandler(c.pushProcessor); ih != nil {
ih.setInvalBatchWindow(c.opt.ClientSideCacheInvalidationBatchWindow)
}
c.csc = cache
c.registerConnEvictHook(cache, reg)
c.startBackgroundDrainer()
c.startCSCRefresher()
c.startCSCMissCoalescer()
}
// cscHook returns the shared evict-on-remove hook, nil when CSC is off.
func (c *baseClient) cscHook() *cscEvictOnRemoveHook {
h, _ := c.cscPoolHook.(*cscEvictOnRemoveHook)
return h
}
// cscInstallConnCloseHook evicts cn's owned entries on any close — including the
// ConnMaxLifetime/idle retirement path (CloseConn) that bypasses the OnRemove
// hook — so entries don't outlive the server tracking dropped at close. Uses the
// onCscClose slot so it doesn't clobber streaming-credentials cleanup.
func (c *baseClient) cscInstallConnCloseHook(cn *pool.Conn) {
cn.SetOnCscClose(func() error {
c.cscOnConnClose(cn.GetID())
return nil
})
}
// cscInstallConnReinitHook invalidates the old socket's cache coverage before
// SetNetConnAndInitConn replaces it. The later init can then safely enable
// tracking for the new socket without a post-swap publication window.
func (c *baseClient) cscInstallConnReinitHook(cn *pool.Conn) {
cn.SetOnCscReinit(func() {
c.cscEvictOwnedEntries(cn.GetID())
})
}
// cscOnConnClose evicts a closing conn's entries: via the shared hook (which
// records the removed-ring, closing the close-before-fulfill race), else scoped
// EvictByConn on the owning cache.
func (c *baseClient) cscOnConnClose(connID uint64) {
if h := c.cscHook(); h != nil {
h.markRemoved(connID)
return
}
if c.csc != nil {
c.csc.EvictByConn(connID)
}
}
// poolHookSupport is the pool capability SharedTracking needs to serialize
// cache publication with connection removal and reinitialization.
type poolHookSupport interface {
AddPoolHook(hook pool.PoolHook)
RemovePoolHook(hook pool.PoolHook)
SupportsPoolHooks() bool
}
// cscEvictOnRemoveHook evicts a connection's owned entries when the pool removes
// it (the server stops delivering their invalidations — Window 2), and tracks
// per-conn init generations so fulfillCached can catch a value whose owning
// conn was removed or re-initialized mid-fetch.
type cscEvictOnRemoveHook struct {
evictor Cache
mu sync.Mutex
// initGen counts a live conn's socket (re)initializations: bumped by
// cscEvictOwnedEntries before its eviction (first init included, so every
// serving conn has gen >= 1), deleted on removal/close. fulfillCached
// compares it with the generation captured at reply time.
initGen map[uint64]uint64
}
func (h *cscEvictOnRemoveHook) OnGet(_ context.Context, _ *pool.Conn, _ bool) (bool, error) {
return true, nil
}
func (h *cscEvictOnRemoveHook) OnPut(_ context.Context, _ *pool.Conn) (shouldPool, shouldRemove bool, err error) {
return true, false, nil
}
func (h *cscEvictOnRemoveHook) OnRemove(_ context.Context, cn *pool.Conn, _ error) {
if cn == nil {
return
}
h.markRemoved(cn.GetID())
}
// markRemoved forgets connID's generation, then evicts. Forgetting before
// evicting lets a racing fulfillCached see the change (a served conn's captured
// generation is >= 1, an absent entry reads 0) and drop an entry created after
// the eviction — closing the close-before-fulfill race.
func (h *cscEvictOnRemoveHook) markRemoved(connID uint64) {
h.forgetConn(connID)
h.evictor.EvictByConn(connID)
}
// bumpInitGen advances connID's coverage generation. On reinit it is called by
// the pre-swap hook, before the old socket and its server-side tracking table
// are replaced.
func (h *cscEvictOnRemoveHook) bumpInitGen(connID uint64) {
h.mu.Lock()
if h.initGen == nil {
h.initGen = make(map[uint64]uint64)
}
h.initGen[connID]++
h.mu.Unlock()
}
// invalidateConnCoverage revokes all cache coverage associated with connID.
// Bumping before eviction also rejects an in-flight fetch that completed on the
// connection just before it left the parent's invalidation drainer.
func (h *cscEvictOnRemoveHook) invalidateConnCoverage(connID uint64) {
h.bumpInitGen(connID)
h.evictor.EvictByConn(connID)
}
// initGenOf returns connID's current init generation (0 if never bumped).
func (h *cscEvictOnRemoveHook) initGenOf(connID uint64) uint64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.initGen[connID]
}
// forgetConn drops connID's init-generation entry: the conn was removed/closed,
// or its init failed before ever serving (the pubsub path would otherwise leak
// the entry — no OnRemove hook, close hook not yet installed).
func (h *cscEvictOnRemoveHook) forgetConn(connID uint64) {
h.mu.Lock()
delete(h.initGen, connID)
h.mu.Unlock()
}
// fulfillOwnedIfCovered linearizes the final coverage check with connection
// removal/re-init generation changes. Holding h.mu through FulfillOwned means
// either the old generation is rejected before the placeholder becomes valid,
// or publication wins first and the subsequent lifecycle path evicts it before
// closing/replacing the tracked socket.
func (h *cscEvictOnRemoveHook) fulfillOwnedIfCovered(
cacheKey string,
token, ownerConnID, capturedGen uint64,
value []byte,
) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.initGen[ownerConnID] != capturedGen {
return false
}
return h.evictor.FulfillOwned(cacheKey, token, ownerConnID, value)
}
// invalidateAllCoverage revokes every connection generation known to this
// client's pool and evicts the entries those connections own. Incrementing
// instead of deleting keeps in-flight fetches that captured an old generation
// from publishing after a drainer stops.
func (h *cscEvictOnRemoveHook) invalidateAllCoverage() {
h.mu.Lock()
connIDs := make([]uint64, 0, len(h.initGen))
for connID := range h.initGen {
h.initGen[connID]++
connIDs = append(connIDs, connID)
}
h.mu.Unlock()
for _, connID := range connIDs {
h.evictor.EvictByConn(connID)
}
}
// registerConnEvictHook wires the required OnRemove eviction hook.
func (c *baseClient) registerConnEvictHook(cache Cache, reg poolHookSupport) {
h := &cscEvictOnRemoveHook{evictor: cache, initGen: make(map[uint64]uint64)}
reg.AddPoolHook(h)
c.cscPoolHook = h
}
// cscEvictOwnedEntries evicts connID's entries on first init or immediately
// before a reinit/handoff replaces the socket and its tracking table. It
// prefers the shared hook (so Conn/Tx, which carry it but have a nil csc, still
// evict from the parent cache). Scoped only — no removed-ring (the conn keeps
// serving, and the ring never ages out); the fulfill-vs-re-init race is closed
// by the init-generation bump instead. No custom-cache flush (this also runs on
// first init).
func (c *baseClient) cscEvictOwnedEntries(connID uint64) {
if h := c.cscHook(); h != nil {
h.invalidateConnCoverage(connID)
return
}
if c.csc == nil {
return
}
c.csc.EvictByConn(connID)
}
// newStickyConnPool creates a derived sticky pool and revokes the claimed
// connection's parent-cache ownership before it becomes unreachable to the
// parent's idle-connection drainer.
func (c *baseClient) newStickyConnPool() *pool.StickyConnPool {
sticky := pool.NewStickyConnPool(c.connPool)
if h := c.cscHook(); h != nil {
sticky.SetOnFirstConn(func(cn *pool.Conn) {
if cn != nil {
h.invalidateConnCoverage(cn.GetID())
}
})
}
return sticky
}
// cscFetchCapture receives, from the successful attempt's reply read — while
// the serving connection is still held — everything the CSC fetch path needs to
// attribute the cached entry: the raw RESP reply, the conn id, and the conn's
// CSC init generation. The generation must be captured before the conn is
// released: a handoff queued at Put can re-init the socket (bumping the
// generation) before fulfillCached runs.
type cscFetchCapture struct {
raw []byte
connID uint64
initGen uint64
// key/token: the reservation a background refetch (refresh or miss-coalesce)
// must fulfil. Unused by the single-command path, which passes them to
// fulfillCached explicitly.
key string
token uint64
}
// cscConnInitGen returns connID's CSC init generation, captured by _process at
// reply time (while the conn is still held) and compared by fulfillCached via
// fulfillOwnedIfCovered. Zero without an active evict-on-remove hook.
func (c *baseClient) cscConnInitGen(connID uint64) uint64 {
if h := c.cscHook(); h != nil {
return h.initGenOf(connID)
}
return 0
}
// cscForgetConn drops connID's init-generation entry when initialization does
// not establish tracked coverage, either because init failed or tracking was
// rejected and CSC was disabled.
func (c *baseClient) cscForgetConn(connID uint64) {
if h := c.cscHook(); h != nil {
h.forgetConn(connID)
}
}
// errClientTrackingWithCSC rejects CLIENT TRACKING on clients with built-in CSC
// (see the guards in baseClient.process and generalProcessPipeline). The raw
// escape hatches — Do(ctx, "client", "tracking", ...) with string or []byte
// args, and pipelines — are also caught: the guard matches on the command's
// leading args, not the typed method.
var errClientTrackingWithCSC = errors.New(
"redis: CLIENT TRACKING is not allowed when client-side caching is enabled",
)
// errSelectWithCSC rejects runtime SELECT on clients with built-in CSC. Cache
// keys use Options.DB, while SELECT mutates only the chosen pool connection.
var errSelectWithCSC = errors.New(
"redis: SELECT is not allowed when client-side caching is enabled",
)
// errAuthWithCSC rejects runtime authentication because it can change one
// connection's ACL identity without changing the client's fixed cache namespace.
var errAuthWithCSC = errors.New(
"redis: AUTH is not allowed when client-side caching is enabled",
)
// errHelloWithCSC rejects HELLO with arguments because it can switch a tracked
// connection out of RESP3 (and can also change authentication).
var errHelloWithCSC = errors.New(
"redis: HELLO with arguments is not allowed when client-side caching is enabled",
)
// errResetWithCSC rejects RESET because it disables tracking and switches the
// connection to RESP2.
var errResetWithCSC = errors.New(
"redis: RESET is not allowed when client-side caching is enabled",
)
// errSubscribeWithCSC rejects raw subscriptions on the ordinary pool. The
// typed Subscribe methods use dedicated PubSub connections and remain allowed.
var errSubscribeWithCSC = errors.New(
"redis: SUBSCRIBE is not allowed on pooled connections when client-side caching is enabled",
)
// cscCommandError rejects commands that can make a pooled connection's state
// diverge from the assumptions used by CSC.
func (c *baseClient) cscCommandError(cmd Cmder) error {
// The successful attachment signal is shared with derived clients.
// initConn's internal command wrapper is exempt during library setup.
if !c.cscTrackingRequested() || c.allowClientTracking {
return nil
}
switch {
case isClientTrackingCmd(cmd):
return errClientTrackingWithCSC
case isSelectCmd(cmd):
return errSelectWithCSC
case isAuthCmd(cmd):
return errAuthWithCSC
case isProtocolChangingHelloCmd(cmd):
return errHelloWithCSC
case isResetCmd(cmd):
return errResetWithCSC
case isSubscribeCmd(cmd):
return errSubscribeWithCSC
default:
return nil
}
}
// cscDrainHandle owns the drainer lifecycle and serializes client teardown.
// stop signals shutdown; done is closed on exit so Close can join.
type cscDrainHandle struct {
stop chan struct{}
done chan struct{}
stopOnce sync.Once
teardownOnce sync.Once
workersStopOnce sync.Once
handlerCloseOnce sync.Once
closeOnce sync.Once
closeErr error
invalidateHandler *invalidateHandler
// workersMu/workersTornDown close the race between
// stopCSCRefresherAndCoalescer's one-time teardown and
// startCSCRefresher/startCSCMissCoalescer's publish step (a bot review
// flagged this: async conn init can call disableCSCServing, which the
// drainer goroutine observes on ITS OWN tick and reacts to with the same
// teardown Close uses — and that tick can in principle land concurrently
// with construction). workersStopOnce alone is not enough: it only
// guarantees the teardown body runs once, not that it runs AFTER a
// worker exists to be stopped. A start function checks workersTornDown
// under this lock immediately before publishing its queue/handle; if
// teardown already ran, it declines to start instead of creating a
// goroutine workersStopOnce can never be Do'd again to stop. See both
// start functions and stopCSCRefresherAndCoalescer's body.
//
// startBackgroundDrainer (attachSharedTrackingCSC's first call, before
// either start function) needs no matching guard: it is what allocates
// this struct and assigns it to c.cscDrainHandle, so no teardown path can
// observe a non-nil handle — let alone race this lock — until after it
// returns. Every teardown entry point (Close, the drainer's own
// self-disable) reads c.cscDrainHandle first and no-ops on nil.
workersMu sync.Mutex
workersTornDown bool
}
// signalStop closes stop at most once (so Close and the AddCleanup safety net
// can't double-close) and does not join — a GC cleanup must not block.
func (h *cscDrainHandle) signalStop() {
h.stopOnce.Do(func() { close(h.stop) })
}
// cscHandlerClient is exposed only through the background drainer's handler
// context. Close must return before the handler does, otherwise it would wait
// for the drainer goroutine that is currently invoking the handler.
type cscHandlerClient struct {
*baseClient
}
// closeCanonical closes through the canonical *Client wrapper while it is
// still alive — Client.Close also stops the cached autopipeliners, which
// baseClient.Close does not, so closing only the baseClient would leave flush
// goroutines running against closed pools. Falls back to baseClient.Close if
// the wrapper was already collected (weak ref: see baseClient.cscClientWeak).
func (c cscHandlerClient) closeCanonical() error {
if cl := c.cscClientWeak.Value(); cl != nil {
return cl.Close()
}
return c.baseClient.Close()
}
func (c cscHandlerClient) Close() error {
h := c.cscDrainHandle
if h == nil {
return c.closeCanonical()
}
h.handlerCloseOnce.Do(func() {
// Do NOT deactivate serving or signal the drainer stop here. This runs on
// the drainer goroutine (a custom push handler called Close), so the
// canonical close MUST be async — but let IT drive the teardown so
// stopBackgroundDrainer stops the coalescer and refresher in the canonical
// order with cscActive STILL TRUE, and the refresher's stop-drain flush
// re-fetches the in-window invalidated keys. Preemptively storing
// cscActive=false here made that flush a no-op (refreshInvalidatedBatch
// bails on the inactive check), dropping the last collection window — the
// same class the GC finalizer path was reordered to fix. The async close
// reaches deactivate itself, after the flush; the drainer keeps running the
// harmless idle drain until stopBackgroundDrainer signals its stop.
go func() {
if err := c.closeCanonical(); err != nil {
internal.Logger.Printf(context.Background(), "csc: deferred client close failed: %v", err)
}
}()
})
return nil
}
// cscMinDrainInterval floors a user-supplied DrainInterval: sub-millisecond
// timers are unreliable (https://github.com/golang/go/issues/53824).
const cscMinDrainInterval = time.Millisecond
// cscDrainInterval returns DrainInterval clamped to cscMinDrainInterval, or the
// default (cscDrainSkipWindow) when unset.
func (c *baseClient) cscDrainInterval() time.Duration {
if cfg := c.opt.ClientSideCacheConfig; cfg != nil && cfg.DrainInterval > 0 {
if cfg.DrainInterval < cscMinDrainInterval {
return cscMinDrainInterval
}
return cfg.DrainInterval
}
return cscDrainSkipWindow
}
// idleConnDrainer is the pooler capability the drainer needs (*pool.ConnPool has
// it). attachSharedTrackingCSC leaves a pooler without it uncached, rather than
// serve entries nothing would invalidate.
type idleConnDrainer interface {
DrainIdleConns(ctx context.Context, st *pool.DrainState, fn func(cn *pool.Conn) error)
}
// startBackgroundDrainer launches the per-client invalidation drainer: each tick
// runs one pool.DrainIdleConns pass, draining idle conns' buffered push frames.
// No-op for poolers that don't implement idleConnDrainer.
func (c *baseClient) startBackgroundDrainer() {
cp, ok := c.connPool.(idleConnDrainer)
if !ok {
return
}
if c.cscDrainHandle != nil {
return // already running (startBackgroundDrainer runs once, in NewClient)
}
h := &cscDrainHandle{
stop: make(chan struct{}),
done: make(chan struct{}),
invalidateHandler: lookupInvalidateHandler(c.pushProcessor),
}
c.cscDrainHandle = h
active := &atomic.Bool{}
active.Store(true)
c.cscActive = active
interval := c.cscDrainInterval()
// Custom-processor drain errors are connection-fatal (drainPushNotifications),
// so a PERSISTENTLY failing custom processor would turn every tick into a
// conn removal + redial — a sustained dial storm. Damping: after
// cscDrainCustomErrCap consecutive fatal custom-processor drains, disable
// CSC serving and stop the drainer (with one log line) instead of churning.
// Built-in processor errors are real conn desyncs and are never damped.
_, builtinProc := c.pushProcessor.(*push.Processor)
go func() {
defer func() {
// Self-disable exits (custom-processor damping, or a RESP3/tracking
// downgrade flipping cscActive that a tick then observes) run the SAME
// refresher+coalescer teardown as the clean Close path, so a client that
// KEEPS RUNNING after turning CSC off does not leak those goroutines for
// its life — the refresher parked on its window, and a coalescer session
// still holding a pool connection (F1). stopCSCRefresherAndCoalescer is
// idempotent with the Close path (workersStopOnce), and it joins the
// refresher and coalescer goroutines — never THIS drainer — so running it
// here cannot self-block. It also deactivates serving and UNBINDS this
// client's refresh queue (stopCSCRefresher -> clearRefreshQueue), which on
// a shared cache+processor restores a surviving sibling's binding, so this
// defer no longer needs its own active.Store(false)/clearRefreshQueue.
// Both self-disable paths reach here with cscActive already false
// (disableCSCServing set it; the damping branch above deactivates before
// returning), so the helper's refresher flush no-ops here — the ordering is
// kept only because it is free and uniform with the Close path.
c.stopCSCRefresherAndCoalescer()
if c.cscPoolHook != nil {
if reg, ok := c.connPool.(poolHookSupport); ok {
reg.RemovePoolHook(c.cscPoolHook)
}
}
if hook := c.cscHook(); hook != nil {
hook.invalidateAllCoverage()
}
if h.invalidateHandler != nil {
h.invalidateHandler.release()
}
close(h.done)
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
// st persists round/visited across ticks; single-goroutine, no lock.
var st pool.DrainState
consecFatal := 0
drain := func(cn *pool.Conn) error {
processorSucceeded, err := c.drainPushNotifications(cn)
switch {
case err != nil:
consecFatal++
case processorSucceeded:
// A successful processor invocation resets consecutive
// failures. A conn skipped without invoking the processor —
// including a clean replacement after a fatal drain — does
// not reset the counter.
consecFatal = 0
}
return err
}
for {
select {
case <-h.stop:
return
case <-ticker.C:
if !active.Load() {
return
}
// ctx bounds the whole pass; the drain read has its own hard deadline.
cycleCtx, cancel := context.WithTimeout(context.Background(), interval/2)
cp.DrainIdleConns(cycleCtx, &st, drain)
cancel()
if !builtinProc && consecFatal >= cscDrainCustomErrCap {
internal.Logger.Printf(context.Background(),
"csc: disabling client-side caching: the custom push notification processor failed %d consecutive drains "+
"(each failure removes a connection because the reader may be mid-frame); "+
"caching cannot be kept fresh safely with this processor", consecFatal)
// Deactivate BEFORE the defer's teardown so nothing else (ordinary reads
// via processCached, a normal in-flight flush's refreshInvalidatedBatch
// bail) keeps treating a persistently-broken custom processor as still
// serving. The refresher's OWN stop-drain flush no longer depends on this
// timing either way: it now unconditionally skips its refetch when
// stopping (see runCSCRefresher's h.stop case), so the exit stays prompt
// regardless. The defer still stops and joins both workers (F1).
active.Store(false)
return
}
}
}
}()
}
// disableCSCServing atomically stops cache hits and revokes all tracked
// connection coverage. The owner drainer observes the shared active flag on its
// next tick, including when a derived Conn or Tx discovered the incompatibility.
func (c *baseClient) disableCSCServing(ctx context.Context, reason string) {
active := c.cscActive
if active == nil || !active.CompareAndSwap(true, false) {
return
}
if hook := c.cscHook(); hook != nil {
hook.invalidateAllCoverage()
}
internal.Logger.Printf(ctx, "csc: disabling client-side caching: %s", reason)
}
// stopBackgroundDrainer joins the drainer goroutine and flushes an owned cache.
// The drainer's exit path releases its handler binding and pool hook, including
// when it stops itself. Owner-only: clones have no handle and return early.
// The fields are never cleared here — fulfillCached reads cscPoolHook on the hot
// path, so niling under a concurrent Close would race; teardownOnce makes repeat
// Close idempotent instead.
func (c *baseClient) stopBackgroundDrainer() {
h := c.cscDrainHandle
if h == nil {
return
}
h.teardownOnce.Do(func() {
// Stop the coalescer and refresher in the shared canonical order (coalescer
// connection released first, then the refresher's final flush runs while
// serving is still active, then deactivate). Idempotent with the drainer's
// own self-disable defer via workersStopOnce. The pool is still open here
// (closeResources tears it down after this), so the final refetch can run.
c.stopCSCRefresherAndCoalescer()
h.signalStop()
<-h.done
// The drainer's exit defer revoked and evicted this pool's coverage
// before closing done, including for injected caches shared elsewhere.
if c.cscOwnsCache && c.csc != nil {
c.csc.Flush()
}
})
}
// stopCSCRefresherAndCoalescer tears down the reader-miss coalescer and the
// refresh-on-invalidate goroutine in the one order that satisfies every teardown
// constraint, exactly once. It is shared by the clean Close path
// (stopBackgroundDrainer) and the SELF-DISABLE exit (the drainer goroutine's
// defer), so a client that turns CSC off itself stops these goroutines instead of
// leaking them for its lifetime (F1). workersStopOnce makes the two callers
// idempotent and race-free — whichever reaches it first runs the body, the other
// is a no-op — so cscRefreshHandle/cscMissCoalescer are never torn down twice
// concurrently. Neither caller is the refresher or a coalescer goroutine, so the
// joins below never self-block.
//
// Order:
// 1. Stop the coalescer FIRST, while cscActive is still TRUE. stopCSCMissCoalescer
// swaps its pointer to nil (new misses take the ordinary pooled path), signals
// its sessions, and JOINS them — RELEASING the held pool connection. On a small
// pool (PoolSize 1) under continuous miss traffic the coalescer can otherwise
// hold the only connection, so the refresher's final flush in step 2 would wait
// its full per-chunk deadline for every chunk (~160s Close stall) and lose the
// warming (F3). Stopping the coalescer with serving STILL ACTIVE is safe: every
// fetch stop path returns errCSCRetryUncached and every session stop path settles
// the same (settleErr tags all other failures cscSessionError), so processCached
// re-runs each read on the still-open pool — a caller never sees a raw ErrClosed.
// A teardown-window miss then takes the ordinary pooled path and contends with
// the step-2 flush for the connection, which is strictly better than the coalescer
// holding it (bounded by one RTT per miss, not the session's idle/recycle hold).
// This is why the earlier "deactivate before stopping the coalescer" step is gone:
// the errCSCRetryUncached backstop it relied on already makes deactivate-first
// unnecessary, and it conflicted with releasing the connection before the flush.
// 2. Stop the refresher. Its stop-drain flush no longer re-fetches the in-window
// invalidated keys over the network at all (runCSCRefresher's h.stop case):
// the drainer defer's invalidateAllCoverage revokes this pool's coverage and
// evicts every entry attributed to this client's refresh connection right
// after (pre-existing; see TestStopBackgroundDrainerEvictsSharedCacheCoverage),
// so a refetch here would only be discarded a moment later — it used to run
// anyway and could cost thousands of pointless round trips against a healthy
// server (codex #3989 P1). The targets are abandoned instead: counted into
// RefreshFailed, left evicted for a reader to repopulate. Because the flush no
// longer touches the network, the coalescer-first order in step 1 (freeing the
// pool connection) is no longer LOAD-BEARING for this step, but stays: it is
// still the one canonical order for Close and the self-disable defer, and
// still never serves a stale value.
// 3. Deactivate serving.
//
// STARTUP WINDOW: attachSharedTrackingCSC launches the drainer before it assigns
// cscRefreshHandle/cscMissCoalescer, so this can in principle run (via the
// drainer's self-disable defer, reacting to a HELLO/tracking downgrade an
// async conn init observed) WHILE startCSCRefresher/startCSCMissCoalescer are
// still constructing. workersStopOnce alone only guarantees body runs once —
// it does not guarantee a worker exists yet to be stopped by it. The
// workersMu/workersTornDown flag below (set FIRST, before either stop call)
// closes that gap: a start function checks the flag under the same lock
// immediately before publishing, so either it sees teardown hasn't happened
// yet and publishes (and this body's later stop call correctly joins it), or
// it sees the flag already set and declines to start at all — never leaving
// a worker workersStopOnce can no longer reach.
func (c *baseClient) stopCSCRefresherAndCoalescer() {
// Owner-only. Every worker (drainer, refresher, coalescer) is created together
// under a cscDrainHandle; a caller without one is a clone (clone() copies neither
// the handle nor the workers, see redis.go) or a client where CSC never attached,
// so it has nothing of its own to stop. Running body() here would call
// stopCSCRefresher, whose read-then-nil of cscRefreshHandle is UNSYNCHRONIZED
// (single-owner by design), racing the real owner's teardown.
h := c.cscDrainHandle
if h == nil {
return
}
body := func() {
h.workersMu.Lock()
h.workersTornDown = true
h.workersMu.Unlock()
c.stopCSCMissCoalescer()
c.stopCSCRefresher()
if c.cscActive != nil {
c.cscActive.Store(false)
}
}
h.workersStopOnce.Do(body)
}
// applyCachedReply populates cmd from a previously captured raw RESP reply by
// replaying it through the command's own readReply.
func applyCachedReply(cmd Cmder, raw []byte) error {
return cmd.readReply(proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1))
}
// classifyCachedReply reports the same error applyCachedReply would, without a
// caller command to populate. The miss coalescer uses it on the abandoned path
// (the caller returned and owns its Cmder again) to decide cache-vs-cancel: a
// value or Nil is cacheable, a top-level RESP error is not. It reads the frame
// generically, so it can only diverge from a concrete cmd's readReply on a
// well-formed reply of an unexpected shape — which the next reader re-parses and
// drops (see processCached), so a rare mis-cache self-heals.
func classifyCachedReply(raw []byte) error {
_, err := proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1).ReadReply()
return err
}
// isCacheableReplyResult reports whether a fully read Redis reply can be
// cached. redis.Nil is a normal negative lookup, not a transport/protocol
// failure; tracking will invalidate it if the key is later created.
func isCacheableReplyResult(err error) bool {
return err == nil || err == Nil
}
// cscDrainSkipWindow is the default SharedTracking drain period (overridable via
// ClientSideCacheConfig.DrainInterval). A buffered invalidation is picked up within
// roughly one round; MaxStaleness, when configured, is the hard time-based backstop.
const cscDrainSkipWindow = 5 * time.Millisecond
// cscDrainHardReadCap is the hard socket read deadline the drainer applies via
// Conn.WithReaderHardDeadline. It bounds only a rare partial-frame mid-read. A
// var (not const) so the tuning harness can sweep it.
var cscDrainHardReadCap = 50 * time.Millisecond
// cscDrainProbeReadCap bounds the non-consuming one-byte probe used only when
// an opaque transport may hold data that the socket readiness check cannot see.
const cscDrainProbeReadCap = 50 * time.Microsecond
// cscDrainCustomErrCap is the number of CONSECUTIVE fatal custom-processor
// drain errors after which the drainer disables CSC instead of removing (and
// redialing) a connection per tick indefinitely.
const cscDrainCustomErrCap = 8
// processCached runs the Get-Reserve-Fulfill lifecycle for a cacheable command.
// The caller (process) first makes sure that CSC is active and that cmd is
// eligible.
//
// startAttempt is the number of attempts already spent before this call. It is
// not zero only on the full-duplex divert (retryOnNormalConn) of a cacheable
// command. Such a command spent its first attempt on the FD socket and got a
// retryable reply. Give startAttempt to every processWithRetry fallback and to
// the fetch. If you do not, a diverted cache miss runs MaxRetries+1 retries
// after the FD attempt. That is one attempt too many. A cache HIT does no network
// attempt and returns before the retry loop, so startAttempt does not affect its
// retry BUDGET — but it still seeds the reported attempt COUNT (see below) so a
// diverted hit reports the FD attempt it already spent, not zero.
func (c *baseClient) processCached(ctx context.Context, cmd Cmder, state *processState, startAttempt int) error {
if err := ctx.Err(); err != nil {
return err
}
// Seed the reported attempt count for the OTel duration metric. A cache hit
// returns below without entering processWithRetry's loop (which is what sets
// state.attempts), so without this a diverted hit (startAttempt>0) would report
// zero attempts, hiding the FD socket attempt it already spent. A miss falls
// through to processWithRetry, whose loop overwrites this.
if state != nil {
state.attempts = startAttempt
}
// Once the drainer has stopped (owner Close, or the owner dropped without
// Close), no invalidations flow — a surviving clone must not serve stale hits.
if a := c.cscActive; a != nil && !a.Load() {
return c.processWithRetry(ctx, cmd, nil, state, startAttempt)
}
rawKey, ok := buildCacheKey(cmd)
if !ok {
return c.processWithRetry(ctx, cmd, nil, state, startAttempt)
}
redisKeys := extractRedisKeys(cmd)
if len(redisKeys) == 0 {
// Without a key list we cannot react to invalidations for this command.
return c.processWithRetry(ctx, cmd, nil, state, startAttempt)
}
keyPrefix := c.cscKeyPrefix
if keyPrefix == "" {
// A successfully attached client always has a namespace. Fail closed if
// an incomplete custom baseClient reaches this path.
return c.processWithRetry(ctx, cmd, nil, state, startAttempt)
}
key := cscNamespacedKey(keyPrefix, rawKey)
nsRedisKeys := make([]string, len(redisKeys))
for i, k := range redisKeys {
nsRedisKeys[i] = cscNamespacedKey(keyPrefix, k)
}
// Serve hits straight from the cache.
if data, ok := c.csc.Get(ctx, key); ok {
if err := ctx.Err(); err != nil {
return err
}
if err := applyCachedReply(cmd, data); isCacheableReplyResult(err) {
return err
}
c.csc.DeleteByCacheKey(key)
}
// Demand trigger: a miss for a key still in the refresher's collection window
// flushes that window now (no-op when refresh/coalescing is off). Scope: this
// signals THIS client's own refresh queue. With a shared cache+processor across
// clients the active refresh binding is the last-attached client's queue, so a
// miss on one client does not early-flush a sibling's window — the sibling's
// window timer is the backstop. Correctness is unaffected (the key still
// refreshes); only the early-flush latency is client-local.
c.cscRefreshQueue.signalDemand(key)
token, shouldFetch := c.csc.Reserve(key, nsRedisKeys)
if !shouldFetch {
// Another goroutine is fetching; Get below waits until it completes.
if data, ok := c.csc.Get(ctx, key); ok {
if err := ctx.Err(); err != nil {
return err
}
if err := applyCachedReply(cmd, data); isCacheableReplyResult(err) {
return err
}
c.csc.DeleteByCacheKey(key)
}
// Original fetcher cancelled or its value was invalidated; try to take
// over so later waiters still benefit from the cache. This is the 2x-RTT
// path under churn: we waited a round trip and still must fetch ourselves.
token, shouldFetch = c.csc.Reserve(key, nsRedisKeys)
}
// Reader-miss coalescing: hand the reserved miss to the batcher (no-op when
// off). Load the pointer ONCE: a concurrent Close swaps it to nil, and a
// second load after the nil-check would call fetch on a nil receiver.
if mc := c.cscMissCoalescer.Load(); shouldFetch && mc != nil {
served, err := mc.fetch(ctx, cmd, key, token)
// Re-run on the normal path when the coalescer bailed (errCSCRetryUncached)
// or hit a session or transport failure (tagged cscSessionError by
// settleErr). Then a coalesced miss gets the same MaxRetries and backoff as
// any other command, instead of a raw io.EOF or ErrClosed to the caller (Ofek
// review #3989). The coalescer always cancels the reservation on failure, so
// the re-run starts clean, and processWithRetry handles a cancelled caller
// context itself. A command result is not tagged (for example redis.Nil,
// WRONGTYPE, or a retryable reply such as LOADING). It is the command's
// answer, already applied to cmd, so it is returned as-is. This keeps the
// coalesced path's documented tradeoff: no per-command retry for reply-level
// errors.
var sessErr cscSessionError
if err == errCSCRetryUncached || errors.As(err, &sessErr) {
// The coalescer bailed and cancelled its reservation. Re-reserve and FALL
// THROUGH to the capture path below so a successful retry repopulates the
// cache (a nil-capture processWithRetry returned the value but stored
// nothing, so connection blips and wire-budget sheds left the key uncached
// and later readers missed).
//
// Carry the coalesced attempt into the re-run's accounting. served is
// non-nil exactly when the request reached a session connection (the
// writer attributes every request of a batch to its conn before the
// write; a pre-queue shed or a session that never acquired a conn leaves
// it nil), which is the same "one attempt on that conn" rule the success
// branch below applies. processWithRetry seeds both its reported attempt
// count and its retry-budget position from startAttempt, so the failed
// coalesced attempt shows up in retry_attempts and the error callback, and
// the re-run does not get MaxRetries+1 fresh attempts on top of the one
// already spent (codex on #3989; needs the explicit-start plumbing from the
// full-duplex autopipeline branch, now merged).
if served != nil {
startAttempt++
// Record it on state too, not only through processWithRetry: two exits
// below never reach that loop — the exhausted-budget return here and
// the takeover-hit return after the re-Reserve — and without this they
// reported the attempts seeded before the fetch and a nil connection
// for a request that did reach one (codex on #4002). processWithRetry
// overwrites both on its first iteration, and keeps lastConn when the
// re-run never acquires a connection.
if state != nil {
state.attempts = startAttempt
state.lastConn = served
}
// Budget check. The command has now spent startAttempt of its
// MaxRetries+1 attempts; when that was the last one (an FD attempt plus
// this coalesced one with MaxRetries=1, or any coalesced session failure
// with retries disabled), a re-run would execute it once more:
// processWithRetry clamps startAttempt back into its loop so a caller can
// never disable execution, which here is an attempt over budget (codex on
// #4002). Stop with the coalescer's cause instead — the same raw error
// processWithRetry returns when its own loop runs out — and emit the error
// metric the skipped re-run would have (settleErr leaves that to the
// re-run so a re-run that succeeds is not flagged).
if startAttempt > c.opt.MaxRetries {
cause := err
if sessErr.err != nil {
cause = sessErr.err
}
cmd.SetErr(cause)
recordCommandError(ctx, cause, served, startAttempt-1)
return cause
}
}
token, shouldFetch = c.csc.Reserve(key, nsRedisKeys)
if !shouldFetch {
// Another waiter won the re-Reserve race and is fetching. WAIT on it
// (Get parks on the in-progress entry) instead of falling through to an
// independent pooled request: a session failure wakes every same-key
// waiter at once, and each running its own processWithRetry would turn
// one hot key into a pool stampede mid-recovery — defeating the
// coalescing this path exists for. Same shape (and 2x-RTT churn
// tradeoff) as the first-Reserve loser path above.
if data, ok := c.csc.Get(ctx, key); ok {
if err := ctx.Err(); err != nil {
return err
}
if err := applyCachedReply(cmd, data); isCacheableReplyResult(err) {
return err
}
c.csc.DeleteByCacheKey(key)
}
// The new owner cancelled or its value was invalidated; try to take
// over. A second loss falls through to the plain pooled path — after
// two waits, forward progress outranks another round of parking.
token, shouldFetch = c.csc.Reserve(key, nsRedisKeys)
}
} else {
// Native-recorder attribution: the coalesced fetch contacted Redis on
// the session's held connection — report it as one attempt on that conn
// so operation-duration metrics carry a real server.address instead of
// zero attempts and a nil connection.
if state != nil && served != nil {
state.attempts++
state.lastConn = served
}
return err
}
}
var fc cscFetchCapture
var capture *cscFetchCapture
if shouldFetch {
capture = &fc
// Release the placeholder if processWithRetry panics; Cancel on a
// stale token is a no-op.
defer func() {
if capture != nil {
c.csc.Cancel(key, token)
}
}()
}
err := c.processWithRetry(ctx, cmd, capture, state, startAttempt)
if shouldFetch {
capture = nil // disarm the deferred Cancel
if isCacheableReplyResult(err) {
c.fulfillCached(key, token, &fc)
} else {
c.csc.Cancel(key, token)
}
}
return err
}
// fulfillCached stores a fetched value, attributing it to its serving conn when
// an evict-on-remove hook is active so EvictByConn can drop it if that conn is
// removed. It also closes the attribute-vs-coverage races: the conn is released
// before this runs, so its OnRemove eviction — or a handoff re-init's scoped
// eviction — may fire before the entry exists. Publication is serialized with
// the hook's init-generation changes, so a reply whose invalidation coverage
// was already lost never becomes visible and never wakes waiters with stale
// data.
func (c *baseClient) fulfillCached(key string, token uint64, fc *cscFetchCapture) bool {
if active := c.cscActive; active != nil && !active.Load() {
c.csc.Cancel(key, token)
return false
}
if hook := c.cscHook(); hook != nil {
if fc.connID == 0 {
// Invariant: an active hook always gets a real conn id (>=1). A zero id
// would leave the entry unattributed and un-evictable, so fail closed.
c.csc.Cancel(key, token)
return false
}
if !hook.fulfillOwnedIfCovered(key, token, fc.connID, fc.initGen, fc.raw) {
// A coverage mismatch leaves the reservation IN_PROGRESS because
// FulfillOwned was deliberately skipped. Cancel wakes its waiters
// as misses so one can safely refetch on a covered connection.
c.csc.Cancel(key, token)
return false
}
return true
}
return c.csc.FulfillOwned(key, token, 0, fc.raw)
}
package redis
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
)
// Windowed background invalidation batcher.
//
// Normally the invalidate handler deletes cache entries INLINE, on whatever
// goroutine read the RESP3 "invalidate" push — which, for the coalescer's
// reply reader, means invalidation work steals time from reading miss replies
// and inflates the low-concurrency churn p99 tail.
//
// When Options.ClientSideCacheInvalidationBatchWindow>0 the handler instead
// ENQUEUES invalidated keys (cheap, off the read path) and a single background
// goroutine applies the deletes in batches once per window. Deferring an
// invalidation's application by <= window means a reader may see the
// pre-invalidation value for <= window, which is exactly what MaxStaleness=window
// already licenses; set the window <= MaxStaleness to stay within contract (a
// nonzero window with MaxStaleness=0 is an explicit strictness relaxation).
const cscInvalBatchMax = 4096 // size-cap flush regardless of the timer
// cscInvalSpillMax hard-caps the overflow spill buffer. The spill is normally
// self-limiting (duplicate keys collapse in the worker's dedup), but the set of
// keys a server can invalidate is NOT bounded by the local cache size: Redis
// keeps CLIENT TRACKING for every key a connection read, even after the local
// LRU evicted it (no per-key untrack), so a workload that reads far more than
// MaxEntries and then invalidates them can grow the spill without bound. At this
// many buffered items the batcher stops spilling and schedules ONE full cache
// Flush instead — a correctness-preserving, O(1)-memory fallback (a flush can
// never serve stale) that also keeps the reader unblocked.
const cscInvalSpillMax = 1 << 16 // 65536
// cscInvalSpillMaxBytes hard-caps the RETAINED KEY BYTES in the overflow spill.
// The item cap alone bounds the spill by COUNT, not memory: the spill holds
// namespaced key STRINGS (no values), but a burst of distinct large keys can
// retain GBs while the item count stays far below cscInvalSpillMax. When the
// retained bytes would cross this bound the batcher takes the SAME full-Flush
// fallback the item cap triggers (a single oversized key crosses it alone). 8
// MiB of key strings is already tens of thousands of large keys — in the spirit
// of the refresh queue's key-byte cap (cscRefreshTargetMaxBytes).
const cscInvalSpillMaxBytes = 8 << 20 // 8 MiB
// cscInvalBatchMaxBytes size-caps the worker's PENDING batch by retained KEY
// bytes, the byte twin of cscInvalBatchMax. ch and spill bound the bytes WAITING
// to be consumed, but the worker then holds each key in `pending` until the window
// or the item cap flushes — so a stream of large keys (each under the admission
// limit) could retain GBs in pending across one window while the item count stays
// far below cscInvalBatchMax. At this many pending key bytes the worker flushes the
// batch early (a normal apply, not a full-cache Flush), bounding pending memory. 8
// MiB mirrors the spill/refresh key-byte caps.
const cscInvalBatchMaxBytes = 8 << 20 // 8 MiB
// cscInvalItem is one queued invalidation, tagged with the batcher epoch it
// was enqueued under so a full-cache flush can supersede it (see drop()).
type cscInvalItem struct {
key string
epoch uint64
// sinceToken is the refresh "recently-read" horizon snapshotted at ENQUEUE,
// used by apply for the hot-entry check instead of a live load. A batch window
// >= the recency tick (200ms) would otherwise let the horizon advance during
// the wait, so a key that was hot when the invalidation arrived fails the
// check by apply time and silently degrades to plain eviction. Free size-wise:
// it fits the 8-byte alignment padding the struct already carries.
//
// cscInvalNoHorizon (-1) when refresh was OFF at enqueue. apply re-reads the
// refresh binding at APPLY time, and a refresh-enabled client attaching in
// between (setRefreshQueue repoints the batcher, then stop-drains it) would
// otherwise see horizon 0 and treat EVERY valid entry as hot — the cold-key
// refresh loop the horizon exists to prevent. With the sentinel, apply falls
// back to the live horizon (seeded at that client's start), which can only
// under-refresh, never chase cold keys. LRUClock is a monotonic sequence >= 0,
// so -1 cannot collide with a real horizon.
sinceToken int64
// fetchSnap is cscFetchSeq observed at ENQUEUE (invalidation OBSERVE time).
// apply passes it to deleteByRedisKeyCollectingHot, which keeps any Valid entry
// whose fetch was issued after it (entry.fetchSeq > fetchSnap) — that value was
// refetched after the write, so evicting it would be a spurious miss and would
// undo a fresh refresh (see cacheEntry.fetchSeq). Snapshotting at enqueue, not a
// live load at apply, is what makes a queued duplicate that straddled a refetch
// harmless.
fetchSnap uint64
}
// cscInvalNoHorizon marks an invalidation enqueued while no refresh binding
// existed (see cscInvalItem.sinceToken). It doubles as the LIVE horizon a
// cscRefreshQueue holds when Options.ClientSideCacheRefreshRecencyWindow is
// unset (refresh-everything mode, see startCSCRefresher): -1 is less than any
// real lastAccessNs token, so deleteByRedisKeyCollectingHot's `> sinceToken`
// check marks every Valid entry hot regardless of recency, in either case.
// The two meanings never conflict because they resolve to the same effect.
const cscInvalNoHorizon = -1
type cscInvalBatcher struct {
window time.Duration
// batchMax is the size-cap flush threshold; 0 means cscInvalBatchMax. Per-batcher
// (not a mutable global) so a test can lower it without racing other batchers'
// worker goroutines reading it under -race; production leaves it 0.
batchMax int
// batchMaxBytes size-caps the pending batch by retained KEY bytes; 0 means
// cscInvalBatchMaxBytes. Per-batcher like batchMax so a test can lower it
// race-free; production leaves it 0.
batchMaxBytes int
// cache is snapshotted at creation (batcher lifetime is inside the binding
// lifetime): the release-time stop-drain must still apply queued deletes AFTER
// releaseLocked nils h.cache, or a successor reusing the shared cache serves
// stale until TTL/MaxStaleness.
cache Cache
// refresh is the active refresh binding. Atomic (not a plain snapshot) because
// set/clearRefreshQueue can REPOINT it — under h.mu, before stopping the
// batcher — at the surviving/new binding, so the stop-drain feeds hot keys to
// the live refresher instead of the dead one the batcher was created with. Read
// by both the producer (enqueue) and the worker (apply).
refresh atomic.Pointer[cscRefreshQueue]
ch chan cscInvalItem
stopCh chan struct{}
stopOnce sync.Once
// spill absorbs invalidations that arrive while ch is full, so an overflow
// never applies a delete inline on the producer — critically the coalescer's
// reply reader, where inline apply (lock-held cache work) stalls miss replies
// (head-of-line). The worker drains spill through the SAME seen/pending dedup
// pipeline as ch, so a burst of duplicate keys collapses to one delete per
// key. The worker normally catches up, but the spill is hard-capped at
// cscInvalSpillMax: the invalidatable keyset is NOT bounded by cache capacity
// (the server tracks keys past local LRU eviction), so at the cap the batcher
// schedules one full Flush (flushReq) instead of growing spill. wake nudges
// the worker to drain promptly (cap 1, coalescing: one wake drains the whole
// slice).
spillMu sync.Mutex
spill []cscInvalItem
// spillBytes tracks the retained KEY bytes currently held in spill, guarded by
// spillMu alongside spill itself. Bounds spill memory by bytes as well as by
// item count (see cscInvalSpillMaxBytes); reset with spill on drain/flush.
spillBytes int
// chBytes tracks the retained KEY bytes currently sitting in ch. ch is bounded
// by item COUNT (its slot count) but not memory, so a burst of large keys — or
// one key larger than the whole byte cap — would retain unbounded bytes before
// the spill path (and its byte cap) ever engages. enqueue RESERVES atomically
// before the send (mirroring reserveWireBytes): it charges keyLen up front and
// rolls back if the total crosses cscInvalSpillMaxBytes, so concurrent producers
// can no longer each read a stale below-cap Load and all overshoot. A send that
// then finds ch full rolls the reservation back too (those bytes are tracked on
// the spill path via spillBytes); the worker subtracts on consume. Written by
// producers (reserve/rollback) and the worker (consume); read by the gate.
chBytes atomic.Int64
wake chan struct{}
// flushReq: spill hit cscInvalSpillMax, so the worker must drop() + full-Flush
// the cache instead of applying a huge backlog. Set by enqueue, consumed by the
// worker (CAS to false).
flushReq atomic.Bool
// spilled counts overflow events (ch full at send time). Internal; read by
// tests and available for a future stat, so the feature's degradation under
// invalidation bursts is observable instead of silent.
spilled atomic.Uint64
// epoch versions enqueued invalidations against full-cache flushes: drop()
// bumps it, and apply skips items from an older epoch. applyMu serializes
// the two: without it, apply could snapshot the epoch, a concurrent
// drop()+Flush() land mid-loop, and the remaining stale items would still
// be applied AFTER the flush — evicting post-flush repopulations, the exact
// case the epoch exists to prevent. drop() holding applyMu means any
// in-flight batch finishes BEFORE the flush (harmless: the flush wipes
// everything anyway), and every later apply sees the new epoch.
epoch atomic.Uint64
applyMu sync.Mutex
// stopMu/stopped interlock enqueue against stop: a handler can hold a stale
// batcher pointer across a rebuild. enqueue sends under the read side, so a
// sent key lands BEFORE stop closes stopCh (the stop-drain sees it); once
// stopped, enqueue applies inline — no delete is ever parked in a channel
// nothing drains.
stopMu sync.RWMutex
stopped bool
// done is closed when run() exits (after its stop-drain and final flush);
// join() waits on it so stop+join is a synchronous teardown. Nil only in
// test-constructed literals whose worker never starts.
done chan struct{}
}
// stop signals run() to flush and exit; idempotent, never touches h.mu, does
// not wait — safe under the handler lock. Lock order h.mu -> stopMu, never
// reversed (enqueue's stopMu section is only the flag check and the send).
func (b *cscInvalBatcher) stop() {
b.stopOnce.Do(func() {
b.stopMu.Lock()
b.stopped = true
b.stopMu.Unlock()
close(b.stopCh)
})
}
// join blocks until run() has exited — i.e. the stop-drain and final flush have
// fully applied. Callers pair it with stop() so a teardown or rebuild is
// SYNCHRONOUS: without the join, the old worker's drain ran after Close (or
// after a rebuild installed a new batcher with a new window contract), leaving
// a straggler goroutine past Close and letting a late apply evict entries a
// successor just repopulated. Safe under h.mu: run() never takes handler locks
// (its drain touches only the cache shards, applyMu, and non-blocking refresh
// offers). No-op for a batcher whose worker was never started (test literals).
func (b *cscInvalBatcher) join() {
if b.done == nil {
return
}
<-b.done
}
// drop marks everything enqueued so far as superseded by a full cache Flush:
// bumping the epoch makes apply skip stale-epoch items — both queued and in an
// in-progress batch — without draining anything. The old drain-based drop
// could discard a NEWER per-key invalidation racing in from another tracked
// connection after the flush, losing its delete and leaving a repopulated
// entry stale; with epochs that item carries the new epoch and survives.
// Callers bump BEFORE flushing the cache, so a stale epoch always means
// "enqueued before the flush", where the delete is redundant by the flush.
func (b *cscInvalBatcher) drop() {
// Serialize with apply (see applyMu): after drop returns, no stale-epoch
// delete can run — the caller may flush the cache immediately.
b.applyMu.Lock()
b.epoch.Add(1)
b.applyMu.Unlock()
}
// enqueueAt hands a namespaced key to the batcher without blocking the caller and
// without ever applying a delete inline on the producer (see the spill field):
// on a full ch it appends to spill and nudges the worker; only once the batcher
// is stopped (no worker left to drain, see stopMu) does it apply inline so an
// invalidation is never dropped.
//
// fetchSnap is cscFetchSeq observed at the notification OBSERVE time. The push
// handler batches MANY keys per notification, so it snapshots ONCE and passes the
// same value for every key in the push (a per-key load would let a fetch reserved
// after the push was observed slip in as "not newer" for a later key — the same
// drift the inline delete path already avoids). See the test-only enqueue helper
// for the single-key convenience form.
func (b *cscInvalBatcher) enqueueAt(nsKey string, fetchSnap uint64) {
it := cscInvalItem{
key: nsKey,
epoch: b.epoch.Load(),
sinceToken: cscInvalNoHorizon,
// fetchSnap is cscFetchSeq observed at the notification OBSERVE time (shared by
// every key in one push) so a Valid entry refetched after this invalidation is
// kept at apply, and a queued duplicate that straddles that refetch cannot undo
// it (see cscInvalItem.fetchSnap).
fetchSnap: fetchSnap,
}
// Snapshot the refresh recency horizon NOW (enqueue time), so a batch-window
// delay can't advance it out from under apply's hot-entry check (see the field
// doc). Cheap: one atomic load, no lock. Stays cscInvalNoHorizon when refresh
// is off, so a binding that appears before apply cannot read it as horizon 0.
if r := b.refresh.Load(); r != nil {
it.sinceToken = r.sinceToken.Load()
}
keyLen := int64(len(it.key))
b.stopMu.RLock()
if b.stopped {
b.stopMu.RUnlock()
b.apply([]cscInvalItem{it})
return
}
// Pre-admission byte gate: ch is bounded by item COUNT, not memory. When this
// key alone exceeds the byte cap, or the bytes already retained in ch plus this
// key would cross it, skip ch entirely and take the full-Flush fallback (below)
// — otherwise a distinct-large-key flood retains unbounded bytes in ch before
// the spill path ever engages. RESERVE the bytes atomically before the send
// (mirroring reserveWireBytes): a stale Load-then-Add let concurrent producers
// each read a below-cap value and all overshoot the cap, so charge keyLen up
// front and roll back when it crosses.
overBytes := keyLen > cscInvalSpillMaxBytes
if !overBytes {
if b.chBytes.Add(keyLen) > cscInvalSpillMaxBytes {
b.chBytes.Add(-keyLen) // over cap: divert to the full-Flush fallback below
overBytes = true
} else {
select {
case b.ch <- it:
b.stopMu.RUnlock()
return
default:
// ch full: release the reservation (the spill path tracks these bytes
// via spillBytes) and fall through to the spill path below.
b.chBytes.Add(-keyLen)
}
}
}
{
// Not admitted to ch (full, or the byte gate diverted here): park on spill
// (off the producer's read path) for the worker to drain, instead of applying
// inline. Correctness is unchanged — the item carries its enqueue-time epoch
// and the worker applies it under applyMu with the same epoch check as the ch
// path.
//
// At either hard cap, stop growing spill: drop this item, clear the backlog,
// and ask the worker to full-Flush the cache. Safe because a Flush drops
// everything (nothing stale can survive), and correct because the capped case
// is a pathological invalidation flood where the cache is churning wholesale
// anyway. Needed because the invalidatable keyset is not bounded by cache size
// (the server tracks keys past local LRU eviction).
b.spillMu.Lock()
// Trip on ANY bound: the byte gate above (overBytes), the spill item count, or
// the spill retained-key bytes. Bytes matter because the invalidatable keyset
// is not bounded by cache size and keys can be large, so a distinct-large-key
// flood grows memory long before the item cap. A single oversized key trips it
// alone (overBytes).
if overBytes || len(b.spill) >= cscInvalSpillMax ||
b.spillBytes+len(it.key) > cscInvalSpillMaxBytes {
// The cleared backlog plus this item are superseded by the coming
// full-Flush and never reach deleteByRedisKeyCollectingHot. They were
// already counted as incoming invalidations at the handler; they simply
// won't become deletions — the Flush supersedes them wholesale.
b.spill = b.spill[:0]
b.spillBytes = 0
// Set flushReq BEFORE releasing stopMu. stop() takes stopMu.Lock, so it
// cannot close stopCh until this store is visible; the worker's stop-drain
// then sees the request via fullFlushIfRequested instead of exiting and
// losing the flush (which would leave the flood's entries stale). Setting
// it after RUnlock would race stop() and drop the flush.
b.flushReq.Store(true)
} else {
b.spill = append(b.spill, it)
b.spillBytes += len(it.key)
}
b.spillMu.Unlock()
b.stopMu.RUnlock()
b.spilled.Add(1)
select {
case b.wake <- struct{}{}:
default:
}
}
}
// takeSpill swaps out the accumulated spill for the worker to drain. Returns nil
// when empty. The swapped slice is owned by the caller; b.spill starts fresh so
// concurrent enqueues never race the drain.
func (b *cscInvalBatcher) takeSpill() []cscInvalItem {
b.spillMu.Lock()
s := b.spill
b.spill = nil
b.spillBytes = 0
b.spillMu.Unlock()
return s
}
// apply deletes the namespaced keys and feeds evicted-hot entries to the current
// refresh binding (b.refresh, which set/clearRefreshQueue may have repointed at a
// survivor before the stop-drain — see the struct field).
func (b *cscInvalBatcher) apply(items []cscInvalItem) {
cache, refresh := b.cache, b.refresh.Load()
if cache == nil {
return
}
// Held for the whole batch so a concurrent drop()+Flush() cannot land
// mid-loop and leave stale-epoch deletes running post-flush (see applyMu).
b.applyMu.Lock()
defer b.applyMu.Unlock()
cur := b.epoch.Load()
lc, canRefresh := cache.(*LocalCache)
canRefresh = canRefresh && refresh != nil
var hot []cscRefreshTarget
for _, it := range items {
// Stale epoch: enqueued before a full cache Flush that superseded it
// (see drop()); applying it would only evict a post-flush repopulation.
if it.epoch != cur {
continue
}
k := it.key
if !canRefresh {
if lc != nil {
// *LocalCache with refresh OFF: still honor the fetch-order guard, or a
// delayed invalidation would delete a value refetched during the batch
// window (fetchSeq > fetchSnap) — a spurious miss that can also cancel an
// in-progress fetch and wake waiters as duplicates. Use the sequence-aware
// deletion path and discard any collected refresh targets (refresh is off).
_ = lc.deleteByRedisKeyCollectingHot(k, cscInvalNoHorizon, it.fetchSnap, nil)
} else {
// Non-*LocalCache: no per-entry fetch sequence to compare; plain delete.
cache.DeleteByRedisKey(k)
}
continue
}
// Use the horizon snapshotted at ENQUEUE, not a live load: a batch-window
// delay advances the live horizon and would chill keys that were hot when
// the invalidation arrived (see cscInvalItem.sinceToken). An item enqueued
// with no refresh binding carries cscInvalNoHorizon; the binding that
// exists NOW (a client attached in between) supplies the live horizon, which
// is a real recency test — never horizon 0, which would mark every entry hot.
since := it.sinceToken
if since == cscInvalNoHorizon {
since = refresh.sinceToken.Load()
}
hot = lc.deleteByRedisKeyCollectingHot(k, since, it.fetchSnap, hot[:0])
for i := range hot {
refresh.offer(hot[i])
}
}
}
func (b *cscInvalBatcher) run() {
// Closed LAST (deferred first): join() unblocks only after the stop-drain and
// final flush below have fully applied, making a stop+join teardown synchronous.
defer close(b.done)
batchMax := b.batchMax
if batchMax <= 0 {
batchMax = cscInvalBatchMax
}
batchMaxBytes := b.batchMaxBytes
if batchMaxBytes <= 0 {
batchMaxBytes = cscInvalBatchMaxBytes
}
t := time.NewTimer(b.window)
defer t.Stop()
pending := make([]cscInvalItem, 0, 256)
// pendingBytes tracks the retained KEY bytes in `pending`, so the size-cap flush
// bounds the batch by memory as well as by item count (see cscInvalBatchMaxBytes).
// Reset together with pending on every flush.
pendingBytes := 0
// seen dedups by key WITHIN an epoch: the same key arriving again after a
// flush bumped the epoch must be re-appended (the old occurrence will be
// skipped by apply), or its post-flush delete would be lost to dedup. idx is the
// key's index in pending, so a deduped duplicate can update the retained item's
// fetch snapshot (see add). seen and pending are always reset together (every
// applyPending is inside flush, which then clearSeen()s; fullFlushIfRequested
// resets both), so a stored idx never outlives its pending entry.
type seenEntry struct {
epoch uint64
idx int
}
seen := make(map[string]seenEntry, 256)
clearSeen := func() {
for k := range seen {
delete(seen, k)
}
}
// applyPending applies the current batch and resets pending, but does NOT clear
// seen — the caller decides. fullFlushIfRequested resets pending without applying
// (a full cache Flush supersedes it) and clears seen itself; every other path
// applies via flush(), which pairs applyPending with clearSeen. seen is a pure
// per-window perf dedup: the fetch-order guard, not dedup lifetime, keeps a
// straddling duplicate from evicting a refreshed entry (see add).
applyPending := func() {
if len(pending) == 0 {
return
}
// Recover so a panic in a cache/refresh call can never kill the worker:
// overflow is parked on the unbounded spill buffer, so a dead worker would
// let spill grow without bound. Log and keep looping instead.
func() {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"redis: csc invalidation batch apply panic: %v", r)
}
}()
b.apply(pending)
}()
pending = pending[:0]
pendingBytes = 0
}
flush := func() {
applyPending()
clearSeen()
}
// add runs one item through the epoch-scoped dedup and size-flushes at the
// cap. Shared by the ch, spill, and stop-drain paths so spilled items get the
// SAME dedup as fast-path items — a burst of duplicate keys collapses to one
// delete per key.
add := func(it cscInvalItem) {
if e, dup := seen[it.key]; !dup || e.epoch != it.epoch {
seen[it.key] = seenEntry{epoch: it.epoch, idx: len(pending)}
pending = append(pending, it)
pendingBytes += len(it.key)
} else if it.fetchSnap > pending[e.idx].fetchSnap {
// Same key, same epoch: dropped as a duplicate delete, BUT carry the LATEST
// fetch snapshot onto the retained item. A second write to the key in this
// window arrives with a newer fetchSnap; keeping only the first would let the
// apply-time fetch-order guard use the older snapshot and preserve an entry
// refetched BETWEEN the two writes, serving it stale until MaxStaleness
// (#3989). The item is still a single delete.
pending[e.idx].fetchSnap = it.fetchSnap
}
// A duplicate key in the same epoch is dropped here: apply() deletes the key
// once, so it becomes a single deletion. The incoming push was already
// counted at the handler, so the dropped duplicate needs no accounting — it
// just won't add a deletion, which is exactly the dedup signal.
if len(pending) >= batchMax || pendingBytes >= batchMaxBytes {
// Size-cap flush (by item COUNT or retained key BYTES): apply the batch AND
// clear seen. The byte cap keeps a burst of large keys from retaining GBs in
// pending across a window (each key can be under the per-item admission limit
// yet sum far past it). A key straddling the cap
// can now be applied in both halves, but that is harmless: the fetch-order
// guard (cscInvalItem.fetchSnap) makes the second apply a no-op when the
// first apply's refetch already republished the entry — the duplicate's
// snapshot predates that refetch. So dedup LIFETIME no longer carries
// correctness (it did while keep-seen was the only defense against evicting
// a refreshed entry — that also left seen unbounded under sustained load and
// dropped a genuine re-invalidation of a refreshed key). seen is back to a
// pure per-window perf dedup, and clearing it here keeps it bounded without a
// full-cache Flush.
flush()
t.Reset(b.window)
}
}
drainSpill := func() {
for _, it := range b.takeSpill() {
add(it)
}
}
// fullFlushIfRequested consumes a flushReq set by enqueue when spill hit its
// cap: drop the whole backlog (epoch bump skips any stale-epoch delete still
// queued/in-flight) and Flush the cache — the O(1)-memory, correctness-
// preserving fallback for an invalidation flood. Recover so a Flush panic can't
// kill the worker (its death would let spill grow unbounded again).
fullFlushIfRequested := func() {
if !b.flushReq.CompareAndSwap(true, false) {
return
}
func() {
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"redis: csc invalidation full-flush panic: %v", r)
}
}()
b.drop()
if b.cache != nil {
b.cache.Flush()
}
}()
// The pending batch is superseded by the Flush and never applied as
// individual deletions (the Flush invalidates wholesale). Incoming pushes
// were already counted at the handler, so just reset the batch.
pending = pending[:0]
pendingBytes = 0
clearSeen()
}
for {
select {
case <-b.stopCh:
// Stopped (last release, or a window-change rebuild where queued deletes
// are still live and must not be lost): drain spill AND ch into the
// batch, flush once, exit. enqueue appends spill under stopMu.RLock, so
// anything spilled before stop set stopped=true is visible here.
fullFlushIfRequested()
drainSpill()
for draining := true; draining; {
select {
case it := <-b.ch:
b.chBytes.Add(-int64(len(it.key)))
add(it)
default:
draining = false
}
}
drainSpill() // catch keys spilled during the ch drain
flush()
return
case it := <-b.ch:
b.chBytes.Add(-int64(len(it.key)))
add(it)
case <-b.wake:
// An overflow parked keys on spill (or hit the cap and asked for a full
// flush); handle the flush request first, then drain what's left.
fullFlushIfRequested()
drainSpill()
case <-t.C:
fullFlushIfRequested()
drainSpill()
flush()
t.Reset(b.window)
}
}
}
package redis
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
)
// errCSCRetryUncached settles a coalesced miss when the coalescer bows out for a
// reason unrelated to the command itself: CSC serving was disabled after the
// miss was queued (a RESP3 downgrade, or CLIENT TRACKING rejected during a
// connection re-init), so this session would fetch on an untracked conn or none
// at all. It never reaches the caller — processCached catches it and re-runs the
// command uncached, so a valid cacheable read is not failed with a spurious
// pool.ErrClosed. The reservation is always cancelled before this is settled, so
// no waiter is left IN_PROGRESS.
var errCSCRetryUncached = errors.New("redis: csc miss-coalescer disabled mid-fetch; retry uncached")
// Reader-miss coalescing, behind Options.ClientSideCacheCoalesceMisses.
//
// The two-client CSC shape serves cache HITS locally (no connection) but every
// MISS fetches on the caching client's MAIN pool, one command per connection.
// At a small pool that is the wall: N concurrent misses contend one GET at a
// time (measured: at CSC pool 8 the reader misses alone saturate the pool, p99
// ~420ms at 64 workers, and no refresh policy helps — the readers are the
// bottleneck).
//
// This streams each miss's ORIGINAL command onto a held tracked (main-pool)
// full-duplex connection (see csc_miss_coalesce_modes.go), reads the replies
// back in order, and applies each to its caller's command AND to the shared
// cache. Individual pipelined commands (not MGET), so it is cluster-safe and
// works for any read shape, not just string GET. Misses are CALLER-BLOCKING, so
// the engine is latency-first: a lone miss is written immediately (batching only
// packs what is already queued, never waits for more), and new misses stream out
// while earlier replies are still in flight (~1 RTT per miss, no batch
// phase-lock, no pool Get on the hot path).
//
// Being cache-aware is why it cannot just ride the ordinary autopipeliner: it
// must Reserve/fulfilCached each key so the entry is tracked and
// single-flighted. The caller Reserves in processCached (only shouldFetch==true
// misses reach here); the engine owns the token from that point and settles it —
// fulfilled or cancelled — before waking the caller, or a dangling IN_PROGRESS
// entry blocks every reader of that key for StaleTimeout.
//
// Tradeoff, same as the autopipeline path: a coalesced miss loses per-command
// MaxRetries/backoff/LOADING handling.
const (
cscMissBatchMax = 128
cscMissQueueDepth = 4096
// cscFullDuplexDepth caps in-flight commands on the single-connection
// full-duplex engine (flow control for the writer).
cscFullDuplexDepth = 4096
// cscMissBatchBytes is the top limit on the serialized size of a coalesced write
// batch. It bounds the buffered write even when the configured write buffer is
// very large. The effective cap is the smaller of this value and the write
// buffer (see cscMissWriteBatchBytes). A batch larger than the write buffer makes
// bufio flush in the middle of the batch, which can deadlock the write and the
// read. The first miss always goes, because a single large command must not
// stall. So the cap bounds only the extra already-queued misses that share the
// write.
cscMissBatchBytes = 1 << 20 // 1 MiB
// cscMissWireBudgetBytes bounds the TOTAL serialized bytes of in-flight coalesced
// misses. The queue's item cap (cscMissQueueDepth) does not bound memory: every
// caller serializes and holds a full command copy (req.wire) while blocked on the
// send, and cacheable commands (e.g. a large MGET) have no small encoded size, so
// a burst of large misses could exhaust the process. Over this budget a miss sheds
// to the ordinary pooled path (errCSCRetryUncached) — which is itself bounded by
// pool turns — instead of coalescing. 8x one batch lets several full batches be in
// flight before shedding.
cscMissWireBudgetBytes = 8 * cscMissBatchBytes // 8 MiB
// cscMissMaxConcurrentSerialize caps how many fetch calls may hold a wire
// snapshot mid-serialization at once. reserveWireBytes gates on cmdApproxBytes,
// an ESTIMATE taken before serialization. It sizes string, []byte, *string and
// BinaryMarshaler args exactly, but can still undercount: a marshaler whose
// output differs between the sizing call and writeCmd's own MarshalBinary call,
// or a numeric/time arg whose text exceeds the fixed 8-byte fallback. A
// concurrent burst of such misses can all pass the byte reservation and each
// allocate its full wireBuf BEFORE reconcileWireBytes sheds — a transient set of
// snapshots the budget never sees, which can exhaust memory even though none
// stay queued. Bounding the concurrent set to budget/batch caps the COUNT of
// coexisting snapshots, not their bytes: when the estimate is accurate each
// snapshot is about batch-sized, so the peak is ~O(budget). A single command
// whose encoded form far exceeds its estimate makes that one snapshot
// arbitrarily large, so the true worst-case transient peak is cap x (largest
// single serialized command), NOT O(budget) — a hard per-request byte ceiling
// enforced during writeCmd is a focused follow-up. A caller that cannot get a
// slot promptly sheds to the pooled path rather than block. Serialization is
// CPU-only and brief, so normal small-command bursts pass without shedding.
cscMissMaxConcurrentSerialize = cscMissWireBudgetBytes / cscMissBatchBytes // 8
)
// cscMissSerializeWait is how long acquireSerialize waits for a serialization slot
// before shedding to the pooled path. Serialization never blocks on I/O (it writes
// to an in-memory buffer), so a slot frees in microseconds; the wait only absorbs
// brief scheduling contention. A var so a test can set it to 0 (shed immediately)
// for a deterministic admission assertion.
var cscMissSerializeWait = 5 * time.Millisecond
// cscMissWriteBatchBytes returns the effective per-batch size cap. It is the
// connection's write buffer, limited to at most cscMissBatchBytes. When a batch
// fits the write buffer, bufio flushes it once, at the end of WithWriter, not in
// the middle. So the reader, which waits until the whole batch is written, starts
// to drain replies before any request that could block the writer is on the wire.
// This closes the write and read deadlock window for large payloads.
func cscMissWriteBatchBytes(opt *Options) int {
wb := 0
if opt != nil {
wb = opt.WriteBufferSize
}
if wb <= 0 {
wb = proto.DefaultBufferSize
}
if wb > cscMissBatchBytes {
wb = cscMissBatchBytes
}
return wb
}
// cscCoalesceMissesEnabled is read once, at coalescer construction (per client).
func cscCoalesceMissesEnabled(opt *Options) bool {
return opt != nil && opt.ClientSideCacheCoalesceMisses
}
// cscReq* are the states of cscMissReq.apply, the single-word interlock over who
// owns req.cmd: the fetching caller or the reader applying the reply. Exactly
// one side wins the pending->X CAS, so the reader never writes cmd after the
// caller has returned and may be reusing it.
const (
cscReqPending uint32 = iota // no one has claimed cmd yet
cscReqAbandoned // caller's ctx (or Close) fired first: cmd is the caller's again
cscReqApplying // reader claimed cmd first: it will write and settle
)
// cscMissReq is one caller waiting for its missed key. done carries the fetch
// result back (the reply is already applied to cmd by the time done fires).
type cscMissReq struct {
cmd Cmder
cacheKey string
token uint64
done chan error
// servedBy is the session connection that served (or failed) this request,
// set by the engine BEFORE settling done (the channel receive is the
// happens-before edge for the caller's read). Feeds the native OTel
// recorder: processCached stamps it into processState so a coalesced miss
// reports its serving connection and an attempt, like any other command.
// Nil when the request never reached a session (enqueue reject, abandon).
servedBy *pool.Conn
// sentConn is servedBy published atomically: the session stores it when the batch
// is assigned to a connection (at send), so a caller that abandons on ctx-cancel
// BEFORE the req.done happens-before edge can still attribute the metric to the
// serving conn instead of reporting nil for a command Redis actually received
// (#3989). Plain servedBy is unsafe to read on that branch (it races the session's
// write); this atomic is the safe view for it. Nil until the batch reaches a conn.
sentConn atomic.Pointer[pool.Conn]
// wire is cmd's RESP encoding, snapshotted at enqueue while the caller
// still owned cmd. The session writer writes ONLY these engine-owned bytes
// — it never reads cmd — so an abandoning caller may reuse mutable args
// (e.g. a []byte key) the moment fetch returns, and a post-abandon
// mutation can neither reach the wire nor publish under the original
// cache key. Only the reply side touches cmd, gated by the apply interlock.
wire []byte
// reserved is the wire-budget byte reservation for this request (see
// reserveWireBytes). It is charged in fetch before serialization and released
// exactly once — by the settle helper when the request is consumed (its wire
// leaves mc.ch/inflight), or by fetch itself if the request never reaches the
// queue. Tracking the WIRE's lifetime (not the caller's) keeps a cancelled but
// still-queued snapshot counted so the budget cannot be reused while thousands
// of copies remain retained.
reserved int64
// apply interlocks ownership of cmd between the fetching caller and the
// reader. The caller abandons (CAS pending->abandoned) if its context is
// cancelled — or Close races its enqueue — before a reply is applied; the
// reader claims (CAS pending->applying) before it writes the reply into cmd. A
// plain flag would still race: the reader could read "not abandoned", begin
// writing cmd, and the caller cancel mid-write. Either way the reader still
// settles the token and publishes to the shared cache (the fetch is never
// wasted) — only the cmd write is gated.
apply atomic.Uint32
}
// claimAbandon is the caller's side of the cmd interlock: it succeeds only if no
// reply is being applied, in which case the reader skips the cmd write.
func (r *cscMissReq) claimAbandon() bool {
return r.apply.CompareAndSwap(cscReqPending, cscReqAbandoned)
}
// claimApply is the reader's side: it succeeds only if the caller has not
// abandoned, in which case it is safe to write the caller's cmd.
func (r *cscMissReq) claimApply() bool {
return r.apply.CompareAndSwap(cscReqPending, cscReqApplying)
}
type cscMissCoalescer struct {
c *baseClient
ch chan *cscMissReq
stop chan struct{}
stopOnce sync.Once // guards close(stop): Close and the GC cleanup can both stop
wg sync.WaitGroup
// stopDrainBudget overrides the Close-time drain cap. Zero in all production paths
// (never set outside tests) — cscMissStopDrainIntervals × interval applies. Set only
// by tests, which cannot otherwise reach the cap in bounded time: interval is floored
// at batchBudget()+1s >= 6s, so the default cap first fires ~48s in.
stopDrainBudget time.Duration
// maxBatchBytes bounds the serialized size of one coalesced write batch to the
// connection's write buffer (see cscMissWriteBatchBytes). This keeps bufio from
// a flush in the middle of a batch. The reason: the reader waits until the whole
// batch is written, because the writer fills inflight only after the write. A
// mid-batch flush would put request bytes on the wire and let the server reply
// into buffers that nobody drains yet. On large payloads this can deadlock the
// write and the read. Set once at construction.
maxBatchBytes int
// wireBytes is the total serialized size of in-flight coalesced misses, bounded
// by cscMissWireBudgetBytes. A fetch adds its approximate encoded size before
// serializing and subtracts it when done; over budget it sheds to the pooled path.
wireBytes atomic.Int64
// serializeSem bounds how many fetch calls hold a wire snapshot mid-serialization
// at once (see cscMissMaxConcurrentSerialize). Buffered to the cap; a slot is held
// ONLY across writeCmd+reconcile and released before the enqueue send, so it
// bounds the transient allocation set without coupling to queue backpressure — no
// slot is ever held across a channel send or a reply wait, so it cannot deadlock
// the single-worker session. Nil in test-constructed literals: acquire/release
// treat a nil sem as unbounded and skip the serializing/maxSerializing counters
// too (so they stay paired), so those keep working; production always sizes it.
serializeSem chan struct{}
// serializing/maxSerializing track the current and peak concurrent serializations
// (updated under a held slot), so a test can assert the bound holds. Internal,
// like abandonedApplies.
serializing atomic.Int64
maxSerializing atomic.Uint64
batched atomic.Uint64 // reqs that went through a batch
batches atomic.Uint64 // batches flushed
failed atomic.Uint64 // reqs settled as errors (conn failure)
maxBatchSz atomic.Uint64
// abandonedApplies counts replies whose caller had already abandoned the req,
// so the cmd write was skipped. Read only by the -race abandoned-path test, to
// assert it hit the window it guards.
abandonedApplies atomic.Uint64
}
// startCSCMissCoalescer launches the coalescer sessions. No-op unless
// Options.ClientSideCacheCoalesceMisses is set and CSC is active, and — see the
// option's GoDoc — unless the cache is the built-in *LocalCache: the publish path
// (fulfillCached capture, refresh integration, hot-entry collection) is
// LocalCache-specific, so a custom Cache falls back to per-caller fetches.
func (c *baseClient) startCSCMissCoalescer() {
if !cscCoalesceMissesEnabled(c.opt) || c.cscMissCoalescer.Load() != nil {
return
}
if _, ok := c.csc.(*LocalCache); !ok {
return
}
// Cheap fast path: if CSC serving is already known inactive, skip building
// anything. Not the safety mechanism on its own — see the workersMu check
// below, which is what actually closes the construction/teardown race.
if a := c.cscActive; a != nil && !a.Load() {
return
}
mc := &cscMissCoalescer{
c: c,
ch: make(chan *cscMissReq, cscMissQueueDepth),
stop: make(chan struct{}),
maxBatchBytes: cscMissWriteBatchBytes(c.opt),
serializeSem: make(chan struct{}, cscMissMaxConcurrentSerialize),
}
// publish stores the coalescer and launches its N sessions. Extracted so
// both the guarded and the no-drain-handle path below run the exact same
// sequence.
publish := func() {
c.cscMissCoalescer.Store(mc)
// N independent full-duplex sessions, each holding its own connection and
// pulling misses from the shared queue. Order-free: coalesced misses are
// standalone per-key fetches with no cross-request contract, and each
// session's per-conn reader still matches replies to its own requests.
for i := 0; i < cscFullDuplexConnsDefault; i++ {
mc.wg.Add(1)
go mc.fullDuplexLoop()
}
}
// Run publish under the drain handle's lock, checked against
// workersTornDown (see cscDrainHandle, stopCSCRefresherAndCoalescer, and
// the mirrored check in startCSCRefresher): a concurrent teardown may
// already have consumed workersStopOnce — reachable via the drainer's own
// self-disable tick reacting to an async conn init's disableCSCServing,
// racing this construction. That teardown must not be handed a coalescer
// it will never get another chance to stop (it would then hold a pool
// connection for the client's remaining life), so decline to start at all
// if it already ran. The WHOLE of publish runs inside the lock, not just
// the Store: teardown's body sets workersTornDown as its own first action
// under this same lock, so holding it across the wg.Add/go loop guarantees
// teardown cannot call mc.wg.Wait() (in stopCSCMissCoalescer) concurrently
// with an in-progress mc.wg.Add — sync.WaitGroup's documented misuse case,
// which can panic — because teardown cannot even read the published
// pointer until every Add has happened (a second bot review caught that
// the Store-only version left this window open). dh is nil only for tests
// that call this directly without a drain handle; there is nothing to race
// against in that case.
if dh := c.cscDrainHandle; dh != nil {
dh.workersMu.Lock()
if !dh.workersTornDown {
publish()
}
dh.workersMu.Unlock()
return
}
publish()
}
func (c *baseClient) stopCSCMissCoalescer() {
// Swap, not load-then-nil: exactly one caller wins even if Close races the
// GC cleanup path, so mc.stop is closed once.
mc := c.cscMissCoalescer.Swap(nil)
if mc == nil {
return
}
mc.stopWorkers()
mc.wg.Wait()
// Any request still queued after stop is drained and settled so no caller
// hangs on its done channel. Retry-uncached, matching fetch's stop paths:
// teardown deactivates serving before stopping the coalescer, so a caller
// woken here re-runs its read on the (possibly still open) pool instead of
// surfacing a spurious ErrClosed mid-window; on a truly closed client the
// uncached re-run fails with the real error.
for {
select {
case req := <-mc.ch:
mc.c.csc.Cancel(req.cacheKey, req.token)
mc.settle(req, errCSCRetryUncached)
default:
return
}
}
}
// stopWorkers signals every coalescer goroutine to exit; idempotent, does not
// wait. Called by stopCSCMissCoalescer (Close) and by the GC cleanup for a client
// dropped without Close — the sessions retain the base client (cache, pools), so
// leaving them running leaks all of it per forgotten client.
func (mc *cscMissCoalescer) stopWorkers() {
mc.stopOnce.Do(func() { close(mc.stop) })
}
// fetch hands a reserved miss to the coalescer and waits for the result. The
// caller has already Reserved (shouldFetch==true); the coalescer owns the token
// from here. Honors the caller's context: if it cancels while waiting, the
// session still settles the token and populates the cache (the fetch is not
// wasted), the caller just returns early.
//
// served is the session connection that produced the settle (nil when the
// request never reached one); processCached stamps it into processState so the
// native OTel recorder attributes the miss like any other command.
// reserveWireBytes reserves n bytes of the in-flight wire budget. It returns false
// (reserving nothing) when the reservation would exceed cscMissWireBudgetBytes, so
// the caller sheds to the pooled path instead of holding a wire copy while blocked
// on the send. releaseWireBytes returns a prior reservation.
func (mc *cscMissCoalescer) reserveWireBytes(n int64) bool {
if mc.wireBytes.Add(n) > cscMissWireBudgetBytes {
mc.wireBytes.Add(-n)
return false
}
return true
}
func (mc *cscMissCoalescer) releaseWireBytes(n int64) { mc.wireBytes.Add(-n) }
// acquireSerialize takes a serialization slot (see serializeSem/
// cscMissMaxConcurrentSerialize) and reports whether it succeeded. It tries once
// without blocking, then waits up to cscMissSerializeWait — a slot frees in
// microseconds because serialization never blocks on I/O — and sheds (returns
// false) on the wait or on Close rather than blocking unbounded. A nil sem (test
// literal) is unbounded. Every true return is paired with exactly one
// releaseSerialize.
func (mc *cscMissCoalescer) acquireSerialize() bool {
if mc.serializeSem == nil {
return true
}
select {
case mc.serializeSem <- struct{}{}:
mc.noteSerializing()
return true
default:
}
if cscMissSerializeWait <= 0 {
return false
}
t := time.NewTimer(cscMissSerializeWait)
defer t.Stop()
select {
case mc.serializeSem <- struct{}{}:
mc.noteSerializing()
return true
case <-t.C:
return false
case <-mc.stop:
return false
}
}
// releaseSerialize returns a slot taken by a successful acquireSerialize.
func (mc *cscMissCoalescer) releaseSerialize() {
if mc.serializeSem == nil {
return
}
mc.serializing.Add(-1)
<-mc.serializeSem
}
// noteSerializing records the peak concurrent serialization count for the
// admission-bound test; called only under a freshly taken slot.
func (mc *cscMissCoalescer) noteSerializing() {
n := uint64(mc.serializing.Add(1))
for {
m := mc.maxSerializing.Load()
if n <= m || mc.maxSerializing.CompareAndSwap(m, n) {
return
}
}
}
// reconcileWireBytes corrects req's reservation to the ACTUAL serialized size once
// req.wire is built, and reports whether the request still fits the budget. The
// reservation is charged from cmdApproxBytes BEFORE serialization (so an
// over-budget miss never allocates the wire). That estimate sizes string, []byte,
// *string and BinaryMarshaler args exactly, but it is still an estimate: a
// marshaler whose output differs between the sizing call and writeCmd's own
// MarshalBinary call, or a numeric/time arg whose text exceeds the fixed 8-byte
// fallback, serializes larger than charged. Without a recheck, N concurrent such
// misses all pass the pre-serialize gate, and then charging their true size drifts
// the in-flight total past cscMissWireBudgetBytes while every snapshot stays
// queued.
//
// Returns true (keep) when the actual size fits: the delta is charged and
// req.reserved advanced to actual, so settle() releases exactly once. Returns
// false (shed) when charging the actual size would exceed the budget: the delta is
// rolled back, req.reserved is LEFT at the estimate (fetch's !enqueued defer
// releases that), and the caller sheds to the pooled path — so the over-budget
// wire is dropped, not retained in mc.ch/inflight. A shrinking delta (actual <
// estimate) always keeps.
func (mc *cscMissCoalescer) reconcileWireBytes(req *cscMissReq) bool {
actual := int64(len(req.wire))
delta := actual - req.reserved
if delta <= 0 {
if delta != 0 {
mc.wireBytes.Add(delta)
req.reserved = actual
}
return true
}
if mc.wireBytes.Add(delta) > cscMissWireBudgetBytes {
mc.wireBytes.Add(-delta) // roll back; reserved stays the estimate for the defer release
return false
}
req.reserved = actual
return true
}
// settle releases req's wire-budget reservation and wakes its caller, exactly
// once. Every path that finishes a QUEUED request (applyAndSettle, settleErr, and
// the shutdown drain) funnels through here, so the budget a request holds while its
// wire sits in mc.ch/inflight is returned precisely when that wire is done. Requests
// that never reach the queue release their reservation in fetch instead.
func (mc *cscMissCoalescer) settle(req *cscMissReq, err error) {
mc.releaseWireBytes(req.reserved)
req.done <- err
}
// emitReplyErr records the native error metric for a settled REPLY error (e.g.
// redis.Nil, WRONGTYPE) at the point the caller consumes it — the single
// emission site for coalesced reply errors, giving parity with processWithRetry
// on the uncoalesced path while staying exactly-once per operation. Excluded:
// session errors and shed retries (processCached re-runs those through
// processWithRetry, which emits its own outcome) and the cancellation branch (a
// caller returning ctx.Err() already emitted its cancellation; emitting the late
// reply too recorded two conflicting error types for one operation). Reading
// req.servedBy is safe here: the caller's req.done receive is the
// happens-before edge for that field.
func (mc *cscMissCoalescer) emitReplyErr(ctx context.Context, req *cscMissReq, err error) {
if err == nil || err == errCSCRetryUncached {
return
}
var sessErr cscSessionError
if errors.As(err, &sessErr) {
return
}
errorCallback := pool.GetMetricErrorCallback()
if errorCallback == nil {
return
}
errorType, statusCode, isInternal := classifyCommandError(err)
errorCallback(ctx, errorType, req.servedBy, statusCode, isInternal, 0)
}
func (mc *cscMissCoalescer) fetch(ctx context.Context, cmd Cmder, cacheKey string, token uint64) (served *pool.Conn, err error) {
req := &cscMissReq{cmd: cmd, cacheKey: cacheKey, token: token, done: make(chan error, 1)}
// Bound total in-flight serialized bytes before allocating this command's wire
// snapshot: reserve its approximate encoded size and, if that would exceed
// cscMissWireBudgetBytes, shed to the ordinary pooled path (errCSCRetryUncached)
// rather than serialize-and-block — so a burst of large misses cannot exhaust
// memory with wire copies. Reserving BEFORE serialization means over-budget
// callers never allocate the wire. A single command larger than the whole budget
// sheds too (the pooled path runs it, so there is no progress hazard).
approxBytes := cmdApproxBytes(cmd)
if !mc.reserveWireBytes(approxBytes) {
mc.c.csc.Cancel(cacheKey, token)
return nil, errCSCRetryUncached
}
req.reserved = approxBytes
// Release the reservation HERE only if the request never gets queued (serialize
// error, or Close/ctx before enqueue). Once enqueued, ownership transfers to the
// request and settle() releases it when its wire actually leaves mc.ch/inflight —
// so a caller cancelling after enqueue does not free the budget while its wire
// snapshot is still retained (the OOM vector a caller-lifetime release allowed).
enqueued := false
defer func() {
if !enqueued {
mc.releaseWireBytes(req.reserved)
// Release the cache reservation too when the request never got queued,
// including the panic path (writeCmd serializing a user arg that panics),
// which skips the explicit Cancel on the error returns and would otherwise
// leave the key IN_PROGRESS until StaleTimeout, blocking later readers.
// Cancel is token-guarded, so this is a no-op next to the explicit Cancels.
mc.c.csc.Cancel(cacheKey, token)
}
}()
// Honor ContextTimeoutEnabled for the REPLY wait below (an enqueued request's
// socket I/O): when it is false the ordinary command path drives socket I/O on
// context.Background() (bounded by ReadTimeout, not the caller deadline), so a
// coalesced miss must not surface context.DeadlineExceeded on the caller's
// deadline. c.context returns the caller ctx when the policy is on and Background
// when off. The ENQUEUE-admission wait instead honors the original ctx directly
// (pre-I/O backpressure aborts on caller cancellation regardless of the policy —
// see that select). mc.stop stays the unconditional shutdown signal.
wctx := mc.c.context(ctx)
// Bound the number of callers serializing a wire snapshot AT ONCE before
// allocating this one: reserveWireBytes above gates on cmdApproxBytes, an
// estimate that can still undercount (see reconcileWireBytes), so a concurrent
// burst can all pass it and each allocate a full multi-MiB wireBuf before
// reconcileWireBytes sheds — a transient set the byte budget never sees (#3965
// F3). The slot is held ONLY across
// writeCmd+reconcile and released before the enqueue send below, so it never
// couples to queue backpressure. A caller that cannot get a slot sheds to the
// pooled path (the !enqueued defer releases the byte reservation).
if !mc.acquireSerialize() {
mc.c.csc.Cancel(cacheKey, token)
return nil, errCSCRetryUncached
}
// Release the slot even if writeCmd panics while serializing a user arg (a
// panicking Marshaler): without this the slot leaks and counts against
// cscMissMaxConcurrentSerialize forever, shedding later misses to the pooled
// path. Cleared after each explicit release below, so the normal path still
// releases before the enqueue select (the defer then no-ops).
serializeHeld := true
defer func() {
if serializeHeld {
mc.releaseSerialize()
}
}()
// Snapshot the wire form NOW, while the caller still owns cmd: the session
// writer writes these engine-owned bytes and never reads cmd again, so an
// abandoning caller can immediately reuse mutable args (e.g. a []byte key)
// without racing arg serialization, a mutated key can never be sent or
// published under the original cache key, and the fetch always completes —
// the reservation always settles. The reply side still claims cmd (apply
// interlock) before writing the result into it.
var wireBuf bytes.Buffer
if err := writeCmd(proto.NewWriter(&wireBuf), cmd); err != nil {
mc.releaseSerialize()
serializeHeld = false
mc.c.csc.Cancel(cacheKey, token)
return nil, err
}
req.wire = wireBuf.Bytes()
// Reconcile the reservation to the real serialized size now that the wire exists:
// cmdApproxBytes is an estimate and can still undercount (see reconcileWireBytes).
// If the true size would push the in-flight total over budget, shed to the pooled
// path instead of enqueueing an over-budget snapshot (the !enqueued defer releases
// the estimate). Without this, concurrent undercounted misses all pass the
// pre-serialize gate and then blow the budget while every wire stays queued.
fits := mc.reconcileWireBytes(req)
// Release the serialization slot BEFORE the enqueue select: the snapshot exists
// and is reconciled, so the transient-allocation concern the slot bounds is over.
mc.releaseSerialize()
serializeHeld = false
if !fits {
mc.c.csc.Cancel(cacheKey, token)
return nil, errCSCRetryUncached
}
// Reject early if the coalescer is already stopping, so a send does not win the
// select race against a closed mc.stop and land in mc.ch after the shutdown
// drain, where nothing would pick it up. This narrows but cannot close that
// race; the mc.stop case in the wait select below is what guarantees the caller
// never hangs on a post-drain req.
select {
case <-mc.stop:
// Retry-uncached, not ErrClosed: the coalescer stopping does not mean
// the CLIENT is closed (teardown deactivates serving before stopping
// the coalescer, and a clone can race that window) — the uncached
// re-run either succeeds on the open pool or surfaces the real error.
mc.c.csc.Cancel(cacheKey, token)
return nil, errCSCRetryUncached
default:
}
select {
case mc.ch <- req:
enqueued = true
case <-mc.stop:
mc.c.csc.Cancel(cacheKey, token)
return nil, errCSCRetryUncached
case <-ctx.Done():
// Enqueue-admission backpressure is PRE-I/O (nothing written yet), so honor
// the caller's cancellation directly on ctx — NOT wctx. wctx follows
// ContextTimeoutEnabled because it gates SOCKET I/O (the reply-wait select
// below); a caller cancelling while merely waiting for a queue slot must abort
// regardless of that policy, matching the ordinary path passing ctx to
// ConnPool.Get.
mc.c.csc.Cancel(cacheKey, token)
return nil, ctx.Err()
}
// The reply wait honors caller cancellation in two phases. PRE-I/O (the batch has
// not reached a connection, req.sentConn == nil) a caller cancel aborts on the RAW
// ctx regardless of ContextTimeoutEnabled — matching the ordinary path, which passes
// ctx to ConnPool.Get and only drops to the policy ctx for socket I/O. Once I/O has
// begun (sentConn set) cancellation follows wctx (the policy): ctxDone is disabled so
// an enqueued command Redis is already serving is not abandoned early on a deadline
// the ordinary socket-I/O path would not enforce.
abandon := func() (*pool.Conn, error) {
// Report the cancellation metric here, like processWithRetry: the reader may
// still complete the background fetch (success via applyAndSettle, or its own
// error via settleErr), so neither records THIS caller's cancellation — without
// this it is undercounted whenever coalescing is on. Attribute to the SENT conn
// via req.sentConn (an atomic view safe on this pre-req.done branch, unlike
// servedBy, which would race the session's write): if the command was already
// sent, Redis received it, so report that conn; nil only for a true pre-send cancel.
sentConn := req.sentConn.Load()
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType, statusCode, isInternal := classifyCommandError(ctx.Err())
errorCallback(ctx, errorType, sentConn, statusCode, isInternal, 0)
}
// Lost the interlock: the reader claimed cmd and is mid-apply — wait (the receive
// is a happens-before edge) rather than race by reusing cmd, and return its
// result. Won it: the reader skips the cmd write; in the LIVE case it still
// publishes and settles the token, so leave it — except when stopping, where the
// req may be stranded past the shutdown drain (cancelIfStopping). Return ctx.Err()
// either way, matching processWithRetry.
if !req.claimAbandon() {
<-req.done
return req.servedBy, ctx.Err()
}
mc.cancelIfStopping(cacheKey, token)
return sentConn, ctx.Err()
}
ctxDone := ctx.Done()
for {
select {
case err := <-req.done:
mc.emitReplyErr(ctx, req, err)
return req.servedBy, err
case <-mc.stop:
// Close raced our enqueue: the req may have landed in mc.ch after the
// shutdown drain, so nothing will settle req.done. Winning the interlock
// means no reply is being applied — cancel the reservation and return instead
// of hanging (a duplicate Cancel, if the drain also got this req, is a no-op
// on a settled token).
if req.claimAbandon() {
mc.c.csc.Cancel(cacheKey, token)
// The req may be sitting in mc.ch AFTER the shutdown drain ran, where
// no worker will ever dequeue it — a live WithTimeout clone's pointer
// would then retain it (and its Cmder) indefinitely. Re-run the
// non-blocking drain to empty the queue; settling our own abandoned
// req is harmless (done is buffered, the duplicate Cancel is a no-op).
mc.drainQueueErr(errCSCRetryUncached)
return nil, errCSCRetryUncached
}
// The reader claimed cmd and is mid-apply: wait so we do not read/reuse
// cmd concurrently (the receive is a happens-before edge), then return
// the settle result itself — the reply may have been applied successfully.
e := <-req.done
mc.emitReplyErr(ctx, req, e)
return req.servedBy, e
case <-wctx.Done():
// Caller stopped waiting under ContextTimeoutEnabled (otherwise wctx is
// Background and never fires — matching the ordinary path's I/O timeout policy).
return abandon()
case <-ctxDone:
// Raw caller cancellation. Honor it only PRE-I/O (sentConn == nil); once the
// batch reached a conn, disable this case and fall through to the wctx policy,
// so a command Redis is already serving is not abandoned early when
// ContextTimeoutEnabled is off (wctx == Background there).
if req.sentConn.Load() != nil {
ctxDone = nil
continue
}
return abandon()
}
}
}
// cancelIfStopping releases an abandoned request's reservation, but ONLY when the
// coalescer is stopping. On a caller-deadline abandon (fetch's wctx.Done branch,
// after the caller won the cmd interlock) the live reader still dequeues the req
// and publishes to the cache, so the token is left to it. Once mc.stop is closed
// though, the req may be stranded in mc.ch past the shutdown drain with nothing to
// settle it: its reservation would stay IN_PROGRESS and block a later reader of the
// key until StaleTimeout (worse on a shared cache not flushed on close). So mirror
// the mc.stop wait branch — cancel the token and re-drain the queue. The
// non-blocking select keeps the live path's publish intact; Cancel is token-guarded,
// so a later reader's fresh reservation is unaffected.
func (mc *cscMissCoalescer) cancelIfStopping(cacheKey string, token uint64) {
select {
case <-mc.stop:
mc.c.csc.Cancel(cacheKey, token)
mc.drainQueueErr(errCSCRetryUncached)
default:
}
}
// applyAndSettle applies raw to the caller's command, publishes to the cache when
// cacheable (under the reading connection's tracking generation), and wakes the
// caller. Sends req.done exactly once.
//
// It claims the cmd interlock first: an abandoned caller (context cancelled, or
// Close raced its enqueue) has its Cmder back and may be reading or reusing it,
// so the reply must NOT be written there. The fetch is still not wasted — the
// reply is classified from a throwaway parse and, when cacheable, published for
// the next reader; only the caller's cmd write is skipped.
func (mc *cscMissCoalescer) applyAndSettle(req *cscMissReq, raw []byte, connID, capturedGen uint64) {
// A user CacheSizer (via fulfillCached) or a custom Cmder's reply parse (via
// applyCachedReply) runs on the full-duplex reader goroutine, which has no other
// recover. A panic here would crash the process AND leave this caller blocked on
// req.done forever (the req is already off inflight, so the teardown drain can
// never reach it). Recover, then settleErr: it cancels the reservation, tags a
// cscSessionError so the caller re-runs on the pooled path, and wakes the caller.
// The normal settle below is the terminal statement, so a panic never double-settles.
defer func() {
if r := recover(); r != nil {
internal.Logger.Printf(context.Background(),
"csc: miss-coalesce apply/publish panic (recovered): %v", r)
mc.settleErr(req, fmt.Errorf("csc: apply panic: %v", r))
}
}()
c := mc.c
var applyErr error
if req.claimApply() {
applyErr = applyCachedReply(req.cmd, raw)
} else {
// Abandoned: classify without touching the caller's cmd.
applyErr = classifyCachedReply(raw)
mc.abandonedApplies.Add(1)
}
if isCacheableReplyResult(applyErr) {
fc := &cscFetchCapture{
raw: raw,
connID: connID,
initGen: capturedGen,
key: req.cacheKey,
token: req.token,
}
c.fulfillCached(req.cacheKey, req.token, fc)
} else {
// WRONGTYPE / NOPERM / ...: returned to the caller, not cached.
c.csc.Cancel(req.cacheKey, req.token)
}
// No error-metric emission here: the CALLER emits at its req.done receive
// (emitReplyErr), which makes emission exactly-once per operation. Emitting on
// this side too double-counted an op whose caller cancelled late — the caller
// recorded its context cancellation while this reply error was recorded here,
// two conflicting error types for one operation. An abandoned caller's op is
// accounted by its cancellation alone.
mc.settle(req, applyErr)
}
// cscMissStopDrainIntervals caps the whole Close-time (stopping) drain at N
// stall-detection intervals. Without a total cap the per-interval progress check
// renews forever, so a server trickling one reply per interval keeps a deep in-flight
// pipeline (up to cscFullDuplexDepth) draining and blocks Close in wg.Wait for hours.
// This is an explicit TRADEOFF against the unbounded-progress rule the non-stopping
// drain keeps (see drainBackstop): a deep, legitimately slow-but-progressing drain
// CAN exceed N intervals, and past that budget Close-never-wedges outranks completing
// the remainder — some accepted in-flight replies may be cut. N × interval scales the
// budget with the configured (and maintenance-relaxed) timeouts instead of a fixed
// wall-clock; N=8 keeps a normal Close prompt while bounding the trickle case to tens
// of seconds. Only the stopping path is capped; an age-out/handoff recycle stays
// unbounded (the client is alive, only this conn's recycle waits).
const cscMissStopDrainIntervals = 8
// batchBudget bounds one coalesced batch's write + reads (connection acquisition
// is bounded separately, by acquireCtx). It follows the client's configured
// timeouts instead of a fixed cap: a client that deliberately sets
// ReadTimeout/WriteTimeout high for slow cacheable reads must not see only its
// coalesced misses cut off early (WithReader clamps the read to min(ctx deadline,
// ReadTimeout), so a shorter ctx would win). The 5s floor covers scheduling
// overhead when the configured timeouts are tiny or disabled, and also keeps the
// drainBackstop stall interval (batchBudget + 1s) safely above the reader's per-read
// bound so a single slow reply is never mistaken for a stall.
func (mc *cscMissCoalescer) batchBudget() time.Duration {
opt := mc.c.opt
var budget time.Duration
if opt.WriteTimeout > 0 {
budget += opt.WriteTimeout
}
if opt.ReadTimeout > 0 {
budget += opt.ReadTimeout
}
if budget < 5*time.Second {
budget = 5 * time.Second
}
return budget
}
// countBatch records batch-size accounting. The max is a CAS loop: with
// concurrent sessions a load-then-store could commit a smaller value over a
// larger one that landed in between, permanently underreporting the maximum.
func (mc *cscMissCoalescer) countBatch(batch []*cscMissReq) {
mc.batches.Add(1)
mc.batched.Add(uint64(len(batch)))
n := uint64(len(batch))
for {
cur := mc.maxBatchSz.Load()
if n <= cur || mc.maxBatchSz.CompareAndSwap(cur, n) {
return
}
}
}
// cscSessionError tags an error that comes from a coalescer session or transport
// failure (settleErr), not from the command's own reply (applyAndSettle).
// processCached uses this tag to tell the two apart. It re-runs a session failure
// on the normal path, which applies MaxRetries and backoff. It returns a
// reply-level result as-is (for example redis.Nil, WRONGTYPE, or a retryable reply
// such as LOADING), because that result is the answer. cscSessionError unwraps, so
// errors.Is and errors.As still see the cause.
type cscSessionError struct{ err error }
func (e cscSessionError) Error() string { return e.err.Error() }
func (e cscSessionError) Unwrap() error { return e.err }
// settleErr cancels the reservation and fails one waiting caller. It tags the
// error as cscSessionError, except the retry-uncached sentinel, which
// processCached matches by identity. The tag makes the caller re-run the read on
// the normal, fully instrumented path instead of surfacing a raw transport
// failure. That re-run emits the native error metric, so settleErr does not. To
// emit it here as well would double-count a re-run that fails, and would wrongly
// flag a re-run that succeeds. The retry-uncached path already used this rule.
// Every session failure now re-runs, so the rule applies to all of them.
func (mc *cscMissCoalescer) settleErr(req *cscMissReq, err error) {
mc.c.csc.Cancel(req.cacheKey, req.token)
if err != errCSCRetryUncached {
err = cscSessionError{err}
}
mc.settle(req, err)
mc.failed.Add(1)
}
// settleAllErr fails every request in batch from index `from` onward.
func (mc *cscMissCoalescer) settleAllErr(batch []*cscMissReq, from int, err error) {
for i := from; i < len(batch); i++ {
mc.settleErr(batch[i], err)
}
}
// drainQueueErr fails every request currently queued (non-blocking). Used when a
// connection cannot be acquired, so a caller on a context without a deadline is
// not blocked forever and no reservation is left IN_PROGRESS.
func (mc *cscMissCoalescer) drainQueueErr(err error) {
for {
select {
case req := <-mc.ch:
mc.settleErr(req, err)
default:
return
}
}
}
// settlePlain cancels the reservation and fails one waiting caller with err
// UNTAGGED — no cscSessionError, no retry-uncached sentinel — so processCached
// returns it as-is instead of re-running on the pooled path. Used for a
// Limiter.Allow rejection at session acquire: a re-run would call Limiter.Allow a
// SECOND time and a stateful/token limiter could admit the very operation it just
// denied. The caller's req.done receive still emits the error metric once
// (emitReplyErr), matching the ordinary path's single emission for a rejection.
func (mc *cscMissCoalescer) settlePlain(req *cscMissReq, err error) {
mc.c.csc.Cancel(req.cacheKey, req.token)
mc.settle(req, err)
mc.failed.Add(1)
}
// grabInto appends first, plus any misses already queued, into dst. It does not
// block. It stops at cscMissBatchMax commands or at mc.maxBatchBytes of serialized
// payload. It reuses dst's backing array. first always goes, whatever its size,
// because a single large command must not stall. The byte cap bounds only the
// extra already-queued misses that share the write.
//
// grabInto returns the batch and a carry. The carry is the one request it pulled
// that would push the batch past the byte cap. To pack that request on would
// exceed the write buffer and risk the mid-batch-flush deadlock. grabInto does NOT
// put the carry back on mc.ch; it returns it to the writer, which sends it as the
// first request of the next batch. This keeps the request in the writer's own
// state (never stranded on mc.ch during shutdown) and guarantees progress: a lone
// large request ships next as its own batch. The carry is nil when nothing was
// deferred.
func (mc *cscMissCoalescer) grabInto(dst []*cscMissReq, first *cscMissReq) ([]*cscMissReq, *cscMissReq) {
dst = append(dst, first)
nbytes := len(first.wire)
for len(dst) < cscMissBatchMax {
select {
case more := <-mc.ch:
if nbytes+len(more.wire) > mc.maxBatchBytes {
return dst, more // defer: carry it to the next batch
}
dst = append(dst, more)
nbytes += len(more.wire)
default:
return dst, nil
}
}
return dst, nil
}
// Coalescer observability is the client's normal telemetry (the otel operation
// and error callbacks fire for coalesced misses like any other command path).
// The engine's internal counters (batched/batches/failed/maxBatchSz on
// cscMissCoalescer) exist for in-package tests, which read them directly off
// c.cscMissCoalescer — deliberately NOT exported as a stats API.
package redis
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/push"
)
// The full-duplex miss-coalescer engine — THE engine behind
// Options.ClientSideCacheCoalesceMisses (misses are caller-blocking, so the
// latency-first streaming engine is the only one; see csc_miss_coalesce.go for
// the removed alternatives).
//
// fullDuplexLoop runs sessions holding one tracked connection with concurrent
// writer + reader goroutines: commands stream out while replies stream back, so
// many commands are in flight on a single socket (the rueidis pipelining
// model) — hides RTT with one connection instead of N.
//
// A session holds its connection outside the normal pool Get/Put cycle and
// redials on error. Correctness note: a held connection spans reconnects, so on
// ANY connection error every in-flight request is failed (token cancelled,
// caller woken) rather than matched to a reply across tracking generations, and
// a published reply is gated on the reading connection's id+generation still
// matching the one captured when the command was written.
//
// (An earlier "pinned" engine — one held conn, serial half-duplex batches, NO
// idle invalidation drain — was a benchmark-only prototype and has been
// removed: it could serve stale after an invalidation while idle.)
const cscModeBackoff = 5 * time.Millisecond
func opCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// acquireCtx bounds a session connection acquisition. Unlike opCtx's fixed 5s
// it honors the larger of the configured PoolTimeout (a saturated pool may
// legitimately make Get wait that long) and the pool's full dial budget
// (DialerRetries attempts of DialTimeout plus backoffs — a shorter outer
// deadline would cancel Get mid-dial-sequence and forfeit configured retries).
// Cancelled when the coalescer stops, so Close does not stall behind a blocked
// Get.
func (mc *cscMissCoalescer) acquireCtx() (context.Context, context.CancelFunc) {
opt := mc.c.opt
d := 5 * time.Second
if pt := opt.PoolTimeout; pt > d {
d = pt
}
// Options.init resolves the dial knobs to nonzero defaults. Use a DETERMINISTIC
// estimate that never invokes DialerRetryBackoff: calling a randomized/stateful
// backoff here (e.g. DialRetryBackoffExponential) consumes different jitter
// samples than the pool's actual retries, so the estimate can still be wrong —
// summing it out-of-band is not sound. A custom backoff whose real delays
// exceed this flat estimate should raise PoolTimeout; this deadline is only a
// floor anyway, since mc.stop also cancels the Get on Close.
if db := time.Duration(opt.DialerRetries)*(opt.DialTimeout+opt.DialerRetryTimeout) + opt.DialTimeout; db > d {
d = db
}
ctx, cancel := context.WithTimeout(context.Background(), d)
done := make(chan struct{})
go func() {
select {
case <-mc.stop:
cancel()
case <-done:
}
}()
return ctx, func() { close(done); cancel() }
}
// ---- full-duplex engine (feature-complete) ----
// cscFullDuplexConnsDefault is how many independent full-duplex sessions (each
// its own held connection) the fullduplex engine runs. 1 keeps the footprint
// minimal (optimal at high concurrency). More would shard the miss +
// invalidation-drain load across connections, cutting the low-concurrency p99
// tail. Order-free: coalesced misses are independent per-key fetches.
const cscFullDuplexConnsDefault = 1
// cscFullDuplexIdleProbe is how often the reader drains server-initiated pushes
// on the held connection while no reply is pending. A var (not const) so a test
// can disable the idle drain (set it huge) as a negative control.
var cscFullDuplexIdleProbe = 5 * time.Millisecond
// cscFullDuplexSessionIdle: a session with no miss for this long ends and
// returns its held connection (at PoolSize:1 an idle hold would block
// non-cacheable commands until PoolTimeout). The next miss re-acquires; under
// steady load the timer never fires. Var for tests.
var cscFullDuplexSessionIdle = time.Second
// fullDuplexRecycleAge bounds how long a session holds cn so OnPut pool hooks
// (metrics, health, queued maintenance handoff) run periodically. Capped by the
// conn's REMAINING lifetime (ExpiresAt, jitter included): an old conn must not
// be held past the expiry the pool's reaper enforces.
func (c *baseClient) fullDuplexRecycleAge(cn *pool.Conn) time.Duration {
age := 30 * time.Second
// Lifetime is honored via the conn's ACTUAL absolute expiry only —
// ExpiresAt already includes ConnMaxLifetimeJitter. Capping at the raw
// ConnMaxLifetime first would collapse every positive-jitter connection
// back to the unjittered lifetime, re-synchronizing recycles across
// clients started together (the herd the jitter option exists to prevent).
if exp := cn.ExpiresAt(); !exp.IsZero() {
if remain := time.Until(exp); remain < age {
age = remain
}
}
// A conn at (or past) expiry still serves the session it was just leased
// for — recycle promptly rather than with a zero/negative timer.
if age < 10*time.Millisecond {
age = 10 * time.Millisecond
}
return age
}
// fullDuplexLoop runs full-duplex sessions back to back: each session holds one
// tracked connection until it errors, is recycled (handoff/close/age), or stop
// is requested. Exits on stop.
func (mc *cscMissCoalescer) fullDuplexLoop() {
defer mc.wg.Done()
for {
select {
case <-mc.stop:
return
default:
}
stopped, errored := mc.runFullDuplexSession()
if stopped {
return
}
// Back off only after an ERROR end, so a persistent dial failure does not
// hot-spin; a clean idle/recycle end continues immediately — the next
// session blocks on the miss queue anyway, and sleeping here would add a
// flat cscModeBackoff of latency to a miss already waiting in mc.ch.
if !errored {
continue
}
select {
case <-mc.stop:
return
case <-time.After(cscModeBackoff):
}
}
}
// runFullDuplexSession acquires one tracked connection and pipelines misses on
// it with concurrent writer and reader goroutines. It ends when:
// - a socket read/write errors (fail path: fail in-flight, Close + Remove);
// - the connection is marked for handoff/close, the session ages out, or stop
// is requested (graceful path: drain in-flight, Put so OnPut runs pool hooks
// and any queued maintenance handoff).
//
// Returns true iff stop was requested (the loop should exit).
func (mc *cscMissCoalescer) runFullDuplexSession() (stopped, backoff bool) {
c := mc.c
// Do not hold a pool connection while idle: wait for the first miss BEFORE
// acquiring. An eagerly-held session connection would, at a small pool
// (PoolSize:1), starve non-cacheable commands (PING/SET/uncached reads) until
// PoolTimeout while the session sat waiting for work. The pulled miss is
// written first by the writer below.
var first *cscMissReq
select {
case <-mc.stop:
return true, false
case first = <-mc.ch:
}
getCtx, getCancel := mc.acquireCtx()
cn, limited, err := c.getConnLimited(getCtx)
getCancel()
if err != nil {
// Distinguish a teardown cancellation from a genuine acquire failure by
// checking mc.stop DIRECTLY, not by err's value: getConn runs the dialer,
// credentials callbacks, and init hooks, any of which can return
// context.Canceled from their OWN context while the coalescer is NOT
// stopping. mc.stop is closed only by stopWorkers, and close(mc.stop) is
// ordered before acquireCtx cancels its context (which is ordered before
// getConn returns), so a real teardown is always visible here.
select {
case <-mc.stop:
// Tearing down: settle retry-uncached — like the other stop paths (fetch,
// the serving-disabled branch below, stopCSCMissCoalescer) — so processCached
// re-runs each read on the still-open pool instead of surfacing a spurious
// cancellation, and report stopped so the loop exits without backing off.
mc.settleErr(first, errCSCRetryUncached)
mc.drainQueueErr(errCSCRetryUncached)
return true, false // stop requested: exit the loop
default:
}
if limited {
// Limiter.Allow REJECTED admission for the session connection (before any
// dial): first was the ONE request that woke the session and triggered that
// Allow call. Surface the rejection to IT unchanged — settle without the
// cscSessionError tag so processCached returns it as-is instead of
// re-running on the pooled path, whose getConn would call Limiter.Allow a
// SECOND time and could admit (defeat) the denial the limiter just issued.
//
// The requests already queued behind first never called Allow themselves —
// they were just waiting for the session to write their command. Failing
// them with first's raw, non-retryable error would deny operations the
// limiter was never asked about. Settle them retry-uncached instead: each
// re-runs on the ordinary per-command path and calls Allow independently,
// which is what would have happened had they never been queued (cursor
// review #3989). Still an error end for the session loop (backoff also
// damps Allow frequency under sustained rejection).
mc.settlePlain(first, err)
mc.drainQueueErr(errCSCRetryUncached)
return false, true
}
// Genuine acquire failure (dial error, pool exhaustion, an unrelated
// context.Canceled from a custom dialer/creds/init hook). Fail the first miss
// plus everything queued so a caller on a deadline-less context is not blocked
// forever and no reservation leaks IN_PROGRESS, then back off and retry so the
// sole worker stays alive — requests arriving during backoff are failed on the
// next attempt's drain.
mc.settleErr(first, err)
mc.drainQueueErr(err)
return false, true // error end: the loop backs off before re-acquiring
}
// CSC serving may have been disabled after the miss was queued (e.g. a HELLO 3
// downgrade or CLIENT TRACKING rejected during initConn). Holding the conn to
// wait for more misses would be pointless — none get routed here once serving
// is off — and could tie up a small pool. Release it cleanly and fail pending.
if a := c.cscActive; a != nil && !a.Load() {
relCtx, relCancel := opCtx()
c.releaseConn(relCtx, cn, nil)
relCancel()
// CSC is off now, but the commands are fine — settle with the retry-uncached
// sentinel so processCached re-runs each on the normal path instead of
// surfacing a spurious pool.ErrClosed for a valid cacheable read.
mc.settleErr(first, errCSCRetryUncached)
mc.drainQueueErr(errCSCRetryUncached)
return false, false // clean end: no new misses route here while serving is off
}
connID := cn.GetID()
gen := c.cscConnInitGen(connID)
inflight := make(chan *cscMissReq, cscFullDuplexDepth)
// readsDone counts replies the reader has fully consumed. The drain
// backstop samples it as its progress signal: unlike len(inflight), it sees
// the ACTIVE read too (the reader pops a request before reading its reply,
// so a lone deep read under a maintenance-relaxed timeout would otherwise
// look like a zero-progress stall and get its socket closed mid-reply).
var readsDone atomic.Uint64
sctx, scancel := context.WithCancel(context.Background())
defer scancel()
var sessErr atomic.Value // error, set only on I/O failure
var failOnce sync.Once
fail := func(e error) {
failOnce.Do(func() {
if e == nil {
e = pool.ErrClosed
}
sessErr.Store(e)
scancel()
})
}
errored := func() bool { _, ok := sessErr.Load().(error); return ok }
reasonErr := func() error {
if e, ok := sessErr.Load().(error); ok {
return e
}
return pool.ErrClosed
}
recycle := make(chan struct{})
var recycleOnce sync.Once
doRecycle := func() { recycleOnce.Do(func() { close(recycle) }) }
var stopFlag atomic.Bool
// handoffWanted reports whether the connection should be returned to the pool
// so the OnPut hooks (including a queued maintenance handoff) can run.
handoffWanted := func() bool {
return !cn.IsUsable() || cn.ShouldHandoff() || cn.CloseOnPutReason() != ""
}
var swg sync.WaitGroup
swg.Add(2)
// Writer: pull a request (plus whatever else is queued), write the batch,
// then enqueue each written request to the reader in wire order. The writer
// is the SOLE closer of inflight and closes it as its last act on every exit
// path, which is how the reader learns the session drained.
go func() {
defer swg.Done()
defer close(inflight)
idleT := time.NewTimer(cscFullDuplexSessionIdle)
defer idleT.Stop()
buf := make([]*cscMissReq, 0, cscMissBatchMax)
pending := first // the miss that woke the session; write it before blocking
for {
var req *cscMissReq
if pending != nil {
// A carry from the previous batch. Still honor recycle/stop/cancel
// between batches even under a sustained carry stream, or a
// persistently over-budget producer would starve a maintenance handoff
// or lifetime recycle (the OnPut hooks would never run). Settle the
// carry on bail: it is the writer's own state, so the teardown drain
// (which covers only inflight) would otherwise leak it and hang its
// caller.
select {
case <-recycle:
mc.settleErr(pending, errCSCRetryUncached)
return
case <-mc.stop:
stopFlag.Store(true)
doRecycle()
mc.settleErr(pending, errCSCRetryUncached)
return
case <-sctx.Done():
mc.settleErr(pending, reasonErr())
return
default:
}
// grabInto below overwrites pending with the next carry, so do not
// clear it here (that store would be dead).
req = pending
} else {
if !idleT.Stop() {
select {
case <-idleT.C:
default:
}
}
idleT.Reset(cscFullDuplexSessionIdle)
select {
case <-recycle:
return
case <-mc.stop:
stopFlag.Store(true)
doRecycle()
return
case <-sctx.Done():
return
case <-idleT.C:
// No miss for the grace period: end the session cleanly so the
// held connection goes back to the pool instead of blocking
// non-cacheable traffic at a small pool until the recycle age.
// The reader drains anything still in flight (close(inflight)
// is the graceful no-more signal); the next miss starts a fresh
// session, which acquires only when work is in hand.
return
case req = <-mc.ch:
}
}
// grabInto may defer one over-budget request; carry it in pending so the
// next iteration writes it as the first request of its own batch. Held in
// the writer's own state, never put back on mc.ch, so a shutdown cannot
// strand it there. The error paths below settle it if this batch fails.
buf, pending = mc.grabInto(buf[:0], req)
mc.countBatch(buf)
// Attribute every request in this batch to the session connection up
// front. A write-side failure settles the batch before the reply path
// runs, so without this the error metric and processCached would report a
// nil connection and zero attempts for commands that did reach cn. buf is
// []*cscMissReq, so this persists to the reply path (which re-sets it).
for _, r := range buf {
r.servedBy = cn
r.sentConn.Store(cn) // atomic view for a caller abandoning before req.done
}
// Hand the batch to the reader BEFORE writing it, so the reader drains
// reply k while the writer is still writing and flushing k+1. If the
// reader started only after the whole batch was written (the old order),
// a batch larger than the transport send capacity could deadlock: the
// server blocks writing early replies that nobody reads while the writer
// blocks flushing later requests. With the reader draining concurrently,
// those early replies are consumed and the server keeps accepting the
// rest of the write.
//
// This send cannot deadlock the writer. It blocks only when inflight is
// full (cscFullDuplexDepth), and a batch is at most cscMissBatchMax
// (128 << 4096). The writer is one goroutine and runs sequentially, so
// every PRIOR batch was already flushed by its own WithWriter before this
// iteration. A full channel therefore holds at least
// cscFullDuplexDepth-cscMissBatchMax already-flushed requests, and the
// reader consumes FIFO, so its front is always a flushed request whose
// reply is on the wire: the reader advances, frees a slot, and the send
// proceeds. (Mirrors the autopipeline full-duplex engine, which likewise
// enqueues each batch before it writes.)
//
// A stalled or exited reader cannot strand the writer here either: every
// reader-exit path either cancels sctx (readOne/tick failure via fail, or
// the reader's own sctx.Done case) — so the select below escapes — or
// happens only after the writer already exited and closed inflight. Keep
// that true: a reader return added after doRecycle without cancelling sctx
// would break this escape.
sent := 0
for _, r := range buf {
select {
case inflight <- r:
sent++
case <-sctx.Done():
// Session cancelled mid-handoff. The reader and the teardown settle
// whatever reached inflight; the writer settles the rest and the
// carry (never in inflight).
mc.settleAllErr(buf, sent, reasonErr())
if pending != nil {
mc.settleErr(pending, reasonErr())
}
return
}
}
// sctx directly, NOT c.context(sctx): c.context() strips the engine's
// cancellable session ctx under ContextTimeoutEnabled=false, and with
// per-op timeouts disabled a blocked read/write could then never be
// interrupted (the supervisor's conn-close is the backstop).
werr := cn.WithWriter(sctx, c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, r := range buf {
// r.wire was snapshotted at enqueue while the caller owned
// cmd (see cscMissReq.wire): the writer never reads cmd, so
// an abandoned caller's args are never touched here, and an
// abandoned fetch still completes — its reply publishes to
// the cache and settles the reservation.
if _, e := wr.Write(r.wire); e != nil {
return e
}
}
return nil
})
if werr != nil {
fail(werr)
// The batch is already in inflight, so the reader (and the teardown
// drain) settle it. The writer only owns the carry, which never
// entered inflight.
if pending != nil {
mc.settleErr(pending, werr)
}
return
}
}
}()
// Reader: settle replies in the order the writer enqueued them (== wire
// order), and drain server-initiated pushes (invalidations, maintenance)
// even while idle. Publishes only while the connection's id+generation still
// match the session's.
go func() {
defer swg.Done()
ticker := time.NewTicker(cscFullDuplexIdleProbe)
defer ticker.Stop()
readOne := func(req *cscMissReq) bool { // false => fatal, reader must exit
// batchBudget (write + read), not bare ReadTimeout: the writer hands the batch
// to this reader BEFORE flushing it (deadlock avoidance), so a reply read can
// arm while its request is still being written — an oversized first command
// (allowed past the batch byte cap) or a slow/WAN link then charges write-flush
// time against a bare ReadTimeout and spuriously times out (and tears down) a
// session whose write is within WriteTimeout (#3989). batchBudget is exactly the
// batch's write+read bound; relaxation still raises it further via EffectiveReadTimeout.
rerr := cn.WithReader(sctx, mc.batchBudget(), func(rd *proto.Reader) error {
// Push handling with the nonblocking Close adapter: this reader
// is part of mc.wg, so a custom push handler calling Close() on
// the raw client would self-deadlock (Close waits on mc.wg while
// the reader is parked inside the handler). The adapter's Close
// signals and defers the blocking teardown to a goroutine.
// Drain server pushes before reading this request's reply, through the
// shared helper in BLOCKING mode: block on the socket and skip pushes
// until the reply is the next frame, so a second invalidation still on
// the socket ahead of the reply is not read by ReadRawReply as this
// request's reply. (The Buffered variant stopped at buffer-empty and let
// such a socket-pending push through, shifting the reply stream by one
// frame.) A custom processor is handed only a confirmed push frame (peek
// first). FATAL, not log-and-continue: a drain error means bytes may have
// been consumed mid-frame, so continuing into ReadRawReply on a
// desynchronized stream could apply a push fragment as this request's
// reply and publish it to the cache under the wrong key. Fail the session
// so the connection is removed.
if e := c.drainPushFrames(sctx, cn, rd, true); e != nil {
internal.Logger.Printf(sctx, "csc: miss-coalesce push drain: %v", e)
return e
}
raw, e := rd.ReadRawReply()
if e != nil {
return e
}
// Deliver the reply the caller is waiting for and let applyAndSettle
// gate only the CACHE PUBLISH on the connection's id/generation
// (fulfillCached checks the captured gen). Previously FD failed the
// caller with ErrClosed on a mid-flight id/gen change even though the
// reply was read fine, losing a good result (#3965).
req.servedBy = cn // before settle: done is the happens-before edge
mc.applyAndSettle(req, raw, connID, gen)
return nil
})
if rerr != nil {
fail(rerr)
req.servedBy = cn // attribute the failed read to the session conn
mc.settleErr(req, rerr)
return false
}
readsDone.Add(1)
return true
}
// handoff/close is a rare, non-latency-critical event, so it is checked off
// the per-reply hot path: on every idle tick, and once every readsPerHandoffCheck
// replies (~a few ms of traffic at any throughput).
const readsPerHandoffCheck = 128
reads := 0
for {
select {
case req, ok := <-inflight:
if !ok {
return // writer closed: session drained clean
}
if !readOne(req) {
return
}
reads++
if reads%readsPerHandoffCheck == 0 && handoffWanted() {
doRecycle()
}
case <-ticker.C:
// No reply was ready this tick: drain any server-initiated pushes
// (invalidations, MOVING, ...) unconditionally — the held connection
// is out of the pool, so the background drainer never visits it. Safe
// even if a reply is on the wire: the push processor peeks the reply
// type and consumes only push frames, leaving the reply for the next
// inflight read.
if e := c.peekAndProcessPushNotifications(sctx, cn); e != nil {
fail(e)
return
}
// Opaque-transport fallback, HELD-CONN ONLY: where readiness cannot
// be inspected (Windows; wrappers exposing neither syscall.Conn nor
// NetConn) the gated peek never fires and nothing else drains this
// conn. Throttled timed drain (no-op on inspectable transports).
// Deliberately not in peekAndProcessPushNotifications: pooled paths
// have drainer coverage, and a blanket fallback perturbs sequenced
// push handling.
// Built-in processor ONLY: this probe reads speculatively (no
// readiness signal exists on an opaque transport), and the
// built-in processor swallows the no-data boundary timeout. A
// CUSTOM processor's contract documents being invoked when
// notifications are KNOWN present — a natural implementation
// surfaces the empty-probe timeout, which would fail (and
// redial) a healthy session on every idle probe. Custom +
// opaque conns forgo the speculative drain; their invalidations
// land on session end/recycle at the latest.
if _, builtin := c.pushProcessor.(*push.Processor); builtin {
if cn.TakeCscPeriodicReadPending(cscFallbackProbeInterval) {
if e := c.timedPushDrain(sctx, cn); e != nil {
fail(e)
return
}
}
}
if handoffWanted() {
doRecycle()
}
case <-sctx.Done():
return
}
}
}()
// Supervisor: request a graceful recycle on stop or when the session ages
// out. A handoff/close request comes from the reader via doRecycle().
recycleTimer := time.NewTimer(c.fullDuplexRecycleAge(cn))
defer recycleTimer.Stop()
superDone := make(chan struct{})
supDead := make(chan struct{}) // closed when the supervisor exits (joined before release)
// drainBackstop bounds a graceful drain by PROGRESS, not wall clock: no
// context can interrupt a deadline-less socket read, so a stalled drain must
// force the I/O out with a conn close (else Close wedges in wg.Wait) — but a
// HEALTHY drain of a deep in-flight pipeline (up to cscFullDuplexDepth
// replies, ~in-flight × RTT, possibly under a maintenance-relaxed timeout)
// may legitimately exceed any fixed budget. Progress is COMPLETED READS
// (readsDone), not queue length: the reader pops a request before reading
// its reply, so len(inflight) is blind to the active read and would call a
// lone deep read a stall. Each budget interval that completed at least one
// reply extends the wait; only a zero-progress interval closes the conn.
drainBackstop := func(stopping bool) { mc.drainBackstopRun(cn, &readsDone, superDone, stopping) }
go func() {
defer close(supDead)
select {
case <-mc.stop:
stopFlag.Store(true)
doRecycle()
drainBackstop(true)
case <-recycleTimer.C:
doRecycle()
drainBackstop(false)
case <-recycle:
// Reader-triggered recycle (handoffWanted: unusable / handoff-marked /
// close-on-put). The writer exits, but replies may remain in flight —
// without a backstop a stalled read would hold the conn (and postpone
// the requested handoff) until Close. Same bounded drain as the timer
// path; doRecycle already ran (this channel IS the recycle signal).
drainBackstop(false)
case <-sctx.Done(): // I/O error: unblock a reader/writer parked on the socket
_ = cn.Close()
case <-superDone:
}
}()
swg.Wait()
close(superDone)
// JOIN the supervisor before releasing cn: the deferred scancel makes
// sctx.Done() ready, and a still-parked supervisor could pick it over the
// equally-ready superDone and close a healthy conn already back in the pool.
<-supDead
// Teardown. Error path: the socket was (or is being) closed; Remove it.
// Graceful path: the connection is clean (every written reply was read), so
// Put it — OnPut runs the pool hooks and any queued maintenance handoff, and
// the handoff/reinit path evicts this connection's now-uncovered CSC entries.
relCtx, relCancel := opCtx()
if errored() {
_ = cn.Close()
c.releaseConn(relCtx, cn, reasonErr())
} else {
c.releaseConn(relCtx, cn, nil)
}
relCancel()
// Fail anything the writer enqueued that the reader never consumed (only
// possible on the error path; the graceful path drains inflight fully). The
// writer always closes inflight on exit, so a receive here can report the
// channel closed (ok=false) — do not settle a nil request.
for {
select {
case r, ok := <-inflight:
if !ok {
return stopFlag.Load(), errored()
}
mc.settleErr(r, reasonErr())
default:
return stopFlag.Load(), errored()
}
}
}
// drainBackstop bounds a graceful drain of the full-duplex miss-coalescer session
// by PROGRESS (completed reads, readsDone), not wall clock — see the block comment at
// the call site and cscMissStopDrainIntervals for the stopping-path total cap.
// Extracted from runFullDuplexSession so the stopping-drain budget is unit-testable.
func (mc *cscMissCoalescer) drainBackstopRun(cn *pool.Conn, readsDone *atomic.Uint64, superDone <-chan struct{}, stopping bool) {
prev := readsDone.Load()
// On the stopping (Close) path the progress branch below must not renew forever:
// a server trickling one reply per interval keeps readsDone advancing, so with up
// to cscFullDuplexDepth in-flight, Close would block in wg.Wait for hours. Cap the
// whole stopping drain with a total deadline, armed ONCE (the first stopping loop),
// at cscMissStopDrainIntervals × interval. This is a TRADEOFF, not a free win: a
// deep, legitimately slow-but-progressing drain CAN exceed that budget (see the
// unbounded-progress rationale below), and past it Close-never-wedges outranks
// completing the remainder — some accepted in-flight replies may be cut. Non-stopping
// (age-out / handoff recycle) keeps the unbounded rule: the client stays alive, only
// this conn's recycle waits.
var stopDeadline time.Time
for {
// One blocked read may legitimately wait out its full reply deadline
// (readOne arms batchBudget, and a maintenance-relaxed timeout can exceed
// even that), so the zero-progress verdict must not fire before the reader's
// own deadline. Keep the interval strictly ABOVE batchBudget — the reader's
// per-read bound — so a single legitimately-slow reply is never mistaken for a
// stall and its connection force-closed. Recomputed per interval: an expired
// relaxation shrinks it back.
interval := mc.batchBudget() + time.Second
rt := cn.EffectiveReadTimeout(mc.c.opt.ReadTimeout)
wt := cn.EffectiveWriteTimeout(mc.c.opt.WriteTimeout)
if rt+time.Second > interval {
interval = rt + time.Second
}
if wt+time.Second > interval {
interval = wt + time.Second
}
// Arm the total stopping-drain deadline once (see cscMissStopDrainIntervals):
// N intervals from the first stopping loop, so a trickling server cannot renew
// the drain past a bounded budget on Close. mc.stopDrainBudget overrides the cap
// (tests only; zero in production).
if stopping && stopDeadline.IsZero() {
budget := cscMissStopDrainIntervals * interval
if mc.stopDrainBudget > 0 {
budget = mc.stopDrainBudget
}
stopDeadline = time.Now().Add(budget)
}
// Deadline-less I/O (no active relaxation): the user explicitly
// disabled the read and/or write deadline, so the RECYCLE path must
// not cut a session whose reader is in a legitimately long reply OR
// whose writer is blocked flushing a large request — readsDone
// cannot show progress before a reply exists, so a deadline-free
// blocked WRITE looks exactly like a stall. Wait for the session
// (or Close) instead. Close still terminates: once stop fires, the
// bounded zero-progress close below applies, which is the shutdown
// contract (Close never wedges). NOTE the <= 0: Options.init
// normalizes -1 (indefinite) to 0 and -2 (no deadline calls) to
// -1, so both deadline-less modes land at <= 0 here.
if !stopping && (rt <= 0 || wt <= 0) {
select {
case <-superDone:
return
case <-mc.stop:
stopping = true
}
continue
}
// Watch mc.stop on the non-stopping (recycle) path too: if Close arrives AFTER an
// age-out/handoff recycle drain has already started here, flip to the stopping path
// so the next loop arms the total cap. Without this, a recycle-then-Close race stays
// on this unbounded progress wait and a trickling server hangs Close in wg.Wait —
// the exact hang cscMissStopDrainIntervals was added to prevent. nil once stopping:
// mc.stop is then closed and would busy-spin the select.
var stopCh <-chan struct{}
if !stopping {
stopCh = mc.stop
}
select {
case <-superDone:
return
case <-stopCh:
stopping = true
continue
case <-time.After(interval):
cur := readsDone.Load()
if cur != prev {
prev = cur // still consuming replies; give it another interval
// ...unless the total stopping-drain budget is spent: Close-never-wedges
// outranks completing the remaining in-flight against a trickling server.
if !stopDeadline.IsZero() && !time.Now().Before(stopDeadline) {
_ = cn.Close()
return
}
continue
}
_ = cn.Close()
return
}
}
}
package redis
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
)
// Refresh-on-invalidate: re-fetch a key as soon as its invalidation arrives,
// instead of dropping it and waiting for a reader to discover the miss.
//
// Why this is the lever worth pulling. The connection requirement of a CSC client
// is roughly `miss_rate x throughput x RTT`, so the hit rate is the only quantity
// that moves throughput and connection count in the same direction. Every other
// item measured in this investigation trades one against the other. Under churn
// the misses are exactly the invalidated keys, and they are known the moment the
// push arrives — a whole round trip before any reader asks.
//
// # Why it re-inserts rather than refreshes in place
//
// The time-based sweeper (csc_revalidate.go) can use RefreshValue, which replaces
// the value of a LIVE entry. This path cannot: the entry must be DELETED the
// instant the invalidation lands, because leaving a known-stale value readable
// while a refresh is in flight is the exact bug this whole line of work exists to
// remove. So the entry is dropped first and re-inserted through the ordinary
// Reserve/Fulfil path.
//
// Residual (not the same bug): because this republishes a value nobody asked for,
// a SECOND write whose invalidation is still on the refresh connection's socket
// behind the reply can be applied after the republish, so a reader briefly gets a
// stale HIT where, without refresh, they'd have taken a fresh MISS. It self-heals
// — the republish is on a CLIENT TRACKING conn, so that pending invalidation is
// delivered and deletes the entry — and is bounded by the drain interval /
// MaxStaleness. This is a narrower window than the "stale value readable for the
// whole refresh RTT" case above, not a reintroduction of it.
//
// That has a useful consequence: Reserve single-flights. If a reader misses the
// key while a background refresh is already in flight, it waits on that
// reservation instead of issuing a second fetch — so the key is fetched once, by
// one connection, no matter how many parties want it.
//
// # On fetching from the "owner" connection
//
// A reasonable worry is that refreshing on an arbitrary connection would register
// the key on several connections at once, so one write would produce several
// invalidations. It does not, because default (non-BCAST) tracking is ONE-SHOT:
// when the server sends `invalidate k` it removes k from its tracking table, so
// at the moment this path runs no connection is registered for k. The refresh
// creates exactly one registration, whichever connection performs it, and Reserve
// guarantees only one refresh runs.
//
// What connection choice does still affect is eviction blast radius: an entry is
// attributed to the connection that fetched it (see EvictByConn), so refreshing
// many entries onto one connection concentrates them there. Pinning the refresh to
// the original owner would fix that, but the pool has no "give me connection N"
// operation — withPipelineConn takes whichever is free. Noted as a follow-up; it
// is a robustness question, not a correctness or invalidation-count one.
// cscNoRefreshOnInvalidate force-disables the feature. The gate is
// Options.ClientSideCacheRefreshOnInvalidate; this stays constant false.
const cscNoRefreshOnInvalidate = false
const (
// cscRefreshQueueDepth bounds the pending-refresh backlog. Bounded, and it
// DROPS rather than blocks: the producer is the invalidation drainer, and
// stalling that would delay the invalidations themselves — trading a hit-rate
// optimization for a correctness-critical path. A drop just means the key is
// refetched by whichever reader wants it next, which is today's behaviour.
cscRefreshQueueDepth = 4096
// cscRefreshTargetMaxBytes caps the key bytes (cacheKey + Redis keys) a single
// queued refresh target may retain. The depth cap alone bounds the queue by ITEM
// count, not memory: with large key encodings, up to cscRefreshQueueDepth targets
// could pin far more than the cache's own limit. A target over this cap is dropped
// rather than queued — like a full-queue drop, it just means a later reader does a
// normal miss fetch. Bounds the queue's retained key memory to roughly
// cscRefreshQueueDepth * this.
cscRefreshTargetMaxBytes = 4 << 10 // 4 KiB
// cscRefreshBatchMax caps how many keys ride one round trip.
cscRefreshBatchMax = 128
// cscRefreshReplyBudgetBytes caps one refresh round trip's EXPECTED REPLY
// volume (sum of the evicted values' sizes, cscRefreshTarget.valBytes). The
// key-byte budget bounds only what we write; replies for a chunk's early keys
// stream back while its later keys are still being written, and against a
// flow-controlling middlebox (a proxy or tunnel that stops reading when its
// buffer to us fills — vanilla Redis instead buffers replies in RAM) enough
// in-flight reply bytes stall the connection both ways: the classic pipeline
// write-read deadlock, which here surfaces as the per-chunk deadline failing
// the whole chunk. 32 KiB stays comfortably inside typical kernel socket
// buffering (~64K+64K), so expected replies alone cannot jam the path; a
// value that grew far past its evicted size since invalidation can still
// overshoot — the estimate narrows the window, the deadline stays the
// backstop. The first target of a chunk always goes regardless, so an
// oversized single value cannot stall the loop.
cscRefreshReplyBudgetBytes = 32 << 10
// cscRefreshBatchTimeout bounds one refresh round trip. It caps the batch ctx
// AND, when the user disabled per-op write timeouts, the batch write deadline —
// so a stalled deadline-less flush cannot wedge the refresher (the ctx does not
// interrupt a deadline-less socket write). The two uses must agree, so they
// share this constant.
cscRefreshBatchTimeout = 5 * time.Second
// cscRefreshRecencyTick is how often the "recently read" horizon advances.
cscRefreshRecencyTick = 200 * time.Millisecond
cscRefreshWarnEvery = 30 * time.Second
)
// cscRefreshWindow is the coalescing window. Invalidations are COLLECTED for this
// long before the batch is refetched, instead of firing a round trip as each
// trickle of pushes arrives. The window starts when the first key is collected
// and does NOT slide on later arrivals — under continuous churn a sliding reset
// would never fire and the design would degenerate to the size cap alone. The
// window is flushed early by demand (a reader touching a collected key) or by the
// size cap; whichever comes first. Longer window = fewer, larger round trips (the
// point: it lets refresh work at a small pipeline pool) at the cost of staleness
// bounded by the window for keys nobody reads. A var (not const) so a test can
// take the timer out of the picture.
var cscRefreshWindow = 500 * time.Millisecond
// cscRefreshWindowMaxKeys flushes the window early once this many distinct keys
// have collected, so a burst cannot outrun the window ahead of the queue's own
// bounded backlog. A multiple of the per-round-trip cap.
const cscRefreshWindowMaxKeys = 4 * cscRefreshBatchMax
// cscDemandRefresh gates the demand trigger. On (default): a read for a key still
// sitting in the window flushes the whole window immediately, so an actively-read
// batch refreshes in ~one RTT instead of waiting out the window, while an idle
// batch waits the full window (and nobody is reading it, so the wait is free).
// Off: window + size cap only. On by default.
const cscDemandRefresh = true
// cscRefreshCooldown optionally suppresses a refresh for an entry published within
// this window. DEFAULT 0, meaning off — measurement said to leave it off.
//
// The motivation was real. The same key read on several connections is registered
// by the server once per connection, so ONE write produces one invalidation push
// per registration, and that happens routinely: when a batched read finds another
// fetch already in flight it declines the reservation and goes to the wire anyway
// rather than stall the batch (the miss-coalescer path), registering the key a
// second time. Measured: 34-38% of incoming invalidation pushes match no entry at
// all, which is the signature of exactly this.
//
// But a time window is the wrong instrument, for two reasons.
//
// First, Reserve already dedups the part that costs anything. A duplicate
// invalidation that lands while the refresh is still in flight finds the key
// reserved, is declined, and is dropped for one map operation. Only a duplicate
// arriving AFTER the refresh completed can start a second fetch — which needs the
// refresh to be faster than the gap between duplicates, i.e. loopback, not WAN.
//
// Second, the window cannot tell a duplicate of the write it already handled from a
// genuinely NEW write arriving a few milliseconds later, so it suppresses both. The
// A/B showed the cost of that: at 20k invalidations/s, enabling it cut refresh work
// 36% (289,822 -> 184,780) but LOST throughput (1,347,964 -> 1,326,234) and hit rate
// (93.6% -> 93.0%). Doing the extra work was cheaper than skipping some of the
// useful part.
//
// Left defaulted to 0 (off); measurement said to leave it off.
//
//nolint:unused // deliberately-off, kept as a documented measured knob (see above) for the follow-up that wires cooldown; referencing it now would misrepresent the shipped behavior.
var cscRefreshCooldown time.Duration
// cscRefreshTarget is one entry to re-fetch: the cache key doubles as the wire
// form of the command that produced it, and redisKeys are already namespaced, so
// both can be handed straight back to Reserve.
type cscRefreshTarget struct {
cacheKey string
redisKeys []string
token uint64
// valBytes approximates the refetch reply size: the length of the value the
// invalidation just evicted (captured under the shard lock in
// collectHotAndDelete). The refresher budgets each round trip's EXPECTED
// REPLY bytes with it — the request side is tiny (keys), but replies for the
// chunk's early keys stream back while later keys are still being written,
// and unbounded reply volume is what jams a flow-controlling middlebox
// (proxy/tunnel) into the classic pipeline write-read deadlock. An estimate,
// not a guarantee: the value may have grown since eviction (often why it was
// invalidated), so this narrows the jam window rather than closing it.
valBytes int
// accessNs is the evicted entry's reader-access token (lastAccessNs), captured
// under the shard lock in collectHotAndDelete. The refresh republish restores it
// after fulfill so a background refresh does NOT count as a fresh reader access:
// fulfill stamps a new token, which would keep the key above the refresh horizon
// forever and make each invalidation refresh it again even after all readers stop
// (a self-sustaining refetch loop, contrary to the cold-key guard). Restoring the
// original token means only a real reader read (get, which re-stamps) keeps a key
// eligible for refresh. See restoreAccessToken.
accessNs int64
}
// cscRefreshQueue carries invalidated-but-hot keys from the drainer to the
// refresher.
type cscRefreshQueue struct {
ch chan cscRefreshTarget
// pendingSet mirrors the cache keys currently sitting in the refresher's
// collection window, so the read path (processCached, miss branch) can test
// membership without a lock and signal demand. Written and cleared only by
// the refresher goroutine; read concurrently by every reader — hence sync.Map.
pendingSet sync.Map
// demandCh carries a single "flush now" nudge from a reader that touched a
// pending key, STAMPED with the window generation it belongs to (see demandGen).
// Buffered depth 1 and sent non-blocking: one pending nudge is all the refresher
// needs, and the window timer is the backstop if it is dropped.
demandCh chan uint64
// demandGen tags the current collection window. The refresher bumps it every
// time it clears a window (in flush); signalDemand stamps the value it reads
// BEFORE the pendingSet membership check onto its nudge. A reader that passed the
// check just before a clear then sends AFTER it: its nudge carries the retired
// generation, so the refresher compares it unequal to the current one and ignores
// it — a stale nudge can no longer flush the NEXT window early, which would defeat
// the coalescing window and inflate DemandFlushes.
demandGen atomic.Uint64
// sinceToken is the "recently read" horizon: only entries whose last read is
// newer than this are worth refetching. Without it the refresher would chase
// cold keys, and because a refresh RE-REGISTERS the key with the server, that
// would both pin the server's tracking table and manufacture the next
// invalidation — a feedback loop that generates its own work. Same guard the
// time-based sweeper uses for the same reason.
//
// Holds cscInvalNoHorizon (-1) forever when
// Options.ClientSideCacheRefreshRecencyWindow is unset (the default): every
// Valid entry is then "hot" by construction, and the feedback-loop tradeoff
// above is an accepted, documented default (see the option's doc comment) —
// bound it with the option to restore this guard's original intent.
sinceToken atomic.Int64
// recency holds the multi-tick history backing sinceToken when
// ClientSideCacheRefreshRecencyWindow is set; nil in the default
// refresh-everything mode, where sinceToken is fixed and this is unused.
recency *cscRecencyRing
enqueued atomic.Uint64
dropped atomic.Uint64
refreshed atomic.Uint64
refreshFailed atomic.Uint64
demandFlushes atomic.Uint64
}
// signalDemand nudges the refresher to flush its collection window now, but only
// if cacheKey is actually sitting in that window — i.e. this read is a miss for a
// key whose invalidation we already collected. Called on the read miss path, so
// it must stay cheap: one sync.Map load and, on the rare hit, one non-blocking
// send. The read is already paying a server round trip for its own key; the
// nudge just warms the rest of the co-invalidated batch a window earlier.
func (q *cscRefreshQueue) signalDemand(cacheKey string) {
if q == nil || !cscDemandRefresh {
return
}
// Read the generation BEFORE the membership check, so a window cleared between
// here and the send retires this stamp: the refresher then sees a stale
// generation and ignores the nudge (see demandGen). Reading it AFTER the check
// would stamp the NEW window and flush it early — the bug this guards.
gen := q.demandGen.Load()
if _, ok := q.pendingSet.Load(cacheKey); !ok {
return
}
select {
case q.demandCh <- gen:
default:
}
}
// demandIsCurrent reports whether a demand nudge stamped with gen still refers to
// the window the refresher is collecting now. A nudge from a retired generation
// (its window was already flushed) is ignored, so it cannot flush the next window
// early. Extracted so the generation check is unit-tested (like cscRefreshChunkEnd).
func (q *cscRefreshQueue) demandIsCurrent(gen uint64) bool {
return gen == q.demandGen.Load()
}
// offer enqueues without blocking, counting what it had to drop. A target whose
// key bytes exceed cscRefreshTargetMaxBytes is dropped too, so the item-bounded
// queue cannot pin unbounded key memory (a dropped target is refetched by the next
// reader that wants it).
func (q *cscRefreshQueue) offer(t cscRefreshTarget) {
if cscRefreshTargetBytes(t) > cscRefreshTargetMaxBytes {
q.dropped.Add(1)
return
}
select {
case q.ch <- t:
q.enqueued.Add(1)
default:
q.dropped.Add(1)
}
}
// cscRefreshTargetBytes approximates the key memory a queued target retains: its
// cache key plus every Redis key string.
func cscRefreshTargetBytes(t cscRefreshTarget) int {
n := len(t.cacheKey)
for _, k := range t.redisKeys {
n += len(k)
}
return n
}
// cscRefreshChunkEnd returns the exclusive end index of the next refresh round
// trip starting at `start`: at most cscRefreshBatchMax targets, request bytes
// (cache key sans prefix = wire form) within writeBudget, and expected reply
// bytes (valBytes of the evicted values) within cscRefreshReplyBudgetBytes. The
// first target always goes, so a single oversized key or value cannot stall the
// loop. Pure; unit-tested like fdBatchEnd.
func cscRefreshChunkEnd(targets []cscRefreshTarget, start, prefixLen, writeBudget int) int {
end, reqBytes, replyBytes := start, 0, 0
for end < len(targets) && end-start < cscRefreshBatchMax {
w := len(targets[end].cacheKey) - prefixLen
r := targets[end].valBytes
if end > start && (reqBytes+w > writeBudget || replyBytes+r > cscRefreshReplyBudgetBytes) {
break
}
reqBytes += w
replyBytes += r
end++
}
return end
}
// CSCRefreshStats reports refresh-on-invalidate activity: keys queued, keys
// dropped because the backlog was full, and values actually republished.
//
// Experimental: this API may change in a minor release.
type CSCRefreshStats struct {
Enqueued uint64
Dropped uint64
Refreshed uint64
// RefreshFailed counts refresh round trips that errored (a connection or
// protocol failure during the batch). Those keys stay evicted and a later read
// repopulates them, so a rising count means refresh is degrading to plain
// eviction. Counted per errored batch, not per key.
RefreshFailed uint64
// DemandFlushes counts collection windows flushed early because a reader
// missed a key still sitting in the window (vs flushed by the window timer or
// the size cap). High relative to total flushes means the demand trigger is
// doing its job — actively-read batches refresh in ~one RTT instead of
// waiting out the window.
DemandFlushes uint64
// Invalidations counts keys named in INCOMING invalidation pushes, tallied at
// the handler before dedup/batching. Deletions counts keys the cache actually
// processed for removal; under duplicate pushes Deletions < Invalidations
// because the batcher dedups (and, under an invalidation flood, the spill-cap
// full-Flush supersedes queued deletes wholesale — those count as invalidations
// but not deletions). So Invalidations - Deletions measures dedup + flood
// fallback, not dedup alone. DeletionsNoop counts applied deletes that matched
// no live entry, the direct duplicate-invalidation signature.
Invalidations uint64
Deletions uint64
DeletionsNoop uint64
}
// CSCRefreshStats returns the client's refresh-on-invalidate counters.
//
// SCOPE on a SHARED cache: Invalidations/Deletions/DeletionsNoop are cache-global
// (read from the shared LocalCache, so every client attached to it reports the
// same totals), while Enqueued/Dropped/Refreshed/RefreshFailed/DemandFlushes come
// from THIS client's refresh queue. With a shared cache+processor the active
// refresh binding is the last-attached client's queue, so an earlier client can
// report every invalidation but ~zero refresh work — not a defect, that client
// genuinely is not the one refreshing. Treat a refresh/invalidation RATIO as
// meaningful only for a single-client cache or for the client holding the active
// binding; across a shared cache the two groups are different scopes.
//
// Experimental: this API may change in a minor release.
func (c *Client) CSCRefreshStats() CSCRefreshStats {
var st CSCRefreshStats
// Cache-level counters (Invalidations, Deletions, DeletionsNoop) are recorded
// by the invalidation handler whenever CSC is on, even with
// ClientSideCacheRefreshOnInvalidate off (the default) and only batching
// enabled. Read them independently of the refresh queue, or a batch-only setup
// would always report zero for activity it clearly performs.
if lc, ok := c.baseClient.csc.(*LocalCache); ok {
st.Invalidations = lc.InvalidationStats()
st.Deletions, st.DeletionsNoop = lc.DeletionStats()
}
// The remaining counters exist only when the refresh queue is running.
if q := c.baseClient.cscRefreshQueue; q != nil {
st.Enqueued = q.enqueued.Load()
st.Dropped = q.dropped.Load()
st.Refreshed = q.refreshed.Load()
st.RefreshFailed = q.refreshFailed.Load()
st.DemandFlushes = q.demandFlushes.Load()
}
return st
}
// cscRefreshOnInvalidateEnabled reports whether this client should re-fetch
// invalidated keys in the background.
func (c *baseClient) cscRefreshOnInvalidateEnabled() bool {
if cscNoRefreshOnInvalidate || c.csc == nil || c.opt == nil {
return false
}
if !c.opt.ClientSideCacheRefreshOnInvalidate {
return false
}
// The collect step needs the concrete cache: a Cache implementation that only
// satisfies the interface cannot report which entries it removed.
_, ok := c.csc.(*LocalCache)
return ok
}
// startCSCRefresher launches the refresher goroutine and hands the drainer the
// queue to feed.
func (c *baseClient) startCSCRefresher() {
if !c.cscRefreshOnInvalidateEnabled() || c.cscRefreshQueue != nil {
return
}
// Cheap fast path: if CSC serving is already known inactive, skip building
// anything. Not the safety mechanism on its own — see the workersMu check
// below, which is what actually closes the construction/teardown race.
if a := c.cscActive; a != nil && !a.Load() {
return
}
lc := c.csc.(*LocalCache)
q := &cscRefreshQueue{
ch: make(chan cscRefreshTarget, cscRefreshQueueDepth),
demandCh: make(chan uint64, 1),
}
// Default (window <= 0): refresh every invalidated Valid entry regardless of
// recency. cscInvalNoHorizon (-1) is less than any real lastAccessNs token,
// so it marks everything hot; sinceToken then never changes (see the
// recency-tick case in runCSCRefresher, which no-ops when q.recency is nil).
q.sinceToken.Store(cscInvalNoHorizon)
if w := c.opt.ClientSideCacheRefreshRecencyWindow; w > 0 {
q.recency = newCscRecencyRing(cscRefreshWindowTicks(w))
q.recency.push(lc.LRUClock())
q.sinceToken.Store(q.recency.oldest())
}
h := &cscRevalidateHandle{stop: make(chan struct{}), done: make(chan struct{})}
// publish attaches the queue to the invalidate handler (so pushes start
// feeding it, joining any detached predecessor batcher's stop-drain into it
// first) and launches the goroutine. Extracted so both the guarded and the
// no-drain-handle path below run the exact same sequence.
publish := func() {
c.cscRefreshQueue = q
c.cscRefreshHandle = h
// The invalidate handler is what sees the pushes, so it owns the producer
// end. Join the detached batcher (setRefreshQueue only detaches + signals)
// so the attach is synchronous — the drain has fully applied before the
// goroutine launches below.
if ih := lookupInvalidateHandler(c.opt.PushNotificationProcessor); ih != nil {
if b := ih.setRefreshQueue(q); b != nil {
b.join()
}
}
go c.runCSCRefresher(h, lc, q)
}
// Run publish under the drain handle's lock, checked against
// workersTornDown (see cscDrainHandle, stopCSCRefresherAndCoalescer): a
// concurrent teardown may already have consumed workersStopOnce —
// reachable via the drainer's own self-disable tick reacting to an async
// conn init's disableCSCServing, racing this construction (a bot review
// flagged this). That teardown must not be handed a handle it will never
// get another chance to stop, so decline to publish (and start nothing) if
// it already ran. The WHOLE of publish runs inside the lock, not just the
// field writes: teardown's body sets workersTornDown as its own first
// action under this same lock, so holding it across setRefreshQueue's
// batcher join and the goroutine launch guarantees teardown cannot observe
// a published handle/coalescer whose goroutine isn't fully running yet — a
// second bot review caught that the field-writes-only version still let
// stopCSCRefresher block on a <-h.done that nothing had started yet
// (bounded by however long the join took, not a permanent deadlock, but
// unnecessary and worth just closing). dh is nil only for tests that call
// this directly without a drain handle; there is nothing to race against in
// that case.
//
// Safety of holding workersMu this long: publish only calls internal,
// panic-free code (setRefreshQueue/join, a launch of our own
// runCSCRefresher) — lookupInvalidateHandler's GetHandler is a plain
// registry map lookup, never user code, because CSC requires the built-in
// push processor (a custom one is rejected at init). A panic here would
// leave workersMu held forever and deadlock every later teardown, so this
// only holds because publish cannot panic. Lock order while held:
// workersMu -> (invalidateHandler.mu acquired and released entirely inside
// setRefreshQueue) -> block on the detached batcher's done. Nothing on
// that path re-enters workersMu; keep it that way if either lock's scope
// grows.
if dh := c.cscDrainHandle; dh != nil {
dh.workersMu.Lock()
if !dh.workersTornDown {
publish()
}
dh.workersMu.Unlock()
return
}
publish()
}
func (c *baseClient) runCSCRefresher(h *cscRevalidateHandle, lc *LocalCache, q *cscRefreshQueue) {
defer close(h.done)
recency := time.NewTicker(cscRefreshRecencyTick)
defer recency.Stop()
// The collection window. It starts (timer armed) when the first key of a
// batch is collected and is NOT re-armed on later arrivals — a sliding reset
// would never fire under continuous churn, leaving only the size cap. Flushed
// by the timer, by demand, or by the size cap; whichever fires first.
window := time.NewTimer(cscRefreshWindow)
defer window.Stop()
if !window.Stop() {
<-window.C
}
windowArmed := false
// pending is keyed by cache key so a key invalidated twice within one window
// is refetched once. Owned solely by this goroutine.
pending := make(map[string]cscRefreshTarget, cscRefreshWindowMaxKeys)
var lastWarn time.Time
collect := func(t cscRefreshTarget) {
if _, dup := pending[t.cacheKey]; dup {
return
}
pending[t.cacheKey] = t
q.pendingSet.Store(t.cacheKey, struct{}{})
if !windowArmed {
windowArmed = true
window.Reset(cscRefreshWindow)
}
}
// flush applies the collected window. When stopping is true (Close's
// stop-drain) it skips the refetch below entirely instead of running it:
// stopCSCRefresherAndCoalescer's teardown (step 2 doc, invalidateAllCoverage,
// csc_integration.go:1305-1316) evicts every entry attributed to this
// client's refresh connection right after this call returns, regardless of
// whether the refetch would have succeeded — so running it first only spends
// network round trips on a result that is discarded a moment later. Against a
// healthy server that cost used to go unbounded: nothing here ever fails, so
// the previous "bail after the first failed chunk" bound (#3965 F2) never
// triggered, and a full backlog could cost thousands of round trips — one per
// chunk once the reply-byte budget shrinks each chunk to a single target
// (codex #3989 P1). The abandoned targets stay evicted; a reader repopulates
// them normally, the same outcome a failed refetch already produced. The
// normal (non-stopping) path is unchanged: it refetches every chunk, only
// aborting if Close begins while it's still running (see the h.stop check
// below).
flush := func(demand, stopping bool) {
if windowArmed {
windowArmed = false
if !window.Stop() {
// Drain a possibly-already-fired timer so the next Reset is clean.
select {
case <-window.C:
default:
}
}
}
if len(pending) == 0 {
return
}
if demand {
q.demandFlushes.Add(1)
}
// Snapshot to a slice and clear the collection state before any network
// I/O, so keys invalidated during the refetch start a fresh window rather
// than being lost or double-counted.
targets := make([]cscRefreshTarget, 0, len(pending))
for k, t := range pending {
targets = append(targets, t)
q.pendingSet.Delete(k)
delete(pending, k)
}
// Retire this window's generation: a demand nudge stamped with it (a reader
// that passed signalDemand's pendingSet check just before the clear above,
// then sends AFTER it) now compares unequal to the current generation and is
// ignored by the demandCh case — a stale nudge can no longer flush the NEXT
// window early, defeating the coalescing window and inflating DemandFlushes.
q.demandGen.Add(1)
// Then drop any nudge already buffered for THIS just-retired window: collect
// runs only on this goroutine, so nothing can have stamped the new generation
// yet, making a buffered value provably stale. Draining keeps the depth-1
// buffer clear for the next window's first legit nudge (the generation check
// above is the correctness guard; this stays buffer hygiene).
select {
case <-q.demandCh:
default:
}
if stopping {
// Abandoned by choice, not by network failure: leave RefreshFailed (one
// count per errored ROUND TRIP, per its doc) alone. These targets simply
// stay evicted, same as an errored refetch's targets already do.
return
}
// A window can hold more than one round trip's worth; chunk it. Keys that
// self-healed during the window (a reader missed and repopulated them) are
// now Valid, so Reserve inside refreshInvalidatedBatch declines them for
// free — no MGET slot is spent rewriting fresh data.
// Recover inside a closure so a panic in refreshInvalidatedBatch (cache/RESP/
// network path) does not kill the refresher goroutine — which would silently
// and permanently degrade refresh-on-invalidate to plain eviction for the
// client's lifetime. Mirrors the invalidation batcher's flush guard. The
// per-round-trip deadline is created PER CHUNK below, not once for the whole
// window, so a slow early chunk cannot expire later chunks' own budget.
func() {
defer func() {
if r := recover(); r != nil {
q.refreshFailed.Add(1)
internal.Logger.Printf(context.Background(),
"csc: refresh-on-invalidate batch panic (recovered): %v", r)
}
}()
// Chunk by count (cscRefreshBatchMax), by serialized REQUEST bytes (the
// write buffer), and by expected REPLY bytes (cscRefreshReplyBudgetBytes —
// see its doc: the reply side, not the request side, is what jams a
// flow-controlled path). Like the miss-coalescer, the refresh writes the
// whole chunk before it reads the replies. The first target in a chunk
// always goes, even if it alone exceeds a budget, so the loop always makes
// progress. Boundary logic extracted pure (cscRefreshChunkEnd) and
// unit-tested.
writeBudget := cscMissWriteBatchBytes(c.opt)
prefixLen := len(c.cscKeyPrefix)
for start := 0; start < len(targets); {
// Abort this (always non-stopping — see the stopping check above) flush
// if Close begins while it's still running: continuing to give each
// remaining chunk a fresh cscRefreshBatchTimeout against a dead/stalled
// server would delay Close by minutes (with reply budgeting producing
// one-target chunks, ~43 min for a full window). This covers a flush that
// was ENTERED before Close and is still running when h.stop closes (#3965
// F2 follow-up); the stop-drain's OWN flush call never reaches here at all
// now. Bailed targets stay evicted — a reader repopulates them and this
// client's coverage is revoked on Close anyway.
select {
case <-h.stop:
return
default:
}
end := cscRefreshChunkEnd(targets, start, prefixLen, writeBudget)
// Per-chunk deadline: each round trip gets its own cscRefreshBatchTimeout,
// so aggregate latency across chunks cannot expire the later chunks (#3989).
rctx, rcancel := context.WithTimeout(context.Background(), cscRefreshBatchTimeout)
n, err := c.refreshInvalidatedBatch(rctx, targets[start:end])
rcancel()
q.refreshed.Add(uint64(n))
if err != nil {
// One count per errored round trip: those keys were not refreshed and
// stay evicted (a reader repopulates them). This is the signal that
// refresh is degrading to plain eviction.
q.refreshFailed.Add(1)
if time.Since(lastWarn) > cscRefreshWarnEvery {
lastWarn = time.Now()
internal.Logger.Printf(context.Background(),
"csc: refresh-on-invalidate batch failed: %v", err)
}
}
start = end
}
}()
}
// drainQueue moves targets already waiting in q.ch into the window without
// blocking, up to the window cap. Returns true when q.ch was empty (nothing
// more to take), false when it stopped at the cap with the channel possibly
// still holding more.
drainQueue := func() (empty bool) {
for len(pending) < cscRefreshWindowMaxKeys {
select {
case more := <-q.ch:
collect(more)
default:
return true
}
}
return false
}
for {
select {
case <-h.stop:
// Pull everything buffered in q.ch (offered but not yet collected) and
// hand it to flush, which (stopping==true) abandons it without a refetch
// — see flush's doc: a refetch here would only be discarded by the
// teardown that runs right after this join anyway. stopCSCRefresher
// rebinds the handler away from this queue BEFORE signalling stop, so
// nothing new arrives here, making this loop finite. Loop until q.ch is
// fully drained rather than stopping after one window-cap-sized batch:
// c.cscRefreshQueue is never nilled on stop (see stopCSCRefresher), so a
// caller that keeps a reference to the closed *Client would otherwise
// keep every undrained target's key bytes reachable through it — up to
// cscRefreshQueueDepth entries at cscRefreshTargetMaxBytes each. Cheap
// now that flush does no network I/O: at most
// cscRefreshQueueDepth/cscRefreshWindowMaxKeys rounds.
for {
empty := drainQueue()
flush(false, true)
if empty {
return
}
}
case <-recency.C:
// Advance the horizon: entries not read within the configured window
// stop being worth refetching. Default (q.recency nil, window
// unset): sinceToken is fixed at cscInvalNoHorizon — refresh
// everything, forever — so there is nothing to advance.
if q.recency != nil {
q.recency.push(lc.LRUClock())
q.sinceToken.Store(q.recency.oldest())
}
case t := <-q.ch:
collect(t)
// Opportunistically take whatever else is already waiting so one wake
// drains the backlog into the window.
drainQueue()
if len(pending) >= cscRefreshWindowMaxKeys {
flush(false, false)
}
case g := <-q.demandCh:
// A reader missed a key still in the window: the batch is being used, so
// refetch it now instead of waiting out the window. Ignore a nudge stamped
// with a retired generation — its window was already flushed, and acting on
// it would flush the current (unrelated) window early (see signalDemand).
if q.demandIsCurrent(g) {
flush(true, false)
}
case <-window.C:
flush(false, false)
}
}
}
// stopCSCRefresher joins the refresher goroutine.
func (c *baseClient) stopCSCRefresher() {
h := c.cscRefreshHandle
if h == nil {
return
}
c.cscRefreshHandle = nil
// Clear only OUR binding: a sibling client sharing this cache/processor may
// have re-attached its own queue after ours, and an unconditional nil here
// would sever the survivor's refresher (see clearRefreshQueue).
if ih := lookupInvalidateHandler(c.opt.PushNotificationProcessor); ih != nil {
// Join the detached batcher OUTSIDE h.mu (clearRefreshQueue only detaches +
// signals): the Close path needs a synchronous, no-straggler teardown, and
// joining under the handler lock would stall a sibling on the hot path.
if b := ih.clearRefreshQueue(c.cscRefreshQueue); b != nil {
b.join()
}
}
h.signalStop()
<-h.done
}
// cscRefreshReplyCacheable reports whether a refetched raw reply may be published
// as a fresh cache entry. It classifies the FULL reply (like the coalescer's
// classifyCachedReply), not raw[0]: a RESP3 attribute-prefixed error leads with
// RespAttr ('|'), so a first-byte error check would treat an attributed error as
// cacheable and publish it as a false success (bumping Refreshed; the next reader
// would then have to evict and refetch). A nil reply IS cacheable (a negative
// lookup, or a since-deleted key caching as missing); an empty reply never is.
// Extracted pure so the classification is unit-tested (like cscRefreshChunkEnd).
func cscRefreshReplyCacheable(raw []byte) bool {
return len(raw) > 0 && isCacheableReplyResult(classifyCachedReply(raw))
}
// refreshInvalidatedBatch re-fetches one chunk of invalidated keys in a single
// round trip and publishes each reply as a fresh entry. Returns how many entries
// it published.
//
// Every reservation this takes is either published or released before returning.
// An abandoned reservation is worse than no refresh at all: LocalCache.Get WAITS
// on an in-progress entry, so one orphan blocks every reader of that key until
// StaleTimeout. (Measured elsewhere in this work: that mistake cost 30x and 8500x
// throughput in the intra-batch rewrites.)
func (c *baseClient) refreshInvalidatedBatch(ctx context.Context, targets []cscRefreshTarget) (int, error) {
prefix := c.cscKeyPrefix
if prefix == "" {
return 0, nil
}
if a := c.cscActive; a != nil && !a.Load() {
return 0, nil
}
// Reserve before touching the network, so only keys this batch owns are sent.
// A declined reservation means a reader is already fetching it — leave it to
// them rather than duplicating the work.
kept := make([]cscRefreshTarget, 0, len(targets))
// Arm the cancellation defer BEFORE the reservation loop. A user CacheSizer can
// panic inside Reserve; the outer refresher recovery keeps the worker alive, so
// without the defer already installed the targets reserved so far would stay
// IN_PROGRESS and block every reader of those keys until StaleTimeout (#3989). Any
// reservation still holding a token when this returns OR unwinds was never settled;
// release it rather than leave a placeholder readers would block on.
defer func() {
for i := range kept {
if kept[i].token != 0 {
c.csc.Cancel(kept[i].cacheKey, kept[i].token)
}
}
}()
for _, t := range targets {
token, shouldFetch := c.csc.Reserve(t.cacheKey, t.redisKeys)
// token==0 with shouldFetch==true is Reserve's "fetch uncached" signal
// (oversized entry / over-capacity / lost race), not an owned reservation.
// Skipping it avoids spending an MGET slot on a reply we'd discard and, more
// importantly, registering server-side tracking for a key we never cache
// (which would manufacture one spurious future invalidation).
if !shouldFetch || token == 0 {
continue
}
t.token = token
kept = append(kept, t)
}
if len(kept) == 0 {
return 0, nil
}
published := 0
// Publish on the MAIN pool (tracked). On this base the dedicated pipeline pool
// is deliberately EXCLUDED from CLIENT TRACKING (PR #3959), so a refetch
// published via withPipelineConn would be un-invalidatable and serve stale
// until TTL. withConn lands on a CLIENT TRACKING ON connection, same as the
// miss-coalescer.
err := c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
connID := cn.GetID()
// Coverage generation captured BEFORE the reads: if this conn loses
// tracking mid-batch, every reply is discarded rather than published, the
// same discipline fulfillCached and revalidateBatch use.
capturedGen := c.cscConnInitGen(connID)
// Pass the refresher's internally-bounded ctx (5s) DIRECTLY, not
// c.context(ctx): c.context applies the user's ContextTimeoutEnabled policy,
// which — when disabled (the default) — swaps our ctx for context.Background,
// stripping our own deadline. With ReadTimeout/WriteTimeout also disabled
// (-1/-2) a stalled Redis would then hang this reader forever, and Close
// (stopCSCRefresher waits on the refresher goroutine) would wedge. The
// miss-coalescer passes its session ctx directly for the same reason.
// Bound the write even when per-op timeouts are disabled. The refresh writes
// the whole chunk before it reads, so a deadline-less flush the server cannot
// drain (transport backpressure) would wedge the refresher: the 5s ctx does
// not interrupt a deadline-less socket write. A positive timeout makes
// WithWriter set a write deadline (still capped by the 5s ctx), so a stalled
// flush fails the refresh and the entries degrade to plain eviction
// (self-healing) instead of hanging the goroutine.
writeTimeout := c.opt.WriteTimeout
if writeTimeout <= 0 {
writeTimeout = cscRefreshBatchTimeout
}
if err := cn.WithWriter(ctx, writeTimeout, func(wr *proto.Writer) error {
for i := range kept {
// The cache key is the namespaced RESP encoding of the command that
// produced the entry; strip the namespace and it is already wire form.
if _, err := wr.Write([]byte(kept[i].cacheKey[len(prefix):])); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
// Bound the read the same way as the write above: with per-op timeouts
// disabled WithReader skips SetReadDeadline (options.go maps -2 to -1, which
// WithReader treats as no deadline), so a stalled reply or push drain would
// park refreshInvalidatedBatch forever — and stopCSCRefresher waits on this
// goroutine, so Client.Close would never return. A positive deadline (still
// capped by the 5s ctx) turns that into a timeout that fails the refresh.
readTimeout := c.opt.ReadTimeout
if readTimeout <= 0 {
readTimeout = cscRefreshBatchTimeout
}
return cn.WithReader(ctx, readTimeout, func(rd *proto.Reader) error {
for i := range kept {
// Invalidation pushes share this connection with replies, so drain
// them first or a push frame would be read as a value and cached.
// Use the nonblocking Close adapter (like the miss-coalescer and
// background drainer): this reader is part of the refresher's
// waitgroup, so a custom push handler calling Close() on the raw
// client would self-deadlock (Close waits on the very goroutine
// parked in the handler). And PROPAGATE a processor error instead of
// logging and continuing: a surfaced error means bytes may have been
// consumed mid-frame, so reading the next reply on a desynced stream
// could publish a push fragment under the wrong cache key — abort so
// withConn retires the connection.
if c.opt.Protocol == 3 && c.pushProcessor != nil {
// Route through the shared helper in BLOCKING mode: block on the
// socket and skip pushes until the refetch reply is the next frame, so
// a second invalidation still on the socket ahead of the reply is not
// read by ReadRawReply below and published under the wrong cache key.
// (The Buffered variant stopped at buffer-empty and let such a
// socket-pending push through.) A custom processor is handed only a
// confirmed push frame (peek first); PeekReplyType is attribute-aware,
// so a fragmented attribute prefix is handled without a separate
// buffered scan. The helper sets no read deadline, so this reader's
// ReadRawReply is unaffected.
if err := c.drainPushFrames(ctx, cn, rd, true); err != nil {
internal.Logger.Printf(ctx, "csc: refresh push drain: %v", err)
return err
}
}
raw, err := rd.ReadRawReply()
if err != nil {
// The reply stream is now out of step with kept; abort so the
// connection is retired rather than reused mid-batch. The deferred
// release cancels every reservation still outstanding.
return err
}
if !cscRefreshReplyCacheable(raw) {
// Not cacheable (WRONGTYPE after a type change, NOPERM, ...). A nil
// reply is NOT an error: a negative lookup is cacheable, and a key
// that has since been deleted should cache as missing. See
// cscRefreshReplyCacheable: it classifies the FULL frame, so a RESP3
// attribute-prefixed error is not mis-read as cacheable.
c.csc.Cancel(kept[i].cacheKey, kept[i].token)
kept[i].token = 0
continue
}
fc := &cscFetchCapture{
raw: raw,
connID: connID,
initGen: capturedGen,
key: kept[i].cacheKey,
token: kept[i].token,
}
if c.fulfillCached(kept[i].cacheKey, kept[i].token, fc) {
published++
// Undo the fresh access token fulfill stamped: a background refresh is
// not a reader read, so it must not renew the key's refresh eligibility,
// or each invalidation would keep refreshing it after all readers stop
// (see restoreAccessToken). Refresh runs only with the built-in
// *LocalCache; the hook path fulfills that same cache.
if lc, ok := c.csc.(*LocalCache); ok {
lc.restoreAccessToken(kept[i].cacheKey, kept[i].accessNs)
}
}
// fulfillCached cancels on its own failure paths, so the token is
// settled either way.
kept[i].token = 0
}
return nil
})
})
return published, err
}
package redis
import (
"sync"
"time"
)
// Support shims for refresh-on-invalidate and reader-miss coalescing, kept in
// one file so the feature is a clean addition over the CSC base.
// cscRevalidateHandle is the stop/join handle for a background CSC goroutine.
type cscRevalidateHandle struct {
stop chan struct{}
done chan struct{}
stopOnce sync.Once
}
// signalStop closes stop at most once (so stopCSCRefresher and the AddCleanup
// safety net cannot double-close) and does not join — a GC cleanup must not block.
func (h *cscRevalidateHandle) signalStop() {
h.stopOnce.Do(func() { close(h.stop) })
}
// LRUClock returns the current global recency token; the refresher uses it as
// the "recently read" horizon.
func (c *LocalCache) LRUClock() int64 { return lruSequence.Load() }
// cscRecencyRing holds the last N per-tick LRUClock() snapshots, giving
// startCSCRefresher's sinceToken horizon a bounded multi-tick lookback
// instead of the single most-recent tick, so
// Options.ClientSideCacheRefreshRecencyWindow can span more than one
// cscRefreshRecencyTick. oldest() is the horizon: once the ring has filled,
// an entry read since oldest() was captured is guaranteed to fall within the
// configured window, regardless of where an invalidation lands relative to
// the tick phase (see the ring-sizing comment in startCSCRefresher). Owned
// solely by the refresher goroutine — push/oldest are not called
// concurrently, so no lock.
type cscRecencyRing struct {
buf []int64
pos int
n int
}
// cscRecencyRingMaxSize caps the ring allocation newCscRecencyRing builds. At
// the 200ms tick this is ~58 hours of coverage — a pathological
// ClientSideCacheRefreshRecencyWindow (a duration typo, or math.MaxInt64)
// degrades to that cap instead of an oversized or panicking make([]int64, N).
const cscRecencyRingMaxSize = 1 << 20
// newCscRecencyRing builds a ring of the given size, clamped to
// [1, cscRecencyRingMaxSize].
func newCscRecencyRing(size int) *cscRecencyRing {
if size < 1 {
size = 1
}
if size > cscRecencyRingMaxSize {
size = cscRecencyRingMaxSize
}
return &cscRecencyRing{buf: make([]int64, size)}
}
// cscRefreshWindowTicks converts a requested
// Options.ClientSideCacheRefreshRecencyWindow into a cscRecencyRing size.
// Ring size N gives a GUARANTEED-minimum lookback of (N-1)*cscRefreshRecencyTick
// once the ring has filled (oldest() is then N-1 ticks behind the latest
// push) — the tick phase relative to an arbitrary invalidation can cost up to
// one full tick of coverage, so N-1 must itself already be >= ceil(w/tick)
// for the enforced window to never fall short of w. Hence N = ceil(w/tick)+1,
// giving an enforced window in [w, w+tick). Extracted pure so the rounding is
// unit-tested; w<=0 (unbounded mode) never reaches this function.
func cscRefreshWindowTicks(w time.Duration) int {
ticks := int(w / cscRefreshRecencyTick)
if w%cscRefreshRecencyTick != 0 {
ticks++
}
return ticks + 1
}
// push records the latest per-tick snapshot, evicting the oldest once full.
func (r *cscRecencyRing) push(v int64) {
r.buf[r.pos] = v
r.pos = (r.pos + 1) % len(r.buf)
if r.n < len(r.buf) {
r.n++
}
}
// oldest returns the least-recent snapshot currently held. Before the ring
// fills (just after the refresher starts), it returns the earliest snapshot
// taken so far, so every access since the refresher started counts as hot
// until the configured window has had time to mature. Once full, r.pos
// always points at the slot about to be overwritten next — the oldest value
// held — a standard circular-buffer property.
func (r *cscRecencyRing) oldest() int64 {
if r.n < len(r.buf) {
return r.buf[0]
}
return r.buf[r.pos]
}
// InvalidationStats reports INCOMING invalidation pushes: the count of keys named
// in server invalidation messages, tallied at the handler before dedup/batching.
func (c *LocalCache) InvalidationStats() (invalidations uint64) {
return c.invalidations.Load()
}
// DeletionStats reports APPLIED invalidations. deletions counts keys the cache
// actually processed for removal (post-dedup, so <= InvalidationStats under
// duplicate pushes); noop counts those that removed no live entry — a key that
// matched nothing, or one whose only match was skipped by the fetch-order guard
// because it was refetched after the invalidation (see collectHotAndDelete).
// Both are the duplicate/stale-invalidation signature.
func (c *LocalCache) DeletionStats() (deletions, noop uint64) {
return c.deletions.Load(), c.deletionsNoop.Load()
}
// deleteByRedisKeyCollectingHot deletes every cache entry tracked under redisKey
// and returns those that were VALID and read since sinceToken, as refetch
// targets. Delete and collect happen under one shard lock: the entry's cache key
// and recency are known only there, and it is about to be removed. fetchSnap is
// the cscFetchSeq value observed when this invalidation arrived; a Valid entry
// whose fetch was ISSUED after that (entry.fetchSeq > fetchSnap) is kept — it was
// refetched after the write, so evicting it would be a spurious miss (see
// cacheEntry.fetchSeq).
func (c *LocalCache) deleteByRedisKeyCollectingHot(redisKey string, sinceToken int64, fetchSnap uint64, dst []cscRefreshTarget) []cscRefreshTarget {
removed := 0
for i := range c.shards {
var n int
dst, n = c.shards[i].collectHotAndDelete(redisKey, sinceToken, fetchSnap, dst)
removed += n
}
// Applied-delete accounting (refresh-on path). Twin of DeleteByRedisKey; the
// incoming push was already counted at the handler.
c.deletions.Add(1)
if removed == 0 {
c.deletionsNoop.Add(1)
}
return dst
}
func (s *cacheShard) collectHotAndDelete(redisKey string, sinceToken int64, fetchSnap uint64, dst []cscRefreshTarget) ([]cscRefreshTarget, int) {
s.mu.Lock()
defer s.mu.Unlock()
cacheKeys, ok := s.byRedisKey[redisKey]
if !ok {
return dst, 0
}
toRemove := make([]string, 0, len(cacheKeys))
for cacheKey := range cacheKeys {
toRemove = append(toRemove, cacheKey)
}
removed := 0
for _, cacheKey := range toRemove {
entry, exists := s.entries[cacheKey]
// Fetch-order guard: an entry whose fetch was ISSUED after this invalidation
// was observed (fetchSeq > fetchSnap) cannot hold the pre-write value, so keep
// it — whether already Valid or still in-progress. Evicting a Valid one would
// be a spurious miss (and a stale queued duplicate straddling a refetch must
// not undo the fresh value, #3965); canceling an in-progress one would fail its
// racing Fulfill, wake every waiter as a miss, and defeat miss coalescing. An
// entry reserved at or before the observe (fetchSeq <= fetchSnap) may predate
// the write — possibly fetched on a different stream (see deleteByRedisKey) —
// so it is removed, and any racing Fulfill for it correctly fails.
if exists && entry.fetchSeq > fetchSnap {
continue
}
if exists && entry.state == cacheEntryValid && entry.lastAccessNs.Load() > sinceToken {
keys := make([]string, len(entry.redisKeys))
copy(keys, entry.redisKeys)
dst = append(dst, cscRefreshTarget{
cacheKey: cacheKey,
redisKeys: keys,
// Preserve the reader-access token so the refresh republish can restore it
// and not renew demand (see cscRefreshTarget.accessNs / restoreAccessToken).
// Re-load is safe: the shard lock is held, so no writer intervenes.
accessNs: entry.lastAccessNs.Load(),
// The dying entry's payload size approximates the refetch REPLY size —
// the refresher chunks round trips by expected reply bytes with it (see
// cscRefreshChunkEnd). Known for free here, under the same shard lock.
valBytes: len(entry.value),
})
}
if s.removeEntryLocked(cacheKey) {
removed++
}
}
return dst, removed
}
package redis
// CSCStats reports cumulative client-side cache activity and current
// residency.
//
// Experimental: this API may change in a minor release.
type CSCStats struct {
Hits uint64
Misses uint64
Entries int
MemoryUsageBytes int64
}
// cacheStatsReporter is an optional interface a Cache implementation may
// satisfy to expose statistics. The built-in LocalCache does; user
// implementations are not required to.
type cacheStatsReporter interface {
Stats() CSCStats
}
// CSCStats returns statistics for this client's client-side cache, read from
// the shared cache when its implementation exposes them (the built-in
// LocalCache does).
//
// It returns a zero value when CSC is not configured or stats are unavailable.
//
// Experimental: this API may change in a minor release.
func (c *Client) CSCStats() CSCStats {
if c == nil || c.baseClient.csc == nil {
return CSCStats{}
}
if r, ok := c.baseClient.csc.(cacheStatsReporter); ok {
return r.Stats()
}
return CSCStats{}
}
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)
}
}
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
}
// Dial errors mean TCP connection was never established — safe to retry even
// when wrapped inside context.DeadlineExceeded (from DialTimeout context).
// Must be checked before the context error check below.
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return true
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// 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 {
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
}
// Other server errors are not retried. This includes the logical
// -SEARCH_TIMEOUT (search-on-timeout fail): retrying would just repeat the
// same expensive query.
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
}
//go:build gofuzz
// +build gofuzz
package fuzz
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
const (
minDataLength = 4
redisAddr = ":6379"
dialTimeout = 10 * time.Second
readTimeout = 10 * time.Second
writeTimeout = 10 * time.Second
poolSize = 10
poolTimeout = 10 * time.Second
scanCount = 10
maxIterPercentage = 256 // Use first byte as percentage of data length
)
var (
ctx = context.Background()
rdb *redis.Client
)
type redisOperation func(key, value string)
func init() {
rdb = redis.NewClient(&redis.Options{
Addr: redisAddr,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
PoolSize: poolSize,
PoolTimeout: poolTimeout,
})
}
func Fuzz(data []byte) int {
if len(data) < minDataLength {
return -1
}
maxIter := (int(data[0]) * len(data)) / maxIterPercentage
if maxIter == 0 {
maxIter = 1 // Ensure at least one iteration
}
operations := []redisOperation{
func(key, value string) { rdb.Set(ctx, key, value, 0).Err() },
func(key, value string) { rdb.Get(ctx, key).Result() },
func(key, value string) { rdb.Incr(ctx, key).Result() },
func(key, value string) {
var cursor uint64
rdb.Scan(ctx, cursor, key, scanCount).Result()
},
}
dataStr := string(data)
for i := 0; i < maxIter && i < len(data); i++ {
start := i % len(data)
end := (i + 1) % len(data)
if end <= start {
end = len(data)
}
key := dataStr[start:end]
opIndex := i % len(operations)
operations[opIndex](key, dataStr)
}
return 1
}
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
}
// ExpireTime returns the absolute expiration time of key as a Unix timestamp
// encoded in *DurationCmd (seconds since the epoch), not a remaining TTL.
// Convert with: time.Unix(int64(d/time.Second), 0). Use TTL/PTTL for remaining TTL.
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
}
// PExpireTime returns the absolute expiration time of key as a Unix timestamp
// encoded in *DurationCmd (milliseconds since the epoch), not a remaining TTL.
// Convert with: time.UnixMilli(int64(d/time.Millisecond)). Use TTL/PTTL for remaining TTL.
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
}
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)
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
}
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
// note: the HIMPORT API is experimental and may be subject to change.
HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd
HImportSet(ctx context.Context, key, fieldsetName string, values ...interface{}) *StatusCmd
HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd
HImportDiscardAll(ctx context.Context) *IntCmd
}
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
}
package redis
import (
"context"
"strings"
"sync"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
)
// himportFieldset is the client-side record of a fieldset registered with
// HImportPrepare.
type himportFieldset struct {
fields []string
version uint64
}
// himportRegistry remembers fieldsets registered through a client so HIMPORT
// SET can lazily prepare them on whichever pooled connection it executes.
// Versions increase monotonically and start at 1; re-registering a name under
// a new version invalidates every connection's prepared flag for it, so a
// replaced fieldset is re-prepared before its next use.
//
// Discards propagate lazily as well: a discarded name is kept as a tombstone
// and the discard-all counter as an epoch, and connections whose sessions
// still hold discarded fieldsets replay HIMPORT DISCARD/DISCARDALL before
// their next HIMPORT command (see baseClient.himportInjectedCmds).
type himportRegistry struct {
mu sync.RWMutex
nextVersion uint64
fieldsets map[string]himportFieldset
// tombstones holds names discarded through this client whose server-side
// copies may survive on pooled connections that prepared them. An entry
// is removed when the name is registered again (the new version replaces
// the fieldset on the server, so no discard is needed) or by discardAll.
// Known limitation: a workload discarding many uniquely-named fieldsets
// grows this map for the client's lifetime and pays an O(tombstones)
// snapshot per HIMPORT round trip; HImportDiscardAll resets it.
tombstones map[string]struct{}
// discardAllEpoch increments on every successful HImportDiscardAll.
discardAllEpoch uint64
}
func newHImportRegistry() *himportRegistry {
return &himportRegistry{}
}
// register stores the fieldset and returns its new version together with the
// current discard-all epoch.
func (r *himportRegistry) register(name string, fields []string) (version, epoch uint64) {
r.mu.Lock()
defer r.mu.Unlock()
if r.fieldsets == nil {
r.fieldsets = make(map[string]himportFieldset)
}
delete(r.tombstones, name)
r.nextVersion++
r.fieldsets[name] = himportFieldset{
fields: append([]string(nil), fields...),
version: r.nextVersion,
}
return r.nextVersion, r.discardAllEpoch
}
func (r *himportRegistry) lookup(name string) (himportFieldset, bool) {
if r == nil {
return himportFieldset{}, false
}
r.mu.RLock()
fs, ok := r.fieldsets[name]
r.mu.RUnlock()
return fs, ok
}
// discard removes the fieldset and leaves a tombstone so connections whose
// sessions still hold it replay the DISCARD before their next HIMPORT
// command. It reports whether the fieldset was registered.
func (r *himportRegistry) discard(name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.fieldsets[name]; !ok {
return false
}
delete(r.fieldsets, name)
if r.tombstones == nil {
r.tombstones = make(map[string]struct{})
}
r.tombstones[name] = struct{}{}
return true
}
// discardAll drops every fieldset and tombstone and moves to a new epoch;
// connections prepared under an older epoch replay HIMPORT DISCARDALL before
// their next HIMPORT command. It returns the new epoch and the number of
// fieldsets that were registered.
func (r *himportRegistry) discardAll() (epoch uint64, removed int) {
r.mu.Lock()
removed = len(r.fieldsets)
r.fieldsets = nil
r.tombstones = nil
r.discardAllEpoch++
epoch = r.discardAllEpoch
r.mu.Unlock()
return epoch, removed
}
// discardVersion withdraws a registration whose fan-out PREPARE was rejected
// by a server — but only while the entry is still at that version, so a
// concurrent re-registration is not clobbered. A tombstone is left: the
// fan-out may have succeeded on some masters before another rejected it
// (per-node ACLs, rolling upgrades), and those sessions hold the withdrawn
// fieldset; the tombstone makes their next HIMPORT command discard it
// instead of leaving a fieldset the client can no longer address.
func (r *himportRegistry) discardVersion(name string, version uint64) {
r.mu.Lock()
if fs, ok := r.fieldsets[name]; ok && fs.version == version {
delete(r.fieldsets, name)
if r.tombstones == nil {
r.tombstones = make(map[string]struct{})
}
r.tombstones[name] = struct{}{}
}
r.mu.Unlock()
}
// refreshVersion bumps a registered fieldset to a new version, keeping its
// fields — but only while the entry is still at the given version, so a
// concurrent re-registration is not disturbed. Every connection's prepared
// flag becomes stale, forcing a re-prepare before the fieldset's next use on
// each of them. Used when a "no such fieldset" reply signals session loss
// that may have hit more connections than the one that reported it (failover,
// cross-region switch, reset storms).
func (r *himportRegistry) refreshVersion(name string, version uint64) {
r.mu.Lock()
if fs, ok := r.fieldsets[name]; ok && fs.version == version {
r.nextVersion++
fs.version = r.nextVersion
r.fieldsets[name] = fs
}
r.mu.Unlock()
}
// idle reports whether the registry implies no injection work at all: no
// fieldsets to replay, no tombstones to discard, and no discard-all epoch a
// session could be behind.
func (r *himportRegistry) idle() bool {
if r == nil {
return true
}
r.mu.RLock()
idle := len(r.fieldsets) == 0 && len(r.tombstones) == 0 && r.discardAllEpoch == 0
r.mu.RUnlock()
return idle
}
// cleanupSnapshot returns the current epoch and the tombstoned names.
func (r *himportRegistry) cleanupSnapshot() (epoch uint64, tombstones []string) {
r.mu.RLock()
epoch = r.discardAllEpoch
if len(r.tombstones) > 0 {
tombstones = make([]string, 0, len(r.tombstones))
for name := range r.tombstones {
tombstones = append(tombstones, name)
}
}
r.mu.RUnlock()
return epoch, tombstones
}
// himportNoSuchFieldset reports whether err is the server's "no such
// fieldset" reply, i.e. an HIMPORT SET executed on a connection whose session
// does not hold the referenced fieldset.
func himportNoSuchFieldset(err error) bool {
return isRedisError(err) && strings.Contains(err.Error(), "no such fieldset")
}
// himportInjectedCmds returns the HIMPORT commands to write to cn ahead of a
// batch, in order:
//
// 1. HIMPORT DISCARDALL when cn's session was prepared under an older
// discard-all epoch;
// 2. HIMPORT DISCARD for each discarded fieldset the session still holds;
// 3. HIMPORT PREPARE for each registered fieldset referenced by an HIMPORT
// SET in the batch that the session lacks at the current version.
//
// A fieldset covered by a user-issued PREPARE earlier in the batch needs no
// injection — the server session holds it by the time the SET runs. Returns
// nil when the batch contains no HIMPORT commands: sessions holding only
// discarded fieldsets are cleaned up on their next HIMPORT use, not on
// unrelated traffic.
func (c *baseClient) himportInjectedCmds(ctx context.Context, cn *pool.Conn, cmds []Cmder) []Cmder {
if c.himport.idle() {
return nil
}
hasHImport := false
for _, cmd := range cmds {
if _, ok := cmd.(himportCmder); ok {
hasHImport = true
break
}
}
if !hasHImport {
return nil
}
var injected []Cmder
// Discards first: a session behind the discard-all epoch is wiped
// entirely; otherwise individual tombstoned fieldsets it still holds are
// discarded.
epoch, tombstones := c.himport.cleanupSnapshot()
sessionWiped := false
if cn.HasPreparedFieldsets() && cn.FieldsetEpoch() != epoch {
da := NewHImportDiscardAllCmd(ctx)
da.registryEpoch = epoch
injected = append(injected, da)
sessionWiped = true
} else {
for _, name := range tombstones {
if cn.FieldsetPreparedVersion(name) != 0 {
injected = append(injected, NewHImportDiscardCmd(ctx, name))
}
}
}
// Prepares for registered fieldsets the batch's SETs reference.
var covered map[string]struct{}
cover := func(name string) {
if covered == nil {
covered = make(map[string]struct{})
}
covered[name] = struct{}{}
}
for _, cmd := range cmds {
switch hc := cmd.(type) {
case *HImportPrepareCmd:
cover(hc.fieldsetName)
case *HImportSetCmd:
if _, ok := covered[hc.fieldsetName]; ok {
continue
}
fs, ok := c.himport.lookup(hc.fieldsetName)
if !ok {
continue
}
if !sessionWiped && cn.FieldsetPreparedVersion(hc.fieldsetName) == fs.version {
continue
}
// The session holds an older version. Discard it before the
// re-prepare: the SET behind it is already on the wire, and if
// the re-prepare fails the SET must answer "no such fieldset"
// rather than silently writing the old version's field names.
if !sessionWiped && cn.FieldsetPreparedVersion(hc.fieldsetName) != 0 {
injected = append(injected, NewHImportDiscardCmd(ctx, hc.fieldsetName))
}
prep := NewHImportPrepareCmd(ctx, hc.fieldsetName, fs.fields...)
prep.registryVersion = fs.version
prep.registryEpoch = epoch
injected = append(injected, prep)
cover(hc.fieldsetName)
}
}
return injected
}
// himportReadInjectedReplies consumes the replies of injected HIMPORT
// commands. Server errors are recorded on the command and the connection is
// left readable; transport errors are returned. Successful commands apply
// their prepared-flag bookkeeping on cn.
func (c *baseClient) himportReadInjectedReplies(ctx context.Context, cn *pool.Conn, rd *proto.Reader, injected []Cmder) error {
for _, cmd := range injected {
if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
err := cmd.readReply(rd)
cmd.SetErr(err)
if err != nil {
if !isRedisError(err) {
return err
}
// A failed injected PREPARE becomes the root cause of the
// dependent SETs' errors downstream; a failed injected discard
// only delays cleanup until the next HIMPORT command.
internal.Logger.Printf(ctx, "himport: injected %s failed: %v", cmd.Name(), err)
continue
}
switch hc := cmd.(type) {
case *HImportPrepareCmd:
cn.MarkFieldsetPrepared(hc.fieldsetName, hc.registryVersion, hc.registryEpoch)
case *HImportDiscardCmd:
cn.UnmarkFieldsetPrepared(hc.fieldsetName)
case *HImportDiscardAllCmd:
cn.ClearPreparedFieldsets(hc.registryEpoch)
}
}
return nil
}
// himportAfterCmd applies registry and prepared-flag updates after a
// user-issued HIMPORT command completed successfully on cn.
func (c *baseClient) himportAfterCmd(cn *pool.Conn, hc himportCmder) {
if c.himport == nil {
return
}
switch cmd := hc.(type) {
case *HImportPrepareCmd:
version, epoch := cmd.registryVersion, cmd.registryEpoch
if version == 0 {
version, epoch = c.himport.register(cmd.fieldsetName, cmd.fields)
}
// A pre-assigned version marks a fan-out copy: the fieldset was
// registered once at the cluster/ring level; only mark the
// executing connection.
cn.MarkFieldsetPrepared(cmd.fieldsetName, version, epoch)
case *HImportDiscardCmd:
registered := c.himport.discard(cmd.fieldsetName)
cn.UnmarkFieldsetPrepared(cmd.fieldsetName)
// The managed API reports the registry lifecycle: 1 when the
// fieldset was registered on this client and is now removed. The
// executing connection's session count stands only for fieldsets
// the registry never knew (raw usage).
if registered {
cmd.SetVal(1)
}
case *HImportDiscardAllCmd:
// A pre-assigned epoch marks a fan-out copy: the registry was
// already wiped at the cluster/ring level; only move the executing
// connection to that epoch.
if cmd.registryEpoch != 0 {
cn.ClearPreparedFieldsets(cmd.registryEpoch)
return
}
epoch, removed := c.himport.discardAll()
cn.ClearPreparedFieldsets(epoch)
// Same registry semantics: report how many registered fieldsets
// were removed, not how many the executing session happened to
// hold.
if removed > 0 {
cmd.SetVal(int64(removed))
}
}
}
// himportAfterBatch runs after all replies of a batch were read: it surfaces
// an injected PREPARE failure as the root cause on the HIMPORT SET commands
// that depended on it (their own reply is the secondary "no such fieldset"
// error), invalidates stale prepared flags for SETs that found their
// registered fieldset missing server-side, and applies registry updates for
// user-issued HIMPORT commands that succeeded in the batch.
// rawErr throughout: this runs on the execution path, before an async
// autopipeline batch completes (its ready channel closes only after the
// pipeline hook chain returns) — Err() on a user command would await and
// self-deadlock the dispatcher.
func (c *baseClient) himportAfterBatch(cn *pool.Conn, injected []Cmder, cmds []Cmder) {
var failed map[string]error
var refreshed map[string]struct{}
for _, cmd := range injected {
if prep, ok := cmd.(*HImportPrepareCmd); ok {
if err := prep.Err(); err != nil {
if failed == nil {
failed = make(map[string]error)
}
failed[prep.fieldsetName] = err
}
}
}
for _, cmd := range cmds {
hc, ok := cmd.(himportCmder)
if !ok {
continue
}
if set, ok := hc.(*HImportSetCmd); ok {
if rootCause, ok := failed[set.fieldsetName]; ok && himportNoSuchFieldset(set.rawErr()) {
set.SetErr(rootCause)
continue
}
// The session lost a fieldset the flags claim is prepared (e.g.
// RESET) — and the same event may have wiped other sessions
// whose flags also still look current. Bump the fieldset
// version once so the SET's re-issue, the cluster re-queue on
// whichever connection it lands, or the caller's transaction
// retry replays the PREPARE.
if himportNoSuchFieldset(set.rawErr()) {
if _, done := refreshed[set.fieldsetName]; !done {
if refreshed == nil {
refreshed = make(map[string]struct{})
}
refreshed[set.fieldsetName] = struct{}{}
if fs, registered := c.himport.lookup(set.fieldsetName); registered {
c.himport.refreshVersion(set.fieldsetName, fs.version)
}
}
}
continue
}
if hc.rawErr() == nil {
c.himportAfterCmd(cn, hc)
}
}
}
// himportRetryFailedSets re-issues, once, the HIMPORT SET commands of a
// pipeline batch that failed with "no such fieldset" while their fieldset is
// registered — the error must not surface for managed fieldsets (NF.4). Only
// the SETs are re-sent: HIMPORT SET is a full replace, so re-execution is
// idempotent, and no other command of the batch runs again. Their prepared
// flags were invalidated by himportAfterBatch, so himportInjectedCmds
// regenerates the PREPAREs for this connection. Transport errors are
// returned; server errors stay recorded on the commands.
// (The retry does not carry an ASKING prefix. A redirected [ASKING, SET]
// pair whose injected PREPARE failed is excluded by the root-cause swap in
// himportAfterBatch; one that lost its session without an injection can be
// re-issued here, and the bare SET then draws a fresh MOVED/ASK that the
// outer cluster redirect handling resolves.)
func (c *baseClient) himportRetryFailedSets(ctx context.Context, cn *pool.Conn, cmds []Cmder) error {
if c.himport.idle() {
return nil
}
var retry []Cmder
for _, cmd := range cmds {
// rawErr: execution path, same self-deadlock rule as himportAfterBatch.
if set, ok := cmd.(*HImportSetCmd); ok && himportNoSuchFieldset(set.rawErr()) {
if _, registered := c.himport.lookup(set.fieldsetName); registered {
retry = append(retry, set)
}
}
}
if len(retry) == 0 {
return nil
}
injected := c.himportInjectedCmds(ctx, cn, retry)
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmds(wr, retry)
}); err != nil {
return err
}
return cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
if err := c.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
return err
}
err := c.pipelineReadCmds(ctx, cn, rd, retry)
if err != nil && !isRedisError(err) {
return err
}
// Server errors (including a repeated failure) stay on the
// individual commands; the batch as a whole is done.
c.himportAfterBatch(cn, injected, retry)
return nil
})
}
// himportShouldRetrySet reports whether a retry of cmd may succeed after it
// failed with "no such fieldset": true when the fieldset is registered
// client-side — the executing connection lost its server session state (for
// example a RESET, or a concurrent discard). The connection's prepared flag
// was already invalidated inside _process, while that goroutine still owned
// the connection, so the retry re-prepares lazily wherever it lands.
func (c *baseClient) himportShouldRetrySet(cmd Cmder, err error) bool {
set, ok := cmd.(*HImportSetCmd)
if !ok || !himportNoSuchFieldset(err) {
return false
}
_, registered := c.himport.lookup(set.fieldsetName)
return registered
}
package redis
import "context"
// Cluster and ring support for the HIMPORT command family.
//
// Correctness comes from the shared registry: every node/shard client holds
// the same himportRegistry (wired at client construction), so any connection
// executing an HIMPORT SET lazily replays the PREPARE, MOVED/ASK redirects
// re-prepare on the target node, and discards propagate through tombstones
// and the discard-all epoch. Replicas share the registry too — roles change
// with the topology, and a promoted replica's connections simply carry no
// prepared flags, so their first SET self-prepares.
//
// On top of that, user-issued PREPARE/DISCARD/DISCARDALL fan out eagerly to
// all masters (R.4): one connection per master is prepared/cleaned up front,
// server-side validation surfaces immediately, and leftover session state is
// bounded. The fan-out is best-effort — any connection it does not reach is
// covered by the lazy replay.
// The fan-out helpers execute the per-node copies through each node client's
// Process, so node-level hooks observe them; the cluster/ring-level
// ProcessHook chain sees only the user's command object, not the fan-out.
//
// Known limitation: an HImportPrepare pipelined together with HImportSets of
// the same new fieldset in one ClusterClient Exec is not ordered across
// nodes — per-node sub-batches run concurrently, and the registration
// happens when the PREPARE's node completes, so SETs routed to other nodes
// can race it and fail with "no such fieldset". Register the fieldset with
// the client-level HImportPrepare before pipelining (the HLD's back-to-back
// PREPARE+SET pattern is a single-connection guarantee).
// himportForEach runs fn on a set of clients (all cluster masters, or all
// ring shards).
type himportForEach func(ctx context.Context, fn func(ctx context.Context, client *Client) error) error
// himportRequeueFailedSets re-queues HIMPORT SETs of registered fieldsets
// that failed with "no such fieldset" — their stale prepared flags were just
// invalidated by himportAfterBatch, so the next pipeline attempt re-prepares
// lazily and re-executes only those SETs (a full replace, so idempotent).
// Bounded by the cluster pipeline's attempt budget.
func (c *ClusterClient) himportRequeueFailedSets(ctx context.Context, cmds []Cmder, failedCmds *cmdsMap) {
for _, cmd := range cmds {
// rawErr: runs on the per-node execution goroutine, same
// self-deadlock rule as himportAfterBatch.
if set, ok := cmd.(*HImportSetCmd); ok && himportNoSuchFieldset(set.rawErr()) {
if _, registered := c.himport.lookup(set.fieldsetName); registered {
_ = c.mapCmdsByNode(ctx, failedCmds, []Cmder{set})
}
}
}
}
// himportFanOutPrepare registers the fieldset once in the shared registry
// and executes a pre-versioned PREPARE copy on every client; each copy marks
// its executing connection without registering again. A deterministic server
// rejection (e.g. duplicate field name) withdraws the registration; a
// transport failure keeps it, and lazy replay covers the connections the
// fan-out missed (all-succeeded semantics: the first error is reported).
func himportFanOutPrepare(ctx context.Context, registry *himportRegistry, forEach himportForEach, cmd *HImportPrepareCmd) {
version, epoch := registry.register(cmd.fieldsetName, cmd.fields)
err := forEach(ctx, func(ctx context.Context, client *Client) error {
fanCmd := NewHImportPrepareCmd(ctx, cmd.fieldsetName, cmd.fields...)
fanCmd.registryVersion = version
fanCmd.registryEpoch = epoch
return client.Process(ctx, fanCmd)
})
if err != nil {
if isRedisError(err) {
// Withdraw the registration; the tombstone cleans the sessions
// on which the fan-out succeeded before the rejection.
registry.discardVersion(cmd.fieldsetName, version)
}
cmd.SetErr(err)
return
}
cmd.SetVal("OK")
}
// himportFanOutDiscard removes the fieldset from the shared registry
// (leaving the tombstone that lazily cleans the connections the fan-out does
// not reach) and discards it on one connection of every client.
func himportFanOutDiscard(ctx context.Context, registry *himportRegistry, forEach himportForEach, cmd *HImportDiscardCmd) {
registered := registry.discard(cmd.fieldsetName)
err := forEach(ctx, func(ctx context.Context, client *Client) error {
return client.Process(ctx, NewHImportDiscardCmd(ctx, cmd.fieldsetName))
})
if err != nil {
cmd.SetErr(err)
return
}
if registered {
cmd.SetVal(1)
} else {
cmd.SetVal(0)
}
}
// himportFanOutDiscardAll wipes the shared registry once and executes a
// pre-epoch DISCARDALL copy on every client; each copy moves its executing
// connection to the new epoch without bumping the registry again.
func himportFanOutDiscardAll(ctx context.Context, registry *himportRegistry, forEach himportForEach, cmd *HImportDiscardAllCmd) {
epoch, removed := registry.discardAll()
err := forEach(ctx, func(ctx context.Context, client *Client) error {
fanCmd := NewHImportDiscardAllCmd(ctx)
fanCmd.registryEpoch = epoch
return client.Process(ctx, fanCmd)
})
if err != nil {
cmd.SetErr(err)
return
}
cmd.SetVal(int64(removed))
}
// HImportPrepare registers the fieldset in the cluster-wide registry and
// eagerly prepares one connection on every master; all other connections —
// including those of replicas promoted later and masters added by
// resharding — are prepared lazily before their first HImportSet. See
// HashCmdable.HImportPrepare (cmdable) for the fieldset semantics.
//
// The fan-out is best-effort and reports the first error: on a server
// rejection (e.g. duplicate field name) the registration is withdrawn and
// any sessions the fan-out already prepared are cleaned lazily; on a
// transport failure the registration is kept and lazy replay covers the
// connections the fan-out missed.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *ClusterClient) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd {
cmd := NewHImportPrepareCmd(ctx, fieldsetName, fields...)
himportFanOutPrepare(ctx, c.himport, c.ForEachMaster, cmd)
return &cmd.StatusCmd
}
// HImportDiscard removes the fieldset from the cluster-wide registry and
// discards it on every master; connections the fan-out does not reach
// replay the discard before their next HIMPORT command. It returns 1 if the
// fieldset was registered on this client and is now removed.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *ClusterClient) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd {
cmd := NewHImportDiscardCmd(ctx, fieldsetName)
himportFanOutDiscard(ctx, c.himport, c.ForEachMaster, cmd)
return &cmd.IntCmd
}
// HImportDiscardAll removes all fieldsets from the cluster-wide registry and
// wipes them on every master; connections the fan-out does not reach replay
// the wipe before their next HIMPORT command. It returns the number of
// fieldsets removed from the registry.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *ClusterClient) HImportDiscardAll(ctx context.Context) *IntCmd {
cmd := NewHImportDiscardAllCmd(ctx)
himportFanOutDiscardAll(ctx, c.himport, c.ForEachMaster, cmd)
return &cmd.IntCmd
}
// HImportPrepare registers the fieldset in the ring-wide registry and
// eagerly prepares one connection on every shard; all other connections are
// prepared lazily before their first HImportSet. The fan-out is best-effort
// with the same failure semantics as ClusterClient.HImportPrepare. See
// HashCmdable.HImportPrepare (cmdable) for the fieldset semantics.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *Ring) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd {
cmd := NewHImportPrepareCmd(ctx, fieldsetName, fields...)
himportFanOutPrepare(ctx, c.opt.himport, c.ForEachShard, cmd)
return &cmd.StatusCmd
}
// HImportDiscard removes the fieldset from the ring-wide registry and
// discards it on every shard; connections the fan-out does not reach replay
// the discard before their next HIMPORT command. It returns 1 if the
// fieldset was registered on this client and is now removed.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *Ring) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd {
cmd := NewHImportDiscardCmd(ctx, fieldsetName)
himportFanOutDiscard(ctx, c.opt.himport, c.ForEachShard, cmd)
return &cmd.IntCmd
}
// HImportDiscardAll removes all fieldsets from the ring-wide registry and
// wipes them on every shard; connections the fan-out does not reach replay
// the wipe before their next HIMPORT command. It returns the number of
// fieldsets removed from the registry.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c *Ring) HImportDiscardAll(ctx context.Context) *IntCmd {
cmd := NewHImportDiscardAllCmd(ctx)
himportFanOutDiscardAll(ctx, c.opt.himport, c.ForEachShard, cmd)
return &cmd.IntCmd
}
package redis
import "context"
// The HIMPORT command family (Redis 8.10+, "hinted hash templates") provides
// fast ingestion of many hashes sharing the same field names. HIMPORT PREPARE
// registers the field names once under a fieldset name, then HIMPORT SET
// creates hashes by sending only the values.
//
// The server scopes a fieldset to the physical connection that prepared it.
// Because go-redis pools connections, the client additionally keeps a
// client-side registry of fieldsets registered through HImportPrepare and
// lazily replays the PREPARE (at most once per connection session) on any
// pooled connection about to execute an HImportSet that references it. See
// himport.go.
//
// The whole HIMPORT surface — the typed methods, the HImport*Cmd types and
// their constructors — is experimental and may be subject to change.
// himportCmder marks HIMPORT commands that participate in client-side
// fieldset tracking. Process paths do a single interface assertion on the
// hot path and inspect the concrete type only for HIMPORT commands.
type himportCmder interface {
Cmder
himportCmd()
}
var (
_ himportCmder = (*HImportPrepareCmd)(nil)
_ himportCmder = (*HImportSetCmd)(nil)
_ himportCmder = (*HImportDiscardCmd)(nil)
_ himportCmder = (*HImportDiscardAllCmd)(nil)
)
// HImportPrepareCmd represents an HIMPORT PREPARE command.
type HImportPrepareCmd struct {
StatusCmd
fieldsetName string
fields []string
// registryVersion and registryEpoch are set only on commands injected by
// the client to replay a registered fieldset onto a connection; on
// success the connection is marked as prepared at this version under
// this discard-all epoch.
registryVersion uint64
registryEpoch uint64
}
func (cmd *HImportPrepareCmd) himportCmd() {}
// NewHImportPrepareCmd returns an HIMPORT PREPARE command.
func NewHImportPrepareCmd(ctx context.Context, fieldsetName string, fields ...string) *HImportPrepareCmd {
args := make([]interface{}, 3+len(fields))
args[0] = "himport"
args[1] = "prepare"
args[2] = fieldsetName
for i, field := range fields {
args[3+i] = field
}
return &HImportPrepareCmd{
StatusCmd: StatusCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStatus,
},
},
fieldsetName: fieldsetName,
fields: append([]string(nil), fields...),
}
}
// HImportSetCmd represents an HIMPORT SET command.
type HImportSetCmd struct {
StatusCmd
fieldsetName string
}
func (cmd *HImportSetCmd) himportCmd() {}
// NewHImportSetCmd returns an HIMPORT SET command.
func NewHImportSetCmd(ctx context.Context, key, fieldsetName string, values ...interface{}) *HImportSetCmd {
args := make([]interface{}, 4+len(values))
args[0] = "himport"
args[1] = "set"
args[2] = key
args[3] = fieldsetName
copy(args[4:], values)
cmd := &HImportSetCmd{
StatusCmd: StatusCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStatus,
},
},
fieldsetName: fieldsetName,
}
cmd.SetFirstKeyPos(2)
return cmd
}
// HImportDiscardCmd represents an HIMPORT DISCARD command.
type HImportDiscardCmd struct {
IntCmd
fieldsetName string
}
func (cmd *HImportDiscardCmd) himportCmd() {}
// NewHImportDiscardCmd returns an HIMPORT DISCARD command.
func NewHImportDiscardCmd(ctx context.Context, fieldsetName string) *HImportDiscardCmd {
return &HImportDiscardCmd{
IntCmd: IntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"himport", "discard", fieldsetName},
cmdType: CmdTypeInt,
},
},
fieldsetName: fieldsetName,
}
}
// HImportDiscardAllCmd represents an HIMPORT DISCARDALL command.
type HImportDiscardAllCmd struct {
IntCmd
// registryEpoch is set only on commands injected by the client to wipe a
// session that predates the registry's discard-all epoch; on success the
// connection adopts this epoch.
registryEpoch uint64
}
func (cmd *HImportDiscardAllCmd) himportCmd() {}
// NewHImportDiscardAllCmd returns an HIMPORT DISCARDALL command.
func NewHImportDiscardAllCmd(ctx context.Context) *HImportDiscardAllCmd {
return &HImportDiscardAllCmd{
IntCmd: IntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"himport", "discardall"},
cmdType: CmdTypeInt,
},
},
}
}
// HImportPrepare registers an ordered list of hash field names under
// fieldsetName for use by subsequent HImportSet calls:
//
// HIMPORT PREPARE fieldset_name field [field ...]
//
// The server keeps the fieldset in the session of the connection that
// executed the command. On pooled clients (Client, Conn, Pipeline, Tx) the
// fieldset is also remembered client-side and the PREPARE is replayed
// lazily — at most once per connection session — on any pooled connection
// about to execute an HImportSet referencing it, so HImportSet works
// transparently across the pool. Preparing an existing fieldset name again
// silently replaces it.
//
// ClusterClient and Ring override this method (see himport_cluster.go): the
// fieldset registers in a registry shared by every node/shard client and the
// PREPARE additionally fans out eagerly to all masters/shards.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd {
cmd := NewHImportPrepareCmd(ctx, fieldsetName, fields...)
_ = c(ctx, cmd)
return &cmd.StatusCmd
}
// HImportSet creates or fully replaces the hash at key using the field list
// registered under fieldsetName, pairing values positionally with the
// prepared fields:
//
// HIMPORT SET key fieldset_name value [value ...]
//
// The number of values must equal the fieldset's field count. The resulting
// key is a regular hash readable and writable by all hash commands. If the
// fieldset was registered through HImportPrepare on this client, it is
// prepared automatically on whichever pooled connection executes the command;
// otherwise the fieldset must have been prepared on the executing connection
// or the server replies "ERR no such fieldset".
//
// "no such fieldset" never surfaces for a registered fieldset: a
// single-command HImportSet whose connection lost its session state (e.g.
// RESET) is transparently re-prepared and retried once — the failure also
// stales every other connection's prepared flag, so the retry re-prepares
// wherever it lands, and this recovery attempt is granted even when retries
// are disabled (MaxRetries -1). In pipelines the failed HImportSets — and
// only those — are re-prepared and re-issued once on the same connection
// (HIMPORT SET is a full replace, so the re-execution is idempotent and no
// other command of the batch runs again). Inside transactions the error does
// surface after EXEC — an executed transaction cannot be partially re-run —
// but the prepared flags are invalidated, so retrying the transaction
// succeeds.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportSet(ctx context.Context, key, fieldsetName string, values ...interface{}) *StatusCmd {
cmd := NewHImportSetCmd(ctx, key, fieldsetName, values...)
_ = c(ctx, cmd)
return &cmd.StatusCmd
}
// HImportDiscard removes fieldsetName from the executing connection's session
// and from the client-side registry, stopping further automatic replay:
//
// HIMPORT DISCARD fieldset_name
//
// It returns 1 if the fieldset was registered on this client and is now
// removed, 0 otherwise (for names never registered through the managed API,
// the executing connection's session reply passes through unchanged). Pooled
// connections whose sessions still hold the fieldset replay the DISCARD
// before their next HIMPORT command, so a subsequent HImportSet fails with
// "no such fieldset" on every connection, exactly as on a single connection.
// Hashes already created through the fieldset are not affected.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd {
cmd := NewHImportDiscardCmd(ctx, fieldsetName)
_ = c(ctx, cmd)
return &cmd.IntCmd
}
// HImportDiscardAll removes all fieldsets from the executing connection's
// session and clears the client-side registry:
//
// HIMPORT DISCARDALL
//
// It returns the number of fieldsets removed from the client-side registry
// (when none were registered, the executing connection's session count
// passes through). Other pooled connections whose sessions were prepared
// earlier replay HIMPORT DISCARDALL before their next HIMPORT command.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportDiscardAll(ctx context.Context) *IntCmd {
cmd := NewHImportDiscardAllCmd(ctx)
_ = c(ctx, cmd)
return &cmd.IntCmd
}
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
}
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
}
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
}
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)
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)
}
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 {
return NewManagerWithWorkers(pl, reAuthTimeout, pl.Size())
}
// NewManagerWithWorkers is like NewManager but sizes the re-auth worker
// semaphore explicitly. Use it when the returned PoolHook is shared across more
// than one pool (e.g. the main pool plus the dedicated pipeline pool): the
// semaphore must cover the COMBINED connection ceiling, or after a credential
// rotation the extra pool's connections queue behind pl.Size() workers and wait
// outside reAuthTimeout.
func NewManagerWithWorkers(pl pool.Pooler, reAuthTimeout time.Duration, workers int) *Manager {
if workers < 1 {
workers = 1
}
m := &Manager{
pool: pl,
poolHookRef: NewReAuthPoolHook(workers, 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)
}
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)
package hashtag
import (
"math/rand"
"strings"
)
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
}
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
}
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())
}
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
}
package internal
import (
"math/rand"
"time"
)
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
}
package internal
import (
"context"
"fmt"
"log"
"os"
"sync/atomic"
)
// 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
calldepth int
}
func (l *DefaultLogger) Printf(ctx context.Context, format string, v ...interface{}) {
_ = l.log.Output(l.calldepth, fmt.Sprintf(format, v...))
}
// Calldepth values for NewDefaultLogger. Reads through Logger.Printf add one
// forwarding frame (atomicLogger.Printf), so the plain default logger uses
// DefaultLoggerCalldepth; wrappers that add another Printf frame (e.g.
// logging's filterLogger) use FilterLoggerCalldepth.
const (
DefaultLoggerCalldepth = 3
FilterLoggerCalldepth = 4
)
// NewDefaultLogger returns a stderr logger that attributes each line to the
// frame calldepth levels up the stack.
func NewDefaultLogger(calldepth int) Logging {
return &DefaultLogger{
log: log.New(os.Stderr, "redis: ", log.LstdFlags|log.Lshortfile),
calldepth: calldepth,
}
}
// atomicLogger holds the active Logging behind an atomic pointer so
// redis.SetLogger, logging.Enable and logging.Disable can swap it while pool
// and background goroutines read it through Printf. The interface is stored via
// a pointer rather than atomic.Value, whose single-concrete-type rule the
// swappable implementations (DefaultLogger, VoidLogger, custom loggers) break.
type atomicLogger struct {
v atomic.Pointer[Logging]
}
func (a *atomicLogger) Store(l Logging) { a.v.Store(&l) }
func (a *atomicLogger) Load() Logging {
if p := a.v.Load(); p != nil {
return *p
}
return nil
}
func (a *atomicLogger) Printf(ctx context.Context, format string, v ...interface{}) {
if l := a.Load(); l != nil {
l.Printf(ctx, format, v...)
}
}
func newAtomicLogger(l Logging) *atomicLogger {
a := &atomicLogger{}
a.Store(l)
return a
}
// Logger calls Output to print to the stderr.
// Arguments are handled in the manner of fmt.Print.
// Swap it with redis.SetLogger; read it through Logger.Printf.
var Logger = newAtomicLogger(NewDefaultLogger(DefaultLoggerCalldepth))
// atomicLogLevel stores the active level as an int32 so redis.SetLogLevel can
// change it while the level guards (isHealthyConn on the Get path, the
// maintnotifications loggers) read it through the *OrAbove helpers.
type atomicLogLevel struct {
v atomic.Int32
}
func (a *atomicLogLevel) Store(l LogLevelT) { a.v.Store(int32(l)) }
func (a *atomicLogLevel) Load() LogLevelT { return LogLevelT(a.v.Load()) }
func (a *atomicLogLevel) WarnOrAbove() bool { return a.Load().WarnOrAbove() }
func (a *atomicLogLevel) InfoOrAbove() bool { return a.Load().InfoOrAbove() }
func (a *atomicLogLevel) DebugOrAbove() bool { return a.Load().DebugOrAbove() }
func newAtomicLogLevel(l LogLevelT) *atomicLogLevel {
a := &atomicLogLevel{}
a.Store(l)
return a
}
var LogLevel = newAtomicLogLevel(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
}
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,
})
}
/*
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 atomic.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 o.done.Load() == 1 {
return nil
}
// Slow-path.
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done.Load() == 0 {
err = f()
if err == nil {
o.done.Store(1)
}
}
return err
}
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
}
// Enabled reports whether a real recorder is installed. Callers use it to
// skip metric work whose INPUTS are expensive to obtain — e.g. reading a
// command's result, which on the async autopipeline face blocks until the
// command executes.
func Enabled() bool {
_, noop := getRecorder().(noopRecorder)
return !noop
}
// 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. pipelinePool
// is the optional dedicated pipeline connection pool (nil when not configured);
// it is registered as a regular pool under a "_pipeline" name suffix.
func RegisterPools(connPool pool.Pooler, pubSubPool PubSubPooler, pipelinePool pool.Pooler, addr string) {
// Check if the global recorder implements PoolRegistrar. Read it through
// getRecorder: SetGlobalRecorder writes globalRecorder under recorderMu, and
// clients are created (and closed) concurrently with telemetry being
// installed, so an unlocked read here is a data race -race reports.
if registrar, ok := getRecorder().(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)
}
if pipelinePool != nil {
poolName := addr + "_" + uniqueID + "_pipeline"
registrar.RegisterPool(poolName, pipelinePool)
}
}
}
// UnregisterPools removes connection pools from the global recorder. pipelinePool
// is the optional dedicated pipeline connection pool (nil when not configured).
func UnregisterPools(connPool pool.Pooler, pubSubPool PubSubPooler, pipelinePool pool.Pooler) {
// Check if the global recorder implements PoolRegistrar (see RegisterPools
// for why this goes through getRecorder rather than reading directly).
if registrar, ok := getRecorder().(PoolRegistrar); ok {
if connPool != nil {
registrar.UnregisterPool(connPool)
}
if pubSubPool != nil {
registrar.UnregisterPubSubPool(pubSubPool)
}
if pipelinePool != nil {
registrar.UnregisterPool(pipelinePool)
}
}
}
// Package pool implements the pool management
package pool
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
uberatomic "go.uber.org/atomic"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/proto"
)
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 atomic.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.
// It is always stored and accessed by pointer, so the embedded atomic.Bool is
// never copied.
type atomicNetConn struct {
conn net.Conn
// closed claims teardown of this specific transport generation. Close
// CAS-claims it before calling conn.Close, so a given socket is closed
// exactly once. A handoff installs a fresh wrapper (closed=false) via
// setNetConn, so a replacement socket is a new generation claimed by the
// next Close rather than skipped under a stale flag. The wrapper is left in
// place on Close (not swapped out) so getNetConn keeps returning the closed
// conn, preserving RemoteAddr/LocalAddr and the connCheck health path.
closed atomic.Bool
}
// generateConnID generates a fast unique identifier for a connection with zero allocations
func generateConnID() uint64 {
return connIDCounter.Add(1)
}
// relaxedState is a snapshot of one connection's relaxed-timeout window. Conn
// publishes it through the relaxed atomic pointer. Do not change a stored
// relaxedState. Each mutator copies the current snapshot, changes the copy, and
// installs the copy with a compare-and-swap. A reader that holds an older pointer
// still sees a consistent window.
//
// Two sources use this state (see the relaxed-timeout methods):
// - Maintenance notifications. SetRelaxedTimeout adds a holder.
// ClearRelaxedTimeout removes it. There is no deadline.
// - Handoff. SetRelaxedTimeoutWithDeadline sets a deadline that expires the
// window. Handoff never calls Clear.
//
// count is the number of current holders. A deadline window holds one slot only,
// even after re-arms, because there is one deadlineNs.
type relaxedState struct {
readNs int64 // relaxed read timeout, nanoseconds
writeNs int64 // relaxed write timeout, nanoseconds
deadlineNs int64 // auto-expiry, unix nanos; 0 = no deadline
count int32 // number of holders; the window clears when count reaches 0
}
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 and health checks
// Used during:
// - SetNetConn (write lock for resetting reader state)
// - HasBufferedData/PeekReplyTypeSafe (read lock for safe concurrent peek operations)
readerMu sync.RWMutex
// State machine for connection state management
// 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)
// preparedFieldsets tracks HIMPORT fieldsets prepared on this
// connection's current server session: fieldset name -> client-side
// registry version. The server drops fieldsets when the session ends,
// so the map is cleared whenever the underlying network connection is
// replaced. preparedFieldsetsEpoch records the registry's discard-all
// epoch the session was prepared under; a session behind the current
// epoch replays HIMPORT DISCARDALL before its next HIMPORT command.
// Guarded by preparedFieldsetsMu; the map is nil until first use.
preparedFieldsetsMu sync.Mutex
preparedFieldsets map[string]uint64
preparedFieldsetsEpoch uint64
// 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
// closeOnPutReason marks an in-use connection for removal when it is returned
// to the pool. The socket is left open for the in-flight command and closed
// by ConnPool.Put.
closeOnPutReason uberatomic.String
// relaxed holds the relaxed-timeout window for maintenance notifications
// (migrations and failovers). One atomic pointer publishes the whole window: the
// read timeout, the write timeout, the optional deadline, and the holder count.
// A reader always gets a consistent snapshot. A reader never sees a half-updated
// window, such as a new deadline with old timeouts. nil means no relaxation. The
// mutators (SetRelaxedTimeout, SetRelaxedTimeoutWithDeadline,
// ClearRelaxedTimeout, expireRelaxedTimeout) install a new relaxedState with a
// compare-and-swap. The read path (getEffective* and HasRelaxedTimeout) does one
// lock-free Load. Each mutation allocates one relaxedState. This cost is small,
// because mutations occur per maintenance notification, not per I/O.
relaxed atomic.Pointer[relaxedState]
// onClose is read and cleared by Close while initConn (running inside
// SetNetConnAndInitConn under the INITIALIZING state) installs it via
// SetOnClose; Close transitions to CLOSED from any state, so the two
// race when a pool shutdown closes a connection mid-init. Stored as an
// atomic pointer so the setter and Close don't need a mutex (keeping
// Conn slim).
onClose atomic.Pointer[func() error]
// Connection initialization function for reconnections
initConnFunc func(context.Context, *Conn) error
// onCscClose is the client-side-caching close hook, kept separate from
// onClose (streaming-credentials cleanup) so neither clobbers the other.
// Both keep overwrite semantics, so re-running initConn can't accumulate
// them. Atomic for the same init-vs-Close race as onClose: it is also
// installed by initConn and read and cleared by Close.
onCscClose atomic.Pointer[func() error]
// onCscReinit runs after the connection is claimed for reinitialization but
// before its socket is replaced. CSC uses it to invalidate entries whose
// server-side tracking coverage belongs to the old socket.
onCscReinit func()
// cscReadPending requests one conservative drain after a command read through
// a transport whose buffered state cannot be fully observed by MaybeHasData.
cscReadPending atomic.Bool
// lastCscPeriodicProbeNs throttles bounded fallback reads on platforms and
// opaque transports without a non-consuming readiness mechanism.
lastCscPeriodicProbeNs atomic.Int64
}
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
}
// MarkCloseOnPut marks the connection for removal when it is returned to the pool.
func (cn *Conn) MarkCloseOnPut(reason string) {
cn.closeOnPutReason.Store(reason)
}
// CloseOnPutReason returns a non-empty reason when the connection should be
// removed instead of pooled on Put.
func (cn *Conn) CloseOnPutReason() string {
return cn.closeOnPutReason.Load()
}
// IsPubSub returns true if the connection is used for PubSub.
func (cn *Conn) IsPubSub() bool {
return cn.pubsub
}
// SetRelaxedTimeout sets the relaxed timeouts for this connection during a
// maintenance-notification upgrade. They apply to every later command until an
// equal number of ClearRelaxedTimeout calls remove this holder. This method is
// lock-free. It installs a new snapshot with a compare-and-swap and keeps any
// deadline that is already in effect.
// Note: the caller (the notification handler) records the metrics, because it
// knows the notification type and the pool name.
func (cn *Conn) SetRelaxedTimeout(readTimeout, writeTimeout time.Duration) {
for {
cur := cn.relaxed.Load()
var next relaxedState
if cur != nil {
next = *cur
}
next.readNs = int64(readTimeout)
next.writeNs = int64(writeTimeout)
next.count++
if cn.relaxed.CompareAndSwap(cur, &next) {
return
}
}
}
// SetRelaxedTimeoutWithDeadline sets the relaxed timeouts and a deadline. After
// the deadline the window reverts on its own, because handoff never calls Clear.
// Only the first deadline window takes a holder slot. An overlapping handoff
// re-arms the window: it replaces the deadline in place and does not add a holder.
// So the later expiry removes exactly one holder and the window clears. The
// connection does not stay relaxed forever. This method is lock-free.
func (cn *Conn) SetRelaxedTimeoutWithDeadline(readTimeout, writeTimeout time.Duration, deadline time.Time) {
deadlineNs := deadline.UnixNano()
for {
cur := cn.relaxed.Load()
var next relaxedState
if cur != nil {
next = *cur
}
next.readNs = int64(readTimeout)
next.writeNs = int64(writeTimeout)
if next.deadlineNs == 0 {
// There is no deadline holder yet. Take one. A re-arm already has a
// deadline set, so it replaces the deadline and does not add a holder.
next.count++
}
next.deadlineNs = deadlineNs
if cn.relaxed.CompareAndSwap(cur, &next) {
return
}
}
}
// ClearRelaxedTimeout removes one holder that SetRelaxedTimeout added. When the
// last holder is gone and no unexpired deadline remains, it drops the whole
// window. The count has a lower bound of zero. So a second clear, or a clear after
// a deadline expiry already emptied the window, does nothing and cannot block a
// later relaxation. This method is lock-free.
func (cn *Conn) ClearRelaxedTimeout() {
for {
cur := cn.relaxed.Load()
if cur == nil || cur.count <= 0 {
return // already cleared (for example, by a deadline expiry)
}
next := *cur
next.count--
// Keep the deadline gate. After an explicit clear the window can still hold
// the relaxed timeouts until the safety deadline. Drop the window only when
// the last holder leaves and the deadline is unset or already past.
newPtr := &next
if next.count <= 0 && (next.deadlineNs == 0 || time.Now().UnixNano() >= next.deadlineNs) {
newPtr = nil
}
if cn.relaxed.CompareAndSwap(cur, newPtr) {
return
}
}
}
// expireRelaxedTimeout removes the deadline holder after the deadline passes.
// getEffective* calls it when a read finds the deadline expired. The
// compare-and-swap checks that the current snapshot still holds THIS deadline. A
// concurrent SetRelaxedTimeout* can install a newer window; then this expiry is
// stale and does nothing. It removes only the deadline holder (count minus one).
// A notification holder on the same connection stays active. The window clears in
// full only when the deadline holder was the last holder. One snapshot publishes
// the whole window, so a reader never sees a half-cleared state.
func (cn *Conn) expireRelaxedTimeout(deadlineNs int64) {
for {
cur := cn.relaxed.Load()
if cur == nil || cur.deadlineNs != deadlineNs {
// Already cleared, or replaced by a newer window: stale expiry.
return
}
next := *cur
next.deadlineNs = 0
if next.count > 0 {
next.count--
}
newPtr := &next
if next.count <= 0 {
newPtr = nil
}
if cn.relaxed.CompareAndSwap(cur, newPtr) {
internal.Logger.Printf(context.Background(), logs.UnrelaxedTimeoutAfterDeadline(cn.GetID()))
return
}
}
}
// HasRelaxedTimeout returns true when the relaxed timeouts are active on this
// connection. Active means a holder is present, a timeout is set, and any deadline
// is still in the future. When the deadline has passed it removes the deadline holder
// once (like getEffective*) and re-reads the snapshot: a surviving non-deadline holder
// keeps the window active. Before this, an expired deadline made it report false even
// while another holder was still active, contradicting the surviving-holder semantics.
// This reports the boolean only; the timeout VALUE it would relax to may still be the
// expired holder's, since all holders share one (readNs, writeNs) pair — see the
// per-holder timeout follow-up.
func (cn *Conn) HasRelaxedTimeout() bool {
cur := cn.relaxed.Load()
if cur == nil || cur.count <= 0 || (cur.readNs <= 0 && cur.writeNs <= 0) {
return false
}
if cur.deadlineNs == 0 || time.Now().UnixNano() < cur.deadlineNs {
return true
}
// The deadline passed. Remove the deadline holder, then re-read: a notification
// holder can still be active with no deadline (surviving-holder semantics), matching
// getEffectiveReadTimeout.
cn.expireRelaxedTimeout(cur.deadlineNs)
s := cn.relaxed.Load()
if s == nil || s.count <= 0 || (s.readNs <= 0 && s.writeNs <= 0) {
return false
}
return s.deadlineNs == 0 || time.Now().UnixNano() < s.deadlineNs
}
// EffectiveReadTimeout reports the read timeout that a read on this connection
// uses now. It returns the active relaxed timeout when the window is set and
// unexpired. Otherwise it returns normalTimeout. A caller that bounds how long a
// blocked read may take (for example, the CSC full-duplex drain backstop) must use
// this method, not the configured ReadTimeout. With ReadTimeout, a relaxed read
// ends too soon.
//
// It is safe to call from several goroutines at once (the reader, the writer, and
// the drain backstop of a full-duplex connection). It reads one atomic snapshot.
// The only change it makes is the deadline safety net: when the deadline has
// passed, it removes the deadline holder once (see expireRelaxedTimeout). It never
// removes an active, unexpired window. If a notification holder survives that
// expiry, it returns that holder's relaxed timeout, not normalTimeout.
func (cn *Conn) EffectiveReadTimeout(normalTimeout time.Duration) time.Duration {
return cn.getEffectiveReadTimeout(normalTimeout)
}
// EffectiveWriteTimeout is the write side of EffectiveReadTimeout. Use it to bound
// how long a blocked write may take. It has the same snapshot behavior.
func (cn *Conn) EffectiveWriteTimeout(normalTimeout time.Duration) time.Duration {
return cn.getEffectiveWriteTimeout(normalTimeout)
}
// getEffectiveReadTimeout returns the timeout for read operations. It returns the
// relaxed read timeout while the window is set and unexpired. Otherwise it returns
// normalTimeout. When the deadline has passed, it removes the deadline holder and
// then reads the window again. A surviving notification holder's relaxed timeout
// still takes priority over normalTimeout.
func (cn *Conn) getEffectiveReadTimeout(normalTimeout time.Duration) time.Duration {
cur := cn.relaxed.Load()
if cur == nil || cur.readNs <= 0 {
return normalTimeout
}
if cur.deadlineNs == 0 {
return time.Duration(cur.readNs)
}
// Use the cached time to avoid an expensive system call. Up to 50 ms of
// staleness is acceptable.
if getCachedTimeNs() < cur.deadlineNs {
return time.Duration(cur.readNs)
}
// The deadline passed. Remove the deadline holder, then read the window again.
// A notification holder can still be active with no deadline. Use its relaxed
// timeout. Do not end this call early with normalTimeout.
cn.expireRelaxedTimeout(cur.deadlineNs)
if s := cn.relaxed.Load(); s != nil && s.readNs > 0 && (s.deadlineNs == 0 || getCachedTimeNs() < s.deadlineNs) {
return time.Duration(s.readNs)
}
return normalTimeout
}
// getEffectiveWriteTimeout is the write side of getEffectiveReadTimeout.
func (cn *Conn) getEffectiveWriteTimeout(normalTimeout time.Duration) time.Duration {
cur := cn.relaxed.Load()
if cur == nil || cur.writeNs <= 0 {
return normalTimeout
}
if cur.deadlineNs == 0 {
return time.Duration(cur.writeNs)
}
if getCachedTimeNs() < cur.deadlineNs {
return time.Duration(cur.writeNs)
}
cn.expireRelaxedTimeout(cur.deadlineNs)
if s := cn.relaxed.Load(); s != nil && s.writeNs > 0 && (s.deadlineNs == 0 || getCachedTimeNs() < s.deadlineNs) {
return time.Duration(s.writeNs)
}
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) {
if fn == nil {
cn.onClose.Store(nil)
return
}
cn.onClose.Store(&fn)
}
// SetOnCscClose sets the client-side-caching close hook, overwriting any
// previous one. It runs on Close in addition to the SetOnClose callback.
func (cn *Conn) SetOnCscClose(fn func() error) {
if fn == nil {
cn.onCscClose.Store(nil)
return
}
cn.onCscClose.Store(&fn)
}
// SetOnCscReinit sets the client-side-caching pre-reinitialization hook,
// overwriting any previous one.
func (cn *Conn) SetOnCscReinit(fn func()) {
cn.onCscReinit = 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)
// A new socket is a new server session with no HIMPORT fieldsets and
// nothing left to discard.
cn.ClearPreparedFieldsets(0)
}
// FieldsetPreparedVersion returns the client-side registry version at which
// the named HIMPORT fieldset was prepared on this connection's current server
// session, or 0 if it was not prepared on it (registry versions start at 1).
func (cn *Conn) FieldsetPreparedVersion(name string) uint64 {
cn.preparedFieldsetsMu.Lock()
version := cn.preparedFieldsets[name]
cn.preparedFieldsetsMu.Unlock()
return version
}
// MarkFieldsetPrepared records that the named HIMPORT fieldset was prepared
// on this connection's current server session at the given registry version.
// A session acquiring its first fieldset adopts the given discard-all epoch
// (fieldsets prepared after an HIMPORT DISCARDALL are not subject to it);
// the epoch never moves backwards, so a mark carrying an older snapshot
// cannot regress a session already wiped at a newer epoch.
func (cn *Conn) MarkFieldsetPrepared(name string, version, epoch uint64) {
cn.preparedFieldsetsMu.Lock()
if len(cn.preparedFieldsets) == 0 {
cn.preparedFieldsets = make(map[string]uint64)
if epoch > cn.preparedFieldsetsEpoch {
cn.preparedFieldsetsEpoch = epoch
}
}
cn.preparedFieldsets[name] = version
cn.preparedFieldsetsMu.Unlock()
}
// UnmarkFieldsetPrepared forgets that the named HIMPORT fieldset was prepared
// on this connection, forcing a replay before the next HIMPORT SET using it.
func (cn *Conn) UnmarkFieldsetPrepared(name string) {
cn.preparedFieldsetsMu.Lock()
delete(cn.preparedFieldsets, name)
cn.preparedFieldsetsMu.Unlock()
}
// HasPreparedFieldsets reports whether any HIMPORT fieldset is prepared on
// this connection's current server session.
func (cn *Conn) HasPreparedFieldsets() bool {
cn.preparedFieldsetsMu.Lock()
n := len(cn.preparedFieldsets)
cn.preparedFieldsetsMu.Unlock()
return n > 0
}
// PreparedFieldsetNames returns the names of the HIMPORT fieldsets prepared
// on this connection's current server session.
func (cn *Conn) PreparedFieldsetNames() []string {
cn.preparedFieldsetsMu.Lock()
names := make([]string, 0, len(cn.preparedFieldsets))
for name := range cn.preparedFieldsets {
names = append(names, name)
}
cn.preparedFieldsetsMu.Unlock()
return names
}
// FieldsetEpoch returns the discard-all epoch this connection's prepared
// fieldsets belong to (0 when none were ever prepared on the session).
func (cn *Conn) FieldsetEpoch() uint64 {
cn.preparedFieldsetsMu.Lock()
epoch := cn.preparedFieldsetsEpoch
cn.preparedFieldsetsMu.Unlock()
return epoch
}
// ClearPreparedFieldsets forgets all HIMPORT fieldsets prepared on this
// connection and records the discard-all epoch the wipe corresponds to.
func (cn *Conn) ClearPreparedFieldsets(epoch uint64) {
cn.preparedFieldsetsMu.Lock()
cn.preparedFieldsets = nil
cn.preparedFieldsetsEpoch = epoch
cn.preparedFieldsetsMu.Unlock()
}
// 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)
}
if cn.onCscReinit != nil {
cn.onCscReinit()
}
// 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 handoff state only if nothing else changed it
// since our CAS above. A concurrent handoff worker may have completed
// the handoff and run ClearHandoffState in this window; a plain Store
// would clobber that, resurrecting ShouldHandoff=true and wedging the
// connection so it can never be acquired again. The CAS leaves the
// worker's state intact when it has taken over.
cn.handoffStateAtomic.CompareAndSwap(newState, 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)
}
// ExpiresAt returns the connection's absolute lifetime expiry (zero when no
// ConnMaxLifetime applies; jitter included). Set once at dial, so a plain read
// is safe. Long-holding callers bound their hold by the REMAINING lifetime.
func (cn *Conn) ExpiresAt() time.Time {
return cn.expiresAt
}
// 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()
}
// PeekReplyTypeForCheck peeks at the reply type while holding readerMu, so it is
// safe against a concurrent SetNetConn resetting the reader during handoff.
// Unlike PeekReplyTypeSafe it does not require the data to already be buffered:
// the pool health check calls it after connCheck reports unexpected socket data,
// and connCheck only MSG_PEEKs, so the byte still has to be pulled from the
// socket into the reader here.
func (cn *Conn) PeekReplyTypeForCheck() (byte, error) {
cn.readerMu.RLock()
defer cn.readerMu.RUnlock()
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
}
} else {
// A negative timeout skips SetReadDeadline, and thus deadline(), which is
// the only per-I/O usedAt update. Record usage anyway so a long
// deadline-free hold (e.g. a full-duplex CSC session under ReadTimeout=-2)
// is not misjudged as idle-expired by the pool on the next Get and
// needlessly closed + redialed. Cheap: one atomic store, only on the rare
// deadline-free path; harmless on a nil netConn.
cn.SetUsedAtNs(getCachedTimeNs())
}
return fn(cn.rd)
}
// WithReaderHardDeadline runs fn under a HARD read deadline of now+timeout,
// bypassing getEffectiveReadTimeout so a relaxed maintenance timeout can't extend
// it (used by the CSC drainer). Takes no context: an expired cycle ctx must not
// become the socket deadline, or the read surfaces context.DeadlineExceeded, which
// isBadConn treats as fatal.
func (cn *Conn) WithReaderHardDeadline(
timeout time.Duration, fn func(rd *proto.Reader) error,
) (err error) {
netConn := cn.getNetConn()
if netConn == nil {
return errConnectionNotAvailable
}
if err := netConn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return err
}
defer func() {
if clearErr := netConn.SetReadDeadline(time.Time{}); clearErr != nil {
err = clearErr
}
}()
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
}
} else {
// See WithReader: keep usedAt fresh on the deadline-free write path too so
// a long-held conn (ReadTimeout/WriteTimeout=-2) is not misjudged as
// idle-expired by the pool on the next Get.
cn.SetUsedAtNs(getCachedTimeNs())
}
// 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 {
// Transition to CLOSED. When the connection is already CLOSED, fall through
// to the cleanup below rather than returning early: a rejected initConn
// marks the connection CLOSED to report failure *before* any teardown runs
// (see redis.go initConn failure paths), so the pool's subsequent Close must
// still release the transport and run the close callbacks. Returning early
// on StateClosed leaked the socket and skipped the streaming-credentials
// unsubscribe / CSC close callbacks (issue #3982).
for {
state := cn.stateMachine.GetState()
if state == StateClosed {
break
}
if cn.stateMachine.TryTransitionFast(state, StateClosed) {
// TryTransitionFast deliberately skips waiter notification; Close
// still needs to wake any goroutine waiting on initialization.
cn.stateMachine.notifyWaiters()
break
}
}
// Callbacks are cleared with an atomic swap so each runs at most once even
// across concurrent or repeated Close calls, and independently of the state
// machine — the CLOSED state may have been set by a failed initialization
// rather than here.
if fn := cn.onClose.Swap(nil); fn != nil {
// ignore error
_ = (*fn)()
}
if fn := cn.onCscClose.Swap(nil); fn != nil {
// ignore error
_ = (*fn)()
}
// Close the current transport generation exactly once, claiming it via the
// wrapper's per-generation flag. The wrapper is left in netConnAtomic (not
// nil-ed or swapped out) so getNetConn keeps returning the closed conn,
// preserving the pre-fix contract that RemoteAddr/LocalAddr and the
// connCheck health path rely on.
//
// Load-then-CAS is deliberately not a single atomic step: if a concurrent
// handoff installs a new wrapper between the Load and the CAS, this Close
// claims and closes the OLD generation (which still needs closing) while the
// replacement is a fresh wrapper (closed=false) claimed by the next Close.
// That is the correct outcome and is what makes teardown generation-bound
// rather than leaking a socket installed after a Close set a lifetime flag.
//
// Repeat/concurrent closes of the same generation lose the CAS and return
// nil, so no spurious "use of closed network connection" reaches callers
// such as ConnPool.closeConnsIf. A handoff may also close the pre-handoff
// socket directly (handoff_worker.go captures oldConn); if that races this
// path the socket is closed twice, which is harmless — the extra close is
// discarded.
if v := cn.netConnAtomic.Load(); v != nil {
if wrapper, ok := v.(*atomicNetConn); ok && wrapper.conn != nil {
if wrapper.closed.CompareAndSwap(false, true) {
return wrapper.conn.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
}
// CheckForData reports whether the socket has data ready and surfaces a
// detected closed or failed socket.
func (cn *Conn) CheckForData() (bool, error) {
if netConn := cn.getNetConn(); netConn != nil {
return checkForData(netConn)
}
return false, nil
}
// MarkCscReadPending requests one conservative CSC drain after a command read
// when the transport may retain data that MaybeHasData cannot observe.
func (cn *Conn) MarkCscReadPending() {
netConn := cn.getNetConn()
if netConn == nil {
return
}
if needsCscReadProbe(netConn) {
cn.cscReadPending.Store(true)
}
}
// TakeCscReadPending consumes the post-command conservative-drain request.
func (cn *Conn) TakeCscReadPending() bool {
return cn.cscReadPending.Swap(false)
}
// TakeCscPeriodicReadPending schedules a throttled conservative read for
// transports with no readiness mechanism. It returns true at most once per
// interval, including when several drainer passes race.
func (cn *Conn) TakeCscPeriodicReadPending(interval time.Duration) bool {
netConn := cn.getNetConn()
if netConn == nil || interval <= 0 || !needsCscPeriodicProbe(netConn) {
return false
}
now := time.Since(cn.createdAt).Nanoseconds()
if now <= 0 {
now = 1
}
for {
last := cn.lastCscPeriodicProbeNs.Load()
if last != 0 && now >= last && now-last < int64(interval) {
return false
}
if cn.lastCscPeriodicProbeNs.CompareAndSwap(last, now) {
return true
}
}
}
// 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
}
//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{})
// Health checks deliberately inspect only the outer connection. Unwrapping a
// buffered transport such as crypto/tls.Conn can reveal an encrypted
// post-handshake record and make isHealthyConn call PeekReplyType on the TLS
// stream. With the deadline cleared above, TLS may consume that control record
// and then wait forever for application data.
sysConn, ok := conn.(syscall.Conn)
if !ok {
return nil
}
return checkSyscallConn(sysConn)
}
func checkSyscallConn(sysConn syscall.Conn) error {
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
}
// underlyingSyscallConn unwraps connections that expose their transport through
// NetConn (notably crypto/tls.Conn). Limit the walk so a broken wrapper cannot
// loop forever.
func underlyingSyscallConn(conn net.Conn) (syscall.Conn, bool) {
for range 8 {
if sysConn, ok := conn.(syscall.Conn); ok {
return sysConn, true
}
unwrapper, ok := conn.(interface{ NetConn() net.Conn })
if !ok {
return nil, false
}
conn = unwrapper.NetConn()
if conn == nil {
return nil, false
}
}
return nil, false
}
// maybeHasData checks if there is data in the socket without consuming it
func maybeHasData(conn net.Conn) bool {
hasData, _ := checkForData(conn)
return hasData
}
func checkForData(conn net.Conn) (bool, error) {
// Clear any residual READ deadline first: a prior command read (WithReader)
// leaves its deadline armed, and once it expires rawConn.Read fails fast
// with "raw-read ... i/o timeout" BEFORE the non-blocking peek runs — the
// caller would misread an idle-but-healthy conn as dead (the CSC drainer
// then removes it and evicts its cache coverage; with the full-duplex
// coalescer concentrating a cache's coverage on one held conn, that one
// spurious removal wipes the whole cache). Read-only on purpose, unlike the
// SetDeadline reset this replaced: checkForData runs CONCURRENTLY with
// command WRITES on a held full-duplex connection, and a full SetDeadline
// would strip an armed write deadline mid-write. No concurrent READ can
// race this: every caller either owns the conn's read side (FD session
// reader between reads) or holds the conn exclusively (drainer borrow,
// pool health check).
_ = conn.SetReadDeadline(time.Time{})
sysConn, ok := underlyingSyscallConn(conn)
if !ok {
return false, nil
}
switch err := checkSyscallConn(sysConn); err {
case nil:
return false, nil
case errUnexpectedRead:
return true, nil
default:
return false, err
}
}
// needsCscReadProbe reports whether a command read may leave data hidden from
// maybeHasData. On Unix a direct syscall.Conn has no intermediate buffering;
// TLS and opaque wrappers need one bounded post-command probe.
func needsCscReadProbe(conn net.Conn) bool {
_, direct := conn.(syscall.Conn)
return !direct
}
// needsCscPeriodicProbe reports whether the platform can inspect the transport
// at all. Opaque wrappers get a throttled bounded fallback so invalidations that
// arrive after the post-command probe are still eventually consumed.
func needsCscPeriodicProbe(conn net.Conn) bool {
_, ok := underlyingSyscallConn(conn)
return !ok
}
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
}
}
}
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
}
package pool
import (
"context"
"errors"
"math/rand"
"net"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/proto"
)
// Connection close reason constants for metrics.
// These are used as the "reason" parameter in CloseConn() calls.
const (
// CloseReasonStale indicates the connection was closed because it exceeded
// the idle timeout or max lifetime.
CloseReasonStale = "stale"
// CloseReasonHookError indicates the connection was closed due to an error
// in a pool hook (OnGet or OnPut).
CloseReasonHookError = "hook_error"
// CloseReasonAuthError indicates the connection was closed due to an
// authentication error during re-authentication.
CloseReasonAuthError = "auth_error"
// CloseReasonTest is used in tests when closing connections.
CloseReasonTest = "test"
// CloseReasonFailover indicates the connection was closed due to a failover event.
CloseReasonFailover = "failover"
// CloseReasonMaintNotificationsDisabled indicates the connection enabled
// maintenance notifications, but the client later downgraded and disabled
// maintenance notification handling for the pool.
CloseReasonMaintNotificationsDisabled = "maintnotifications_disabled"
)
// Metric state constants for connection state tracking.
// These represent the logical state of a connection from a metrics perspective,
// not the internal state machine state (ConnState).
const (
// MetricStateIdle indicates the connection is idle in the pool,
// ready to be acquired.
MetricStateIdle = "idle"
// MetricStateUsed indicates the connection is currently being used
// by a client operation.
MetricStateUsed = "used"
)
var (
// ErrClosed performs any operation on the closed client will return this error.
ErrClosed = errors.New("redis: client is closed")
// ErrPoolExhausted is returned from a pool connection method
// when the maximum number of database connections in the pool has been reached.
ErrPoolExhausted = errors.New("redis: connection pool exhausted")
// ErrPoolTimeout timed out waiting to get a connection from the connection pool.
ErrPoolTimeout = errors.New("redis: connection pool timeout")
// ErrPoolTryFull is the result of TryGet (the non-waiting acquire) when the
// pool has no free turn now. It is not a timeout, because nothing waited. A
// caller with a fallback uses it to spill at once. The pipeline pool spills to
// the main pool this way. ErrPoolTryFull is different from ErrPoolTimeout on
// purpose. Thus getConn does not record a false pool timeout (Stats.Timeouts or
// POOL_TIMEOUT) for a non-wait. A real wait still returns ErrPoolTimeout.
ErrPoolTryFull = errors.New("redis: connection pool has no free turn")
// ErrConnUnusableTimeout is returned when a connection is not usable and we timed out trying to mark it as unusable.
ErrConnUnusableTimeout = errors.New("redis: timed out trying to mark connection as unusable")
// errHookRequestedRemoval is returned when a hook requests connection removal.
errHookRequestedRemoval = errors.New("hook requested removal")
// errConnNotPooled is returned when trying to return a non-pooled connection to the pool.
errConnNotPooled = errors.New("connection not pooled")
// errConnEvictedIdle is passed to OnRemove hooks when a pooled connection is evicted on
// Put because the idle pool is already at MaxIdleConns.
errConnEvictedIdle = errors.New("connection evicted: idle pool at capacity")
// metricCallbackMu protects all global metric callback functions for thread-safe access.
metricCallbackMu sync.RWMutex
// Global metric callbacks for connection state changes
metricConnectionStateChangeCallback func(ctx context.Context, cn *Conn, fromState, toState string)
// Global metric callback for connection creation time
metricConnectionCreateTimeCallback func(ctx context.Context, duration time.Duration, cn *Conn)
// Global metric callback for connection relaxed timeout changes
// Parameters: ctx, delta (+1/-1), cn, poolName, notificationType
metricConnectionRelaxedTimeoutCallback func(ctx context.Context, delta int, cn *Conn, poolName, notificationType string)
// Global metric callback for connection handoff
// Parameters: ctx, cn, poolName
metricConnectionHandoffCallback func(ctx context.Context, cn *Conn, poolName string)
// Global metric callback for error tracking
// Parameters: ctx, errorType, cn, statusCode, isInternal, retryAttempts
metricErrorCallback func(ctx context.Context, errorType string, cn *Conn, statusCode string, isInternal bool, retryAttempts int)
// Global metric callback for maintenance notifications
// Parameters: ctx, cn, notificationType
metricMaintenanceNotificationCallback func(ctx context.Context, cn *Conn, notificationType string)
// Global metric callback for connection wait time
// Parameters: ctx, duration, cn
metricConnectionWaitTimeCallback func(ctx context.Context, duration time.Duration, cn *Conn)
// Global metric callback for connection timeouts
// Parameters: ctx, cn, timeoutType
metricConnectionTimeoutCallback func(ctx context.Context, cn *Conn, timeoutType string)
// Global metric callback for connection closed
// Parameters: ctx, cn, reason, err
metricConnectionClosedCallback func(ctx context.Context, cn *Conn, reason string, err error)
// Global metric callback for connection count changes (UpDownCounter)
// Parameters: ctx, delta (+1/-1), cn, state, isPubSub
metricConnectionCountCallback func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool)
// Global metric callback for pending requests changes (UpDownCounter)
// Parameters: ctx, delta (+1/-1), cn, poolName
// poolName is passed explicitly because we may not have a connection yet when request starts
metricPendingRequestsCallback func(ctx context.Context, delta int, cn *Conn, poolName string)
// errPanicInDial is returned when a panic occurs in the dial function.
errPanicInQueuedNewConn = errors.New("panic in queuedNewConn")
// popAttempts is the maximum number of attempts to find a usable connection
// when popping from the idle connection pool. This handles cases where connections
// are temporarily marked as unusable (e.g., during maintenanceNotifications upgrades or network issues).
// Value of 50 provides sufficient resilience without excessive overhead.
// This is capped by the idle connection count, so we won't loop excessively.
popAttempts = 50
// getAttempts is the maximum number of attempts to get a connection that passes
// hook validation (e.g., maintenanceNotifications upgrade hooks). This protects against race conditions
// where hooks might temporarily reject connections during cluster transitions.
// Value of 3 balances resilience with performance - most hook rejections resolve quickly.
getAttempts = 3
minTime = time.Unix(-2208988800, 0) // Jan 1, 1900
maxTime = minTime.Add(1<<63 - 1)
noExpiration = maxTime
)
// MetricCallbacks holds all metric callback functions.
// Use SetAllMetricCallbacks to register all callbacks atomically.
type MetricCallbacks struct {
// ConnectionCreateTime is called when a new connection is created
ConnectionCreateTime func(ctx context.Context, duration time.Duration, cn *Conn)
// ConnectionRelaxedTimeout is called when connection timeout is relaxed/unrelaxed
// delta: +1 for relaxed, -1 for unrelaxed
ConnectionRelaxedTimeout func(ctx context.Context, delta int, cn *Conn, poolName, notificationType string)
// ConnectionHandoff is called when a connection is handed off to another node
ConnectionHandoff func(ctx context.Context, cn *Conn, poolName string)
// Error is called when an error occurs
Error func(ctx context.Context, errorType string, cn *Conn, statusCode string, isInternal bool, retryAttempts int)
// MaintenanceNotification is called when a maintenance notification is received
MaintenanceNotification func(ctx context.Context, cn *Conn, notificationType string)
// ConnectionWaitTime is called to record time spent waiting for a connection
ConnectionWaitTime func(ctx context.Context, duration time.Duration, cn *Conn)
// ConnectionClosed is called when a connection is closed
ConnectionClosed func(ctx context.Context, cn *Conn, reason string, err error)
// ConnectionCount is called when connection count changes (UpDownCounter)
// delta: +1 when connection added, -1 when connection removed
// state: connection state (e.g., "idle", "used")
// isPubSub: true if this is a PubSub connection
ConnectionCount func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool)
// PendingRequests is called when pending requests count changes (UpDownCounter)
// delta: +1 when request starts waiting, -1 when request stops waiting
// poolName is passed explicitly because we may not have a connection yet when request starts
PendingRequests func(ctx context.Context, delta int, cn *Conn, poolName string)
}
// SetAllMetricCallbacks sets all metric callbacks atomically.
// Pass nil to clear all callbacks (disable metrics).
// This ensures all callbacks are set together under a single lock,
// preventing inconsistent state during registration.
//
// Note on thread safety: After returning, there is a small window where
// concurrent getMetric* calls may return the old callback value. This is
// acceptable for metrics - at most one event may go to the old recorder
// or be missed during the transition. The callbacks themselves are immutable
// function pointers, so calling an "old" callback is safe.
func SetAllMetricCallbacks(callbacks *MetricCallbacks) {
metricCallbackMu.Lock()
defer metricCallbackMu.Unlock()
if callbacks == nil {
metricConnectionCreateTimeCallback = nil
metricConnectionRelaxedTimeoutCallback = nil
metricConnectionHandoffCallback = nil
metricErrorCallback = nil
metricMaintenanceNotificationCallback = nil
metricConnectionWaitTimeCallback = nil
metricConnectionClosedCallback = nil
metricConnectionCountCallback = nil
metricPendingRequestsCallback = nil
return
}
metricConnectionCreateTimeCallback = callbacks.ConnectionCreateTime
metricConnectionRelaxedTimeoutCallback = callbacks.ConnectionRelaxedTimeout
metricConnectionHandoffCallback = callbacks.ConnectionHandoff
metricErrorCallback = callbacks.Error
metricMaintenanceNotificationCallback = callbacks.MaintenanceNotification
metricConnectionWaitTimeCallback = callbacks.ConnectionWaitTime
metricConnectionClosedCallback = callbacks.ConnectionClosed
metricConnectionCountCallback = callbacks.ConnectionCount
metricPendingRequestsCallback = callbacks.PendingRequests
}
// getMetricConnectionStateChangeCallback returns the metric callback for connection state changes.
func getMetricConnectionStateChangeCallback() func(ctx context.Context, cn *Conn, fromState, toState string) {
metricCallbackMu.RLock()
cb := metricConnectionStateChangeCallback
metricCallbackMu.RUnlock()
return cb
}
// GetMetricConnectionCreateTimeCallback returns the metric callback for connection creation time.
func GetMetricConnectionCreateTimeCallback() func(ctx context.Context, duration time.Duration, cn *Conn) {
metricCallbackMu.RLock()
cb := metricConnectionCreateTimeCallback
metricCallbackMu.RUnlock()
return cb
}
// GetMetricConnectionRelaxedTimeoutCallback returns the metric callback for connection relaxed timeout changes.
// This is used by maintnotifications to record relaxed timeout metrics.
func GetMetricConnectionRelaxedTimeoutCallback() func(ctx context.Context, delta int, cn *Conn, poolName, notificationType string) {
metricCallbackMu.RLock()
cb := metricConnectionRelaxedTimeoutCallback
metricCallbackMu.RUnlock()
return cb
}
// GetMetricConnectionHandoffCallback returns the metric callback for connection handoffs.
// This is used by maintnotifications to record handoff metrics.
func GetMetricConnectionHandoffCallback() func(ctx context.Context, cn *Conn, poolName string) {
metricCallbackMu.RLock()
cb := metricConnectionHandoffCallback
metricCallbackMu.RUnlock()
return cb
}
// GetMetricErrorCallback returns the metric callback for error tracking.
// This is used by cluster and client code to record error metrics.
func GetMetricErrorCallback() func(ctx context.Context, errorType string, cn *Conn, statusCode string, isInternal bool, retryAttempts int) {
metricCallbackMu.RLock()
cb := metricErrorCallback
metricCallbackMu.RUnlock()
return cb
}
// GetMetricMaintenanceNotificationCallback returns the metric callback for maintenance notifications.
// This is used by maintnotifications to record notification metrics.
func GetMetricMaintenanceNotificationCallback() func(ctx context.Context, cn *Conn, notificationType string) {
metricCallbackMu.RLock()
cb := metricMaintenanceNotificationCallback
metricCallbackMu.RUnlock()
return cb
}
func getMetricConnectionWaitTimeCallback() func(ctx context.Context, duration time.Duration, cn *Conn) {
metricCallbackMu.RLock()
cb := metricConnectionWaitTimeCallback
metricCallbackMu.RUnlock()
return cb
}
func getMetricConnectionTimeoutCallback() func(ctx context.Context, cn *Conn, timeoutType string) {
metricCallbackMu.RLock()
cb := metricConnectionTimeoutCallback
metricCallbackMu.RUnlock()
return cb
}
func getMetricConnectionClosedCallback() func(ctx context.Context, cn *Conn, reason string, err error) {
metricCallbackMu.RLock()
cb := metricConnectionClosedCallback
metricCallbackMu.RUnlock()
return cb
}
// getMetricConnectionCountCallback returns the metric callback for connection count changes (UpDownCounter).
func getMetricConnectionCountCallback() func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool) {
metricCallbackMu.RLock()
cb := metricConnectionCountCallback
metricCallbackMu.RUnlock()
return cb
}
// getMetricPendingRequestsCallback returns the metric callback for pending requests changes (UpDownCounter).
func getMetricPendingRequestsCallback() func(ctx context.Context, delta int, cn *Conn, poolName string) {
metricCallbackMu.RLock()
cb := metricPendingRequestsCallback
metricCallbackMu.RUnlock()
return cb
}
// Stats contains pool state information and accumulated stats.
//
// TODO(cxl): the uint32/int64 fields below will be changed to atomic value
// types (atomic.Uint32/atomic.Int64) in v10, which is a breaking API change.
type Stats struct {
Hits uint32 // number of times free connection was found in the pool
Misses uint32 // number of times free connection was NOT found in the pool
Timeouts uint32 // number of times a wait timeout occurred
WaitCount uint32 // number of times a connection was waited
Unusable uint32 // number of times a connection was found to be unusable
WaitDurationNs int64 // total time spent for waiting a connection in nanoseconds
TotalConns uint32 // number of total connections in the pool
IdleConns uint32 // number of idle connections in the pool
StaleConns uint32 // number of stale connections removed from the pool
PendingRequests uint32 // number of pending requests waiting for a connection
PubSubStats PubSubStats
// PipelineStats holds the stats of the separate pipeline connection pool
// when one is configured (PipelineReadBufferSize/PipelineWriteBufferSize).
// nil when pipelines share the main pool.
PipelineStats *Stats
}
type ConnRetirer interface {
RetireConns(ctx context.Context, conns []*Conn, reason string)
}
type Pooler interface {
NewConn(context.Context) (*Conn, error)
CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error
Get(context.Context) (*Conn, error)
Put(context.Context, *Conn)
Remove(context.Context, *Conn, error)
Len() int
IdleLen() int
Stats() *Stats
// Size returns the maximum pool size (capacity).
// This is used by the streaming credentials manager to size the re-auth worker pool.
Size() int
AddPoolHook(hook PoolHook)
RemovePoolHook(hook PoolHook)
// RemoveWithoutTurn removes a connection from the pool without freeing a turn.
// This should be used when removing a connection from a context that didn't acquire
// a turn via Get() (e.g., background workers, cleanup tasks).
// For normal removal after Get(), use Remove() instead.
RemoveWithoutTurn(context.Context, *Conn, error)
Close() error
}
type Options struct {
Dialer func(context.Context) (net.Conn, error)
ReadBufferSize int
WriteBufferSize int
PoolFIFO bool
PoolSize int32
MaxConcurrentDials int
DialTimeout time.Duration
PoolTimeout time.Duration
MinIdleConns int32
MaxIdleConns int32
MaxActiveConns int32
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
ConnMaxLifetimeJitter time.Duration
PushNotificationsEnabled bool
// DialerRetries is the maximum number of retry attempts when dialing fails.
// Default: 5
DialerRetries int
// DialerRetryTimeout is the backoff duration between retry attempts.
// Default: 100ms
DialerRetryTimeout time.Duration
// DialerRetryBackoff controls the delay between dial retry attempts.
// If nil, dial retry backoff is constant and equals DialerRetryTimeout (default: 100ms).
DialerRetryBackoff func(attempt int) time.Duration
// Name is a unique identifier for this pool, used in metrics.
// Format: addr_uniqueID (e.g., "localhost:6379_a1b2c3d4")
Name string
}
type lastDialErrorWrap struct {
err error
}
type ConnPool struct {
cfg *Options
dialErrorsNum atomic.Uint32
lastDialError atomic.Value
dialsInProgress chan struct{}
dialsQueue *wantConnQueue
// Fast semaphore for connection limiting with eventual fairness
// Uses fast path optimization to avoid timer allocation when tokens are available
semaphore *internal.FastSemaphore
connsMu sync.Mutex
conns map[uint64]*Conn
idleConns []*Conn
poolSize atomic.Int32
idleConnsLen atomic.Int32
idleCheckInProgress atomic.Bool
idleCheckNeeded atomic.Bool
stats Stats
waitDurationNs atomic.Int64
_closed atomic.Uint32
// Pool hooks manager. atomic.Pointer keeps hot-path reads (Get/Put)
// lock-free; hookMu serializes Add/RemovePoolHook's read-clone-store so
// concurrent mutators (e.g. maintnotifications and CSC) can't lose an update.
hookManager atomic.Pointer[PoolHookManager]
hookMu sync.Mutex
// drainMu/drainDone coordinate the CSC drainer's temporary idle-connection
// claim with Get. The normal semaphore retains its PoolSize capacity (and
// therefore the established MaxActiveConns/ErrPoolExhausted behavior); a Get
// that finds the idle list empty only because the drainer borrowed a conn
// waits for that short claim to finish instead of opening an overflow conn.
drainMu sync.Mutex
drainDone chan struct{}
drainBorrowed int
drainGeneration atomic.Uint64
}
var _ Pooler = (*ConnPool)(nil)
func NewConnPool(opt *Options) *ConnPool {
p := &ConnPool{
cfg: opt,
semaphore: internal.NewFastSemaphore(opt.PoolSize),
conns: make(map[uint64]*Conn),
dialsInProgress: make(chan struct{}, opt.MaxConcurrentDials),
dialsQueue: newWantConnQueue(),
idleConns: make([]*Conn, 0, opt.PoolSize),
}
// Only create MinIdleConns if explicitly requested (> 0)
// This avoids creating connections during pool initialization for tests
if opt.MinIdleConns > 0 {
p.connsMu.Lock()
p.checkMinIdleConns()
p.connsMu.Unlock()
}
return p
}
// initializeHooks sets up the pool hooks system.
func (p *ConnPool) initializeHooks() {
manager := NewPoolHookManager()
p.hookManager.Store(manager)
}
// AddPoolHook adds a pool hook to the pool.
func (p *ConnPool) AddPoolHook(hook PoolHook) {
// Serialize so a concurrent Add/Remove can't clobber this change.
p.hookMu.Lock()
defer p.hookMu.Unlock()
manager := p.hookManager.Load()
if manager == nil {
p.initializeHooks()
manager = p.hookManager.Load()
}
// Create new manager with added hook
newManager := manager.Clone()
newManager.AddHook(hook)
// Atomically swap to new manager (hot-path readers load lock-free)
p.hookManager.Store(newManager)
}
// SupportsPoolHooks reports that AddPoolHook and RemovePoolHook are functional.
// Pooler adapters with no-op hook methods intentionally do not expose this
// optional capability.
func (p *ConnPool) SupportsPoolHooks() bool {
return true
}
// RemovePoolHook removes a pool hook from the pool.
func (p *ConnPool) RemovePoolHook(hook PoolHook) {
p.hookMu.Lock()
defer p.hookMu.Unlock()
manager := p.hookManager.Load()
if manager != nil {
// Create new manager with removed hook
newManager := manager.Clone()
newManager.RemoveHook(hook)
// Atomically swap to new manager
p.hookManager.Store(newManager)
}
}
func (p *ConnPool) checkMinIdleConns() {
// If a check is already in progress, mark that we need another check and return
if !p.idleCheckInProgress.CompareAndSwap(false, true) {
p.idleCheckNeeded.Store(true)
return
}
if p.cfg.MinIdleConns == 0 {
p.idleCheckInProgress.Store(false)
return
}
// Keep checking until no more checks are needed
// This handles the case where multiple Remove() calls happen concurrently
for {
// Clear the "check needed" flag before we start
p.idleCheckNeeded.Store(false)
// Only create idle connections if we haven't reached the total pool size limit
// MinIdleConns should be a subset of PoolSize, not additional connections
for p.poolSize.Load() < p.cfg.PoolSize && p.idleConnsLen.Load() < p.cfg.MinIdleConns {
// Try to acquire a semaphore token
if !p.semaphore.TryAcquire() {
// Semaphore is full, can't create more connections right now
// Break out of inner loop to check if we need to retry
break
}
p.poolSize.Add(1)
p.idleConnsLen.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
p.freeTurn()
internal.Logger.Printf(context.Background(), "addIdleConn panic: %+v", err)
}
}()
err := p.addIdleConn()
if err != nil && err != ErrClosed {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
}
p.freeTurn()
}()
}
// If no one requested another check while we were working, we're done
if !p.idleCheckNeeded.Load() {
p.idleCheckInProgress.Store(false)
return
}
// Otherwise, loop again to handle the new requests
}
}
func (p *ConnPool) addIdleConn() error {
// Do not apply DialTimeout via context here; dialConn applies DialTimeout per attempt.
cn, err := p.dialConn(context.Background(), true)
if err != nil {
return err
}
// NOTE: Connection is in CREATED state and will be initialized by redis.go:initConn()
// when first acquired from the pool. Do NOT transition to IDLE here - that happens
// after initialization completes.
p.connsMu.Lock()
defer p.connsMu.Unlock()
// It is not allowed to add new connections to the closed connection pool.
if p.closed() {
_ = cn.Close()
return ErrClosed
}
p.conns[cn.GetID()] = cn
p.idleConns = append(p.idleConns, cn)
// Record connection count increment (new idle connection from min-idle prewarm)
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(context.Background(), 1, cn, "idle", false)
}
return nil
}
// NewConn creates a new connection and returns it to the user.
// This will still obey MaxActiveConns but will not include it in the pool and won't increase the pool size.
//
// NOTE: If you directly get a connection from the pool, it won't be pooled and won't support maintnotifications upgrades.
func (p *ConnPool) NewConn(ctx context.Context) (*Conn, error) {
return p.newConn(ctx, false)
}
func (p *ConnPool) newConn(ctx context.Context, pooled bool) (*Conn, error) {
if p.closed() {
return nil, ErrClosed
}
if p.cfg.MaxActiveConns > 0 && p.poolSize.Load() >= p.cfg.MaxActiveConns {
return nil, ErrPoolExhausted
}
// Protect against nil context due to race condition in queuedNewConn
// where the context can be set to nil after timeout/cancellation
if ctx == nil {
ctx = context.Background()
}
// Do not apply DialTimeout via context here; dialConn applies DialTimeout per attempt.
// We still propagate ctx so callers can cancel explicitly.
cn, err := p.dialConn(ctx, pooled)
if err != nil {
return nil, err
}
// NOTE: Connection is in CREATED state and will be initialized by redis.go:initConn()
// when first used. Do NOT transition to IDLE here - that happens after initialization completes.
// The state machine flow is: CREATED → INITIALIZING (in initConn) → IDLE (after init success)
if p.cfg.MaxActiveConns > 0 && p.poolSize.Load() > p.cfg.MaxActiveConns {
_ = cn.Close()
return nil, ErrPoolExhausted
}
p.connsMu.Lock()
defer p.connsMu.Unlock()
if p.closed() {
_ = cn.Close()
return nil, ErrClosed
}
// Check if pool was closed while we were waiting for the lock
if p.conns == nil {
p.conns = make(map[uint64]*Conn)
}
p.conns[cn.GetID()] = cn
if pooled {
// If pool is full remove the cn on next Put.
currentPoolSize := p.poolSize.Load()
if currentPoolSize >= p.cfg.PoolSize {
cn.pooled = false
} else {
p.poolSize.Add(1)
}
}
// All new connections start as "used" metrically. For the miss path in getConn,
// this is the final state. For putIdleConn (undelivered conn), a used→idle
// transition is emitted when it's added to idleConns.
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, "", MetricStateUsed)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, 1, cn, "used", false)
}
return cn, nil
}
func (p *ConnPool) dialConn(ctx context.Context, pooled bool) (*Conn, error) {
if p.closed() {
return nil, ErrClosed
}
if p.dialErrorsNum.Load() >= uint32(p.cfg.PoolSize) {
return nil, p.getLastDialError()
}
// Record dial start time for connection creation metric
// This will be used after handshake completes in redis.go _getConn()
// Only call time.Now() if callback is registered to avoid overhead
var dialStartNs int64
if GetMetricConnectionCreateTimeCallback() != nil {
dialStartNs = time.Now().UnixNano()
}
// Retry dialing with backoff
// Dial timeout is applied per attempt (so retries/backoff don't eat into the next
// attempt's dial budget), while still honoring caller cancellation via ctx.
maxRetries := p.cfg.DialerRetries
if maxRetries <= 0 {
maxRetries = 5 // Default value
}
var lastErr error
shouldLoop := true
// when the timeout is reached, we should stop retrying
// but keep the lastErr to return to the caller
// instead of a generic context deadline exceeded error
attempt := 0
for attempt = 0; (attempt < maxRetries) && shouldLoop; attempt++ {
attemptCtx := ctx
var cancel context.CancelFunc
if p.cfg.DialTimeout > 0 {
// Apply DialTimeout per attempt, but never extend an existing earlier deadline.
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > p.cfg.DialTimeout {
attemptCtx, cancel = context.WithTimeout(ctx, p.cfg.DialTimeout)
}
}
netConn, err := p.cfg.Dialer(attemptCtx)
if cancel != nil {
cancel()
}
if err != nil {
lastErr = err
// Add backoff delay for retry attempts
// (not for the first attempt, do at least one)
// Do not sleep after the last attempt.
if attempt+1 < maxRetries {
backoffDuration := p.dialRetryBackoff(attempt)
select {
case <-ctx.Done():
shouldLoop = false
case <-time.After(backoffDuration):
// Continue with retry
}
}
continue
}
cn := NewConnWithBufferSize(netConn, p.cfg.ReadBufferSize, p.cfg.WriteBufferSize)
cn.pooled = pooled
// Store dial start time only if we recorded it
if dialStartNs > 0 {
cn.dialStartNs.Store(dialStartNs)
}
cn.expiresAt = p.calcConnExpiresAt()
// Set pool name for metrics
cn.SetPoolName(p.cfg.Name)
return cn, nil
}
internal.Logger.Printf(ctx, "redis: connection pool: failed to dial after %d attempts: %v", attempt, lastErr)
// All retries failed - handle error tracking
p.setLastDialError(lastErr)
if p.dialErrorsNum.Add(1) == uint32(p.cfg.PoolSize) {
go p.tryDial()
}
return nil, lastErr
}
func (p *ConnPool) dialRetryBackoff(attempt int) time.Duration {
if p.cfg.DialerRetryBackoff != nil {
d := p.cfg.DialerRetryBackoff(attempt)
if d < 0 {
return 0
}
return d
}
base := p.cfg.DialerRetryTimeout
if base <= 0 {
base = 100 * time.Millisecond
}
return base
}
// calcConnExpiresAt calculates the expiration time for a connection.
// It applies random jitter to prevent all connections from expiring simultaneously,
// avoiding the "thundering herd" problem where all connections expire at once.
// Returns noExpiration if ConnMaxLifetime is not set.
func (p *ConnPool) calcConnExpiresAt() time.Time {
if p.cfg.ConnMaxLifetime <= 0 {
return noExpiration
}
if p.cfg.ConnMaxLifetimeJitter <= 0 {
return time.Now().Add(p.cfg.ConnMaxLifetime)
}
jitter := p.cfg.ConnMaxLifetimeJitter
jitterRange := jitter.Nanoseconds() * 2
jitterNs := rand.Int63n(jitterRange) - jitter.Nanoseconds()
return time.Now().Add(p.cfg.ConnMaxLifetime + time.Duration(jitterNs))
}
func (p *ConnPool) tryDial() {
for {
if p.closed() {
return
}
// Probe dialing even when dialErrorsNum is saturated. Apply DialTimeout per probe
// attempt so custom dialers can't hang indefinitely.
ctx := context.Background()
var cancel context.CancelFunc
if p.cfg.DialTimeout > 0 {
ctx, cancel = context.WithTimeout(ctx, p.cfg.DialTimeout)
}
conn, err := p.cfg.Dialer(ctx)
if cancel != nil {
cancel()
}
if err != nil {
p.setLastDialError(err)
time.Sleep(time.Second)
continue
}
p.dialErrorsNum.Store(0)
_ = conn.Close()
return
}
}
func (p *ConnPool) setLastDialError(err error) {
p.lastDialError.Store(&lastDialErrorWrap{err: err})
}
func (p *ConnPool) getLastDialError() error {
err, _ := p.lastDialError.Load().(*lastDialErrorWrap)
if err != nil {
return err.err
}
return nil
}
// Get returns existed connection from the pool or creates a new one.
func (p *ConnPool) Get(ctx context.Context) (*Conn, error) {
return p.getConn(ctx, true)
}
// TryGet returns a connection only if a pool turn is free now. A free turn is an
// open slot or room to dial a new connection. TryGet never waits out PoolTimeout
// for a full pool. It returns ErrPoolTryFull at once instead. This lets a caller
// with a fallback (the pipeline pool) spill without a stall. ErrPoolTryFull is
// different from ErrPoolTimeout: no wait happened, so getConn does not count the
// spill as a pool timeout. A hard MaxActiveConns ceiling still returns
// ErrPoolExhausted. TryGet still dials a new connection under an acquired turn.
func (p *ConnPool) TryGet(ctx context.Context) (*Conn, error) {
return p.getConn(ctx, false)
}
// getConn returns a connection from the pool. When wait is false it does not block
// for a turn (see waitTurn / TryGet).
func (p *ConnPool) getConn(ctx context.Context, wait bool) (cn *Conn, err error) {
if p.closed() {
return nil, ErrClosed
}
// Track pending requests in pool stats
atomic.AddUint32(&p.stats.PendingRequests, 1)
// Record pending request increment (UpDownCounter)
// Pass pool name explicitly since we don't have a connection yet
poolName := p.cfg.Name
if cb := getMetricPendingRequestsCallback(); cb != nil {
cb(ctx, 1, nil, poolName)
}
defer func() {
if err != nil {
// Failed to get connection, decrement pending requests
atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1
// Record pending request decrement on failure
if cb := getMetricPendingRequestsCallback(); cb != nil {
cb(ctx, -1, nil, poolName)
}
}
if err == ErrPoolTimeout {
atomic.AddUint32(&p.stats.Timeouts, 1)
if cb := getMetricConnectionTimeoutCallback(); cb != nil {
cb(ctx, nil, "pool")
}
if cb := GetMetricErrorCallback(); cb != nil {
cb(ctx, "POOL_TIMEOUT", nil, "POOL_TIMEOUT", true, 0)
}
}
}()
// PoolTimeout is one budget for both the pool turn and a drainer handoff.
poolDeadline := time.Now().Add(p.cfg.PoolTimeout)
// Connection wait time measures only semaphore acquisition.
var waitStart time.Time
var waitDuration time.Duration
waitTimeCallback := getMetricConnectionWaitTimeCallback()
if waitTimeCallback != nil {
waitStart = time.Now()
}
if err = p.waitTurn(ctx, wait); err != nil {
return nil, err
}
if waitTimeCallback != nil {
waitDuration = time.Since(waitStart)
}
// Use cached time for health checks (max 50ms staleness is acceptable)
nowNs := getCachedTimeNs()
// Lock-free atomic read - no mutex overhead!
hookManager := p.hookManager.Load()
retryIdle:
drainGeneration := p.drainGeneration.Load()
for attempts := 0; attempts < getAttempts; attempts++ {
p.connsMu.Lock()
cn, err = p.popIdle()
if cn != nil {
// Emit idle→used transition inside the lock so Close() sees
// consistent state (conn removed from idleConns = "used").
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateIdle, MetricStateUsed)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "idle", false)
cb(ctx, 1, cn, "used", false)
}
}
p.connsMu.Unlock()
if err != nil {
p.freeTurn()
return nil, err
}
if cn == nil {
break
}
if !p.isHealthyConn(cn, nowNs) {
// Connection was already transitioned to MetricStateUsed under the lock above.
_ = p.CloseConn(ctx, cn, CloseReasonStale, MetricStateUsed)
continue
}
// Process connection using the hooks system
// Combine error and rejection checks to reduce branches
if hookManager != nil {
acceptConn, hookErr := hookManager.ProcessOnGet(ctx, cn, false)
if hookErr != nil || !acceptConn {
if hookErr != nil {
internal.Logger.Printf(ctx, "redis: connection pool: failed to process idle connection by hook: %v", hookErr)
// Connection was already transitioned to MetricStateUsed under the lock above.
_ = p.CloseConn(ctx, cn, CloseReasonHookError, MetricStateUsed)
} else {
internal.Logger.Printf(ctx, "redis: connection pool: conn[%d] rejected by hook, returning to pool", cn.GetID())
// Connection is already in MetricStateUsed (transitioned under the lock above).
// Return connection to pool without freeing the turn that this Get() call holds.
// putConnWithoutTurn will emit used→idle transition.
p.putConnWithoutTurn(ctx, cn)
cn = nil
}
continue
}
}
atomic.AddUint32(&p.stats.Hits, 1)
// Record wait time (use cached callback from above)
if waitTimeCallback != nil {
waitTimeCallback(ctx, waitDuration, cn)
}
// Decrement pending requests (connection acquired successfully)
atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1
// Record pending request decrement (UpDownCounter)
if cb := getMetricPendingRequestsCallback(); cb != nil {
cb(ctx, -1, cn, poolName)
}
return cn, nil
}
// If the CSC drainer removed the only idle connection during this scan,
// wait for that bounded maintenance claim and retry. The generation closes
// the race where the drainer returns the connection between popIdle and this
// check. Normal MaxActiveConns exhaustion still proceeds to newConn and
// returns ErrPoolExhausted immediately, preserving the existing contract.
if done, retry := p.drainerWaitState(drainGeneration); done != nil {
if !wait {
// TryGet must not wait out the drainer's bounded claim (up to PoolTimeout)
// either — spill immediately, like the pool-turn and dial-permit
// non-blocking paths, so a pipeline falls back to the main pool at once.
p.freeTurn()
return nil, ErrPoolTryFull
}
if err = p.waitForDrainer(ctx, done, poolDeadline); err != nil {
p.freeTurn()
return nil, err
}
goto retryIdle
} else if retry {
goto retryIdle
}
atomic.AddUint32(&p.stats.Misses, 1)
var newcn *Conn
newcn, err = p.queuedNewConn(ctx, wait)
if err != nil {
return nil, err
}
// Process connection using the hooks system
// This includes the handshake (HELLO/AUTH) via initConn hook
if hookManager != nil {
var acceptConn bool
acceptConn, err = hookManager.ProcessOnGet(ctx, newcn, true)
// both errors and accept=false mean a hook rejected the connection
// this should not happen with a new connection, but we handle it gracefully
if err != nil || !acceptConn {
internal.Logger.Printf(ctx, "redis: connection pool: failed to process new connection conn[%d] by hook: accept=%v, err=%v", newcn.GetID(), acceptConn, err)
// newConn emitted +1 used; CloseConn will emit -1 used if we own the removal.
_ = p.CloseConn(ctx, newcn, CloseReasonHookError, MetricStateUsed)
return nil, err
}
// Record connection creation time metric when hooks are used.
// When hookManager is set, ProcessOnGet initializes the connection (AUTH/HELLO),
// causing IsInited()=true. This means _getConn() in redis.go will take the
// early return path and never reach its create time recording.
// When hookManager is nil, _getConn() handles both initialization and create time recording.
if dialStartNs := newcn.GetDialStartNs(); newcn.IsInited() && dialStartNs > 0 {
if cb := GetMetricConnectionCreateTimeCallback(); cb != nil {
duration := time.Duration(time.Now().UnixNano() - dialStartNs)
cb(ctx, duration, newcn)
}
}
}
// newConn already emitted +1 used, so no transition needed here.
// Record wait time (use cached callback from above)
if waitTimeCallback != nil {
waitTimeCallback(ctx, waitDuration, newcn)
}
// Decrement pending requests (connection acquired successfully)
atomic.AddUint32(&p.stats.PendingRequests, ^uint32(0)) // -1
// Record pending request decrement (UpDownCounter)
if cb := getMetricPendingRequestsCallback(); cb != nil {
cb(ctx, -1, newcn, poolName)
}
return newcn, nil
}
func (p *ConnPool) queuedNewConn(ctx context.Context, wait bool) (*Conn, error) {
if wait {
select {
case p.dialsInProgress <- struct{}{}:
// Got permission, proceed to create connection
case <-ctx.Done():
p.freeTurn()
return nil, ctx.Err()
}
} else {
// TryGet: MaxConcurrentDials can be below PoolSize, so a pool turn may be
// free while every dial permit is held by an in-flight dial. A blocking send
// here would make TryGet wait out another caller's whole dial retry sequence,
// violating its non-blocking contract. Give up immediately (spill) instead.
select {
case p.dialsInProgress <- struct{}{}:
// Got permission, proceed to create connection
default:
p.freeTurn()
return nil, ErrPoolTryFull
}
}
// Don't apply DialTimeout via context here; dialConn applies DialTimeout per attempt.
dialCtx, cancel := context.WithCancel(context.Background())
w := &wantConn{
ctx: dialCtx,
cancelCtx: cancel,
result: make(chan wantConnResult, 1),
}
var err error
defer func() {
if err != nil {
if cn := w.cancel(); cn != nil && p.putIdleConn(ctx, cn) {
p.freeTurn()
}
}
}()
p.dialsQueue.discardDoneAtFront()
p.dialsQueue.enqueue(w)
go func(w *wantConn) {
var freeTurnCalled bool
defer func() {
if err := recover(); err != nil {
w.tryDeliver(nil, errPanicInQueuedNewConn)
p.dialsQueue.discardDoneAtFront()
if !freeTurnCalled {
p.freeTurn()
}
internal.Logger.Printf(context.Background(), "queuedNewConn panic: %+v", err)
}
}()
defer w.cancelCtx()
defer func() { <-p.dialsInProgress }() // Release connection creation permission
dialCtx := w.getCtxForDial()
cn, cnErr := p.newConn(dialCtx, true)
if cnErr != nil {
w.tryDeliver(nil, cnErr) // deliver error to caller, notify connection creation failed
p.dialsQueue.discardDoneAtFront()
p.freeTurn()
freeTurnCalled = true
return
}
delivered := w.tryDeliver(cn, cnErr)
p.dialsQueue.discardDoneAtFront()
if !delivered && p.putIdleConn(dialCtx, cn) {
p.freeTurn()
freeTurnCalled = true
}
}(w)
select {
case <-ctx.Done():
err = ctx.Err()
return nil, err
case result := <-w.result:
err = result.err
return result.cn, err
}
}
// putIdleConn puts a connection back to the pool or passes it to the next waiting request.
//
// It returns true if the connection was put back to the pool,
// which means the turn needs to be freed directly by the caller,
// or false if the connection was passed to the next waiting request,
// which means the turn will be freed by the waiting goroutine after it returns.
func (p *ConnPool) putIdleConn(ctx context.Context, cn *Conn) bool {
for {
w, ok := p.dialsQueue.dequeue()
if !ok {
break
}
if w.tryDeliver(cn, nil) {
return false
}
}
p.connsMu.Lock()
defer p.connsMu.Unlock()
if p.closed() {
// Don't close here — this connection is still in p.conns and Close()
// will handle closing it and emitting the correct metric decrements.
// We just skip adding it to idleConns.
return true
}
p.idleConns = append(p.idleConns, cn)
p.idleConnsLen.Add(1)
// Connection was created as "used" in newConn; transition to idle.
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, MetricStateIdle)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
cb(ctx, 1, cn, "idle", false)
}
return true
}
func (p *ConnPool) waitTurn(ctx context.Context, wait bool) error {
// Fast path: check context first
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Fast path: try to acquire without blocking
if p.semaphore.TryAcquire() {
return nil
}
// Non-waiting acquire (TryGet): the pool is full and no turn is free now.
// Return at once. Do not wait out PoolTimeout. The caller (the pipeline pool)
// then spills to the main pool at once. Return ErrPoolTryFull, not
// ErrPoolTimeout, because nothing waited. Thus getConn does not count a timeout.
if !wait {
return ErrPoolTryFull
}
// Slow path: need to wait
start := time.Now()
err := p.semaphore.Acquire(ctx, p.cfg.PoolTimeout, ErrPoolTimeout)
if err != nil {
return err
}
p.waitDurationNs.Add(time.Now().UnixNano() - start.UnixNano())
atomic.AddUint32(&p.stats.WaitCount, 1)
return nil
}
func (p *ConnPool) freeTurn() {
p.semaphore.Release()
}
func (p *ConnPool) beginDrainerBorrow() {
p.drainMu.Lock()
if p.drainBorrowed == 0 {
p.drainDone = make(chan struct{})
}
p.drainBorrowed++
p.drainMu.Unlock()
}
func (p *ConnPool) endDrainerBorrow() {
p.drainMu.Lock()
p.drainBorrowed--
if p.drainBorrowed == 0 {
close(p.drainDone)
p.drainDone = nil
p.drainGeneration.Add(1)
}
p.drainMu.Unlock()
}
// drainerWaitState returns the current drain epoch's completion channel. If no
// drain is active, retry reports whether an epoch completed during the caller's
// idle scan and the idle list therefore needs to be checked again.
func (p *ConnPool) drainerWaitState(generation uint64) (done <-chan struct{}, retry bool) {
p.drainMu.Lock()
defer p.drainMu.Unlock()
if p.drainBorrowed > 0 {
return p.drainDone, false
}
return nil, p.drainGeneration.Load() != generation
}
func (p *ConnPool) waitForDrainer(
ctx context.Context, done <-chan struct{}, poolDeadline time.Time,
) error {
if err := ctx.Err(); err != nil {
return err
}
select {
case <-done:
return nil
default:
}
remaining := time.Until(poolDeadline)
if remaining <= 0 {
return ErrPoolTimeout
}
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
// Prefer a caller cancellation that raced with the pool timeout.
if err := ctx.Err(); err != nil {
return err
}
return ErrPoolTimeout
}
}
func (p *ConnPool) popIdle() (*Conn, error) {
if p.closed() {
return nil, ErrClosed
}
defer p.checkMinIdleConns()
n := len(p.idleConns)
if n == 0 {
return nil, nil
}
var cn *Conn
attempts := 0
maxAttempts := min(popAttempts, n)
for attempts < maxAttempts {
if len(p.idleConns) == 0 {
return nil, nil
}
if p.cfg.PoolFIFO {
cn = p.idleConns[0]
copy(p.idleConns, p.idleConns[1:])
p.idleConns = p.idleConns[:len(p.idleConns)-1]
} else {
idx := len(p.idleConns) - 1
cn = p.idleConns[idx]
p.idleConns = p.idleConns[:idx]
}
attempts++
// Hot path optimization: try IDLE → IN_USE or CREATED → IN_USE transition
// Using inline TryAcquire() method for better performance (avoids pointer dereference)
if cn.TryAcquire() {
// Successfully acquired the connection
p.idleConnsLen.Add(-1)
break
}
// Connection is in UNUSABLE, INITIALIZING, or other state - skip it
// Connection is not in a valid state (might be UNUSABLE for handoff/re-auth, INITIALIZING, etc.)
// Put it back in the pool and try the next one
if p.cfg.PoolFIFO {
// FIFO: put at end (will be picked up last since we pop from front)
p.idleConns = append(p.idleConns, cn)
} else {
// LIFO: put at beginning (will be picked up last since we pop from end)
p.idleConns = append([]*Conn{cn}, p.idleConns...)
}
cn = nil
}
// If we exhausted all attempts without finding a usable connection, return nil
if attempts > 1 && attempts >= maxAttempts && int32(attempts) >= p.poolSize.Load() {
internal.Logger.Printf(context.Background(), "redis: connection pool: failed to get a usable connection after %d attempts", attempts)
return nil, nil
}
return cn, nil
}
func (p *ConnPool) Put(ctx context.Context, cn *Conn) {
p.putConn(ctx, cn, true)
}
// putConnWithoutTurn is an internal method that puts a connection back to the pool
// without freeing a turn. This is used when returning a rejected connection from
// within Get(), where the turn is still held by the Get() call.
func (p *ConnPool) putConnWithoutTurn(ctx context.Context, cn *Conn) {
p.putConn(ctx, cn, false)
}
// putConn is the internal implementation of Put that optionally frees a turn.
func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
// Guard against nil connection
if cn == nil {
internal.Logger.Printf(ctx, "putConn called with nil connection")
if freeTurn {
p.freeTurn()
}
return
}
// Process connection using the hooks system
shouldPool := true
shouldRemove := false
var err error
if reason := cn.CloseOnPutReason(); reason != "" {
p.removeConnInternal(ctx, cn, errors.New(reason), freeTurn)
return
}
if cn.HasBufferedData() {
// Peek at the reply type to check if it's a push notification
if replyType, err := cn.PeekReplyTypeSafe(); err != nil || replyType != proto.RespPush {
// Not a push notification or error peeking, remove connection
internal.Logger.Printf(ctx, "Conn has unread data (not push notification), removing it")
p.removeConnInternal(ctx, cn, err, freeTurn)
return
}
// It's a push notification, allow pooling (client will handle it)
}
// Lock-free atomic read - no mutex overhead!
hookManager := p.hookManager.Load()
if hookManager != nil {
shouldPool, shouldRemove, err = hookManager.ProcessOnPut(ctx, cn)
if err != nil {
internal.Logger.Printf(ctx, "Connection hook error: %v", err)
p.removeConnInternal(ctx, cn, err, freeTurn)
return
}
}
// Combine all removal checks into one - reduces branches
if shouldRemove || !shouldPool {
p.removeConnInternal(ctx, cn, errHookRequestedRemoval, freeTurn)
return
}
if !cn.pooled {
p.removeConnInternal(ctx, cn, errConnNotPooled, freeTurn)
return
}
var shouldCloseConn bool
var removedFromPool bool
if p.cfg.MaxIdleConns == 0 || p.idleConnsLen.Load() < p.cfg.MaxIdleConns {
// Hot path optimization: try fast IN_USE → IDLE transition
// Using inline Release() method for better performance (avoids pointer dereference)
transitionedToIdle := cn.Release()
// Handle unexpected state changes
if !transitionedToIdle {
// Fast path failed - hook might have changed state (e.g., to UNUSABLE for handoff)
// Keep the state set by the hook and pool the connection anyway
sm := cn.GetStateMachine()
if sm == nil {
// State machine is nil - connection is in an invalid state, remove it
internal.Logger.Printf(ctx, "conn[%d] has nil state machine, removing it", cn.GetID())
p.removeConnInternal(ctx, cn, errConnNotPooled, freeTurn)
return
}
currentState := sm.GetState()
switch currentState {
case StateUnusable:
// expected state, don't log it
case StateClosed:
internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, closing it", cn.GetID(), currentState)
if hookManager != nil {
hookManager.ProcessOnRemove(ctx, cn, errHookRequestedRemoval)
}
shouldCloseConn = true
removedFromPool = p.removeConnWithLock(cn)
default:
// Pool as-is
internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, pooling as-is", cn.GetID(), currentState)
}
}
// unusable conns are expected to become usable at some point (background process is reconnecting them)
// put them at the opposite end of the queue
// Optimization: if we just transitioned to IDLE, we know it's usable - skip the check
if !transitionedToIdle && !cn.IsUsable() {
p.connsMu.Lock()
// Check if Close() already removed this connection from p.conns.
// If so, skip the append and metrics — Close() already accounted for it.
if _, inPool := p.conns[cn.GetID()]; inPool {
if p.cfg.PoolFIFO {
p.idleConns = append(p.idleConns, cn)
} else {
p.idleConns = append([]*Conn{cn}, p.idleConns...)
}
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, MetricStateIdle)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
cb(ctx, 1, cn, "idle", false)
}
p.connsMu.Unlock()
p.idleConnsLen.Add(1)
} else {
shouldCloseConn = true
p.connsMu.Unlock()
}
} else if !shouldCloseConn {
p.connsMu.Lock()
if _, inPool := p.conns[cn.GetID()]; inPool {
p.idleConns = append(p.idleConns, cn)
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, MetricStateIdle)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
cb(ctx, 1, cn, "idle", false)
}
p.connsMu.Unlock()
p.idleConnsLen.Add(1)
} else {
shouldCloseConn = true
p.connsMu.Unlock()
}
}
if shouldCloseConn {
// Connection was removed (e.g., hook set state to StateClosed).
// Only emit if we actually removed it from the map (not already taken by Close()).
if removedFromPool {
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, "")
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
}
}
}
} else {
shouldCloseConn = true
if hookManager != nil {
hookManager.ProcessOnRemove(ctx, cn, errConnEvictedIdle)
}
removedFromPool = p.removeConnWithLock(cn)
// Only emit if we actually removed it from the map (not already taken by Close()).
if removedFromPool {
// Notify metrics: connection removed (used -> nothing)
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, "")
}
// Record connection count decrement (connection removed while in used state)
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
}
}
}
if freeTurn {
p.freeTurn()
}
if shouldCloseConn {
// Only emit connection closed if we actually owned the removal.
// If removedFromPool is false, Close() already emitted connectionClosed for this conn.
if removedFromPool {
if cb := getMetricConnectionClosedCallback(); cb != nil {
reason := "conn_pool_close"
if r := cn.closeReason.Load(); r != "" {
reason = r
}
cb(ctx, cn, reason, nil)
}
}
_ = p.closeConn(cn)
}
cn.SetLastPutAtNs(getCachedTimeNs())
}
func (p *ConnPool) Remove(ctx context.Context, cn *Conn, reason error) {
p.removeConnInternal(ctx, cn, reason, true)
}
// RemoveWithoutTurn removes a connection from the pool without freeing a turn.
// This should be used when removing a connection from a context that didn't acquire
// a turn via Get() (e.g., background workers, cleanup tasks).
// For normal removal after Get(), use Remove() instead.
func (p *ConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) {
p.removeConnInternal(ctx, cn, reason, false)
}
// removeConnInternal is the internal implementation of Remove that optionally frees a turn.
func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason error, freeTurn bool) {
// Lock-free atomic read - no mutex overhead!
hookManager := p.hookManager.Load()
if hookManager != nil {
hookManager.ProcessOnRemove(ctx, cn, reason)
}
removed := p.removeConnWithLock(cn)
if freeTurn {
p.freeTurn()
}
// Only emit metric decrements if we actually removed the connection from the map.
// If removed is false, Close() already removed it and emitted the -1 delta.
if removed {
// Notify metrics: connection removed (assume from used state)
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, "")
}
// Record connection count decrement (connection removed, assume from used state)
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
}
}
// Only emit connection closed if we actually owned the removal.
// If removed is false, Close() already emitted connectionClosed for this conn.
if removed {
if cb := getMetricConnectionClosedCallback(); cb != nil {
reasonStr := "unknown"
if reason != nil {
reasonStr = reason.Error()
}
cb(ctx, cn, reasonStr, reason)
}
}
_ = p.closeConn(cn)
// Check if we need to create new idle connections to maintain MinIdleConns
p.checkMinIdleConns()
}
// CloseConn closes a connection and records metrics.
// Parameters:
// - ctx: context for metric callbacks (enables trace-to-metric correlation)
// - cn: the connection to close
// - reason: why the connection is being closed (use CloseReason* constants)
// - fromState: the metric state the connection was in (use MetricState* constants)
func (p *ConnPool) CloseConn(ctx context.Context, cn *Conn, reason string, fromState string) error {
if hookManager := p.hookManager.Load(); hookManager != nil {
hookManager.ProcessOnRemove(ctx, cn, errors.New(reason))
}
removed := p.removeConnWithLock(cn)
// Only emit UpDownCounter decrements if we actually removed the connection.
// If removed is false, Close() already removed it and emitted the -1 delta.
// Only emit connection closed if we actually owned the removal.
// If removed is false, Close() already emitted connectionClosed for this conn.
if removed {
p.recordConnectionMetrics(ctx, cn, reason, fromState)
}
return p.closeConn(cn)
}
func (p *ConnPool) recordConnectionMetrics(ctx context.Context, cn *Conn, reason string, fromState string) {
// Record connection state change: connection is being removed from the specified state
if cb := getMetricConnectionStateChangeCallback(); cb != nil && fromState != "" {
cb(ctx, cn, fromState, "")
}
// Record connection count decrement (UpDownCounter) for the state the connection was in
if cb := getMetricConnectionCountCallback(); cb != nil && fromState != "" {
cb(ctx, -1, cn, fromState, false)
}
if cb := getMetricConnectionClosedCallback(); cb != nil {
cb(ctx, cn, reason, nil)
}
}
// removeConnWithLock removes a connection from the pool under the connsMu lock.
// Returns true if the connection was actually present in p.conns and was removed,
// false if it was already gone (e.g., removed by Close()). Callers must use the
// return value to decide whether to emit metric decrements — this eliminates the
// shutdown race between Close() and concurrent removal paths.
func (p *ConnPool) removeConnWithLock(cn *Conn) bool {
p.connsMu.Lock()
defer p.connsMu.Unlock()
return p.removeConn(cn)
}
// removeConn removes a connection from the pool's internal data structures.
// Returns true if the connection was present and removed, false otherwise.
func (p *ConnPool) removeConn(cn *Conn) bool {
cid := cn.GetID()
if _, exists := p.conns[cid]; !exists {
return false
}
delete(p.conns, cid)
atomic.AddUint32(&p.stats.StaleConns, 1)
// Decrement pool size counter when removing a connection
if cn.pooled {
p.poolSize.Add(-1)
// this can be idle conn
for idx, ic := range p.idleConns {
if ic == cn {
p.idleConns = append(p.idleConns[:idx], p.idleConns[idx+1:]...)
p.idleConnsLen.Add(-1)
break
}
}
}
return true
}
func (p *ConnPool) closeConn(cn *Conn) error {
return cn.Close()
}
// Len returns total number of connections.
func (p *ConnPool) Len() int {
p.connsMu.Lock()
n := len(p.conns)
p.connsMu.Unlock()
return n
}
// IdleLen returns number of idle connections.
func (p *ConnPool) IdleLen() int {
p.connsMu.Lock()
n := p.idleConnsLen.Load()
p.connsMu.Unlock()
return int(n)
}
// Name returns the pool's configured name, which is stamped on every
// connection it creates (Conn.PoolName). Callers holding a Pooler can type
// assert to interface{ Name() string } to find which pool owns a connection —
// used by maintnotifications to route a handoff to the hook that owns the
// conn's pool rather than always the primary one.
func (p *ConnPool) Name() string { return p.cfg.Name }
// Size returns the maximum pool size (capacity).
//
// This is used by the streaming credentials manager to size the re-auth worker pool,
// ensuring that re-auth operations don't exhaust the connection pool.
func (p *ConnPool) Size() int {
return int(p.cfg.PoolSize)
}
// HasFreeCapacity reports whether the pool can likely serve another Get without
// blocking — a best-effort probe for the autopipeline straggler-hold gate (a
// false positive only costs a spill / short hold, never correctness). Checks,
// in order: a truly servable idle conn (usableIdleLen — plain IdleLen counts
// not-usable and handoff-marked entries an OnGet hook would divert); a free
// pool turn (accounts for in-use conns AND in-flight dials); room under
// PoolSize and MaxActiveConns (newConn's hard dial gate); a free dial slot
// (MaxConcurrentDials below PoolSize can saturate slots while a turn is free);
// and the dial circuit breaker not being open (a saturated dialErrorsNum makes
// the Get fail fast rather than serve).
func (p *ConnPool) HasFreeCapacity() bool {
// Turn check comes FIRST: Get acquires a turn before popIdle, so with the
// semaphore exhausted even a usable idle conn is not immediately servable
// (putConnWithoutTurn can re-pool a conn while its turn stays held, so
// idle > 0 does not imply a free turn).
if p.semaphore.Available() <= 0 {
return false
}
if p.usableIdleLen() > 0 {
return true
}
size := p.poolSize.Load()
if size >= p.cfg.PoolSize {
return false
}
if m := p.cfg.MaxActiveConns; m > 0 && size >= m {
return false
}
if c := cap(p.dialsInProgress); c > 0 && len(p.dialsInProgress) >= c {
return false
}
// Dial circuit breaker open (dialConn fails fast until a background probe
// succeeds): the Get would need this dial and error immediately, so there is
// no capacity to report.
if p.dialErrorsNum.Load() >= uint32(p.cfg.PoolSize) {
return false
}
return true
}
// usableIdleLen counts idle connections a Get would actually serve, unlike
// IdleLen(): it excludes not-usable conns (mid state transition) and
// handoff-marked conns (still StateIdle/usable, but the OnGet pool hook diverts
// them to handoff). Best-effort — a re-auth-marked conn is a rare residual
// false positive. The idle slice is bounded by PoolSize, so the scan is cheap.
func (p *ConnPool) usableIdleLen() int {
p.connsMu.Lock()
n := 0
for _, cn := range p.idleConns {
if cn.IsUsable() && !cn.ShouldHandoff() {
n++
}
}
p.connsMu.Unlock()
return n
}
func (p *ConnPool) Stats() *Stats {
return &Stats{
Hits: atomic.LoadUint32(&p.stats.Hits),
Misses: atomic.LoadUint32(&p.stats.Misses),
Timeouts: atomic.LoadUint32(&p.stats.Timeouts),
WaitCount: atomic.LoadUint32(&p.stats.WaitCount),
Unusable: atomic.LoadUint32(&p.stats.Unusable),
WaitDurationNs: p.waitDurationNs.Load(),
PendingRequests: atomic.LoadUint32(&p.stats.PendingRequests),
TotalConns: uint32(p.Len()),
IdleConns: uint32(p.IdleLen()),
StaleConns: atomic.LoadUint32(&p.stats.StaleConns),
}
}
func (p *ConnPool) closed() bool {
return p._closed.Load() == 1
}
func (p *ConnPool) RetireConns(ctx context.Context, conns []*Conn, reason string) {
if len(conns) == 0 {
return
}
idleConnSet := make(map[*Conn]struct{})
toClose := make([]*Conn, 0, len(conns))
p.connsMu.Lock()
for _, ic := range p.idleConns {
idleConnSet[ic] = struct{}{}
}
for _, cn := range conns {
if cn == nil {
continue
}
if _, ok := p.conns[cn.GetID()]; !ok {
continue
}
if _, isIdle := idleConnSet[cn]; isIdle {
if p.removeConn(cn) {
toClose = append(toClose, cn)
}
continue
}
cn.MarkCloseOnPut(reason)
}
p.connsMu.Unlock()
if hookManager := p.hookManager.Load(); hookManager != nil {
for _, cn := range toClose {
hookManager.ProcessOnRemove(ctx, cn, errors.New(reason))
}
}
for _, cn := range toClose {
p.recordConnectionMetrics(ctx, cn, reason, MetricStateIdle)
_ = p.closeConn(cn)
}
p.checkMinIdleConns()
}
func (p *ConnPool) Filter(fn func(*Conn) bool) error {
ctx := context.Background()
p.connsMu.Lock()
defer p.connsMu.Unlock()
idleConnSet := make(map[*Conn]struct{}, len(p.idleConns))
for _, ic := range p.idleConns {
idleConnSet[ic] = struct{}{}
}
var firstErr error
for _, cn := range p.conns {
if fn(cn) {
var err error
if _, isIdle := idleConnSet[cn]; isIdle {
// Idle connection - remove from pool and close.
p.removeConn(cn)
p.recordConnectionMetrics(ctx, cn, CloseReasonFailover, MetricStateIdle)
err = p.closeConn(cn)
} else {
// Used connection - set closeReason and close the connection.
// The connection remains in p.conns. When putConn() is called later,
// it will close the connection instead of pooling it.
cn.closeReason.Store(CloseReasonFailover)
err = cn.Close()
}
if err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
type drainConn struct {
conn *Conn
idleIndex int
}
// DrainState carries the cross-pass round bookkeeping for the CSC drainer.
// It is owned by one drainer goroutine, so no synchronization is needed.
type DrainState struct {
// round contains only initialized idle connections. Entries are processed
// from the end so their snapshot indexes remain stable as connections are
// removed and returned. Connections that go idle mid-round are deferred.
round []drainConn
next int
}
// DrainIdleConns runs one pass of the CSC invalidation drainer over the current
// round, holding AT MOST ONE connection and its pool turn at a time (ctx is the
// per-cycle deadline). A round = idle conn ids snapshotted at start; mid-round
// arrivals are deferred. Each member is drainerPop'd and drained by fn, or — if no
// longer a claimable idle conn — reconciled (marked visited) so it can't hang the
// round. The drainer yields when no turn is immediately available, giving command
// traffic priority. Handles at least one member before honoring ctx (so a tiny
// DrainInterval can't stall it). No-ops if the pool is closed.
func (p *ConnPool) DrainIdleConns(ctx context.Context, st *DrainState, fn func(cn *Conn) error) {
if st == nil || fn == nil || p.closed() {
return
}
if st.round == nil {
st.round = p.idleConnsSnapshot()
st.next = len(st.round)
if len(st.round) == 0 {
st.round = nil
return
}
}
handled := 0
for st.next > 0 {
// Min-progress: handle at least one member — drained OR reconciled — before
// honoring the per-cycle deadline, so a pass does a bounded amount of work
// while ignoring an expired ctx. A deadline-truncated round resumes on the
// next pass.
if handled > 0 && ctx.Err() != nil {
return
}
// Account for the borrowed connection exactly like Get. Without a turn,
// a concurrent Get can observe the temporarily-empty idle pool and either
// exceed PoolSize or fail at MaxActiveConns. Maintenance never waits for a
// turn, so command traffic wins under contention.
if !p.semaphore.TryAcquire() {
return
}
st.next--
cn := p.drainerPop(ctx, st.round[st.next])
if cn == nil {
p.freeTurn()
// Reconcile: not a claimable idle member right now (closed, in use,
// unusable, or moved in idleConns by concurrent traffic). Covered by
// the command-path drain and/or the next round.
handled++
continue
}
func() {
defer p.endDrainerBorrow()
if err := fn(cn); err != nil {
// Fatal drain error (read/protocol/connection).
p.removeConnInternal(ctx, cn, err, true)
} else {
// Normal return: runs OnPut (queues any maintenance handoff).
p.putConn(ctx, cn, true)
}
}()
handled++
}
// Every member handled — round complete; snapshot a fresh round next pass.
st.round = nil
st.next = 0
}
// idleConnsSnapshot returns initialized idle connections and their current
// indexes. StateCreated MinIdleConns are intentionally excluded.
func (p *ConnPool) idleConnsSnapshot() []drainConn {
p.connsMu.Lock()
defer p.connsMu.Unlock()
if len(p.idleConns) == 0 {
return nil
}
round := make([]drainConn, 0, len(p.idleConns))
for idx, cn := range p.idleConns {
if cn.stateMachine.GetState() == StateIdle {
round = append(round, drainConn{conn: cn, idleIndex: idx})
}
}
return round
}
// drainerPop claims a snapshotted connection (strict IDLE->IN_USE) and removes
// it from idleConns in O(1). Entries are processed in reverse index order, so
// swap removal cannot move an unprocessed round member. If concurrent pool
// traffic changed the slot, the member is deferred to the next round.
func (p *ConnPool) drainerPop(ctx context.Context, member drainConn) *Conn {
p.connsMu.Lock()
defer p.connsMu.Unlock()
if p.closed() {
return nil
}
idx := member.idleIndex
if idx < 0 || idx >= len(p.idleConns) || p.idleConns[idx] != member.conn {
return nil
}
cn := member.conn
if !cn.stateMachine.TryTransitionFast(StateIdle, StateInUse) {
return nil
}
p.beginDrainerBorrow()
last := len(p.idleConns) - 1
p.idleConns[idx] = p.idleConns[last]
p.idleConns[last] = nil
p.idleConns = p.idleConns[:last]
p.idleConnsLen.Add(-1)
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateIdle, MetricStateUsed)
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "idle", false)
cb(ctx, 1, cn, "used", false)
}
return cn
}
func (p *ConnPool) Close() error {
if !p._closed.CompareAndSwap(0, 1) {
return ErrClosed
}
var firstErr error
nowNs := time.Now().UnixNano()
p.connsMu.Lock()
// Emit -1 for each connection. Since all idle↔used transitions happen
// under connsMu, the idleConns slice is the source of truth for state.
cb := getMetricConnectionCountCallback()
idleSet := make(map[uint64]struct{}, len(p.idleConns))
for _, cn := range p.idleConns {
idleSet[cn.GetID()] = struct{}{}
}
ctx := context.Background()
for _, cn := range p.conns {
// Check health before closing, since closeConn invalidates the
// underlying fd and would make connCheck (inside isHealthyConn)
// always fail with EBADF.
// Only check health for idle connections to avoid data races when
// peeking at the socket/reader while another goroutine is reading from it.
// Non-idle connections are either in use or in transitional states and
// shouldn't be health-checked during shutdown.
_, isIdle := idleSet[cn.GetID()]
var healthy bool
if isIdle {
healthy = p.isHealthyConn(cn, nowNs)
} else {
healthy = true
}
if cb != nil {
if isIdle {
cb(ctx, -1, cn, "idle", false)
} else {
cb(ctx, -1, cn, "used", false)
}
}
if closedCb := getMetricConnectionClosedCallback(); closedCb != nil {
closedCb(ctx, cn, "pool_shutdown", nil)
}
if err := p.closeConn(cn); err != nil && firstErr == nil {
// Suppress close errors for stale connections, consistent
// with how Get() handles them (see CloseReasonStale path).
if healthy {
firstErr = err
}
}
}
p.conns = nil
p.poolSize.Store(0)
p.idleConns = nil
p.idleConnsLen.Store(0)
p.connsMu.Unlock()
return firstErr
}
func (p *ConnPool) isHealthyConn(cn *Conn, nowNs int64) bool {
// Performance optimization: check conditions from cheapest to most expensive,
// and from most likely to fail to least likely to fail.
// Only fails if ConnMaxLifetime is set AND connection is old.
// Most pools don't set ConnMaxLifetime, so this rarely fails.
if p.cfg.ConnMaxLifetime > 0 {
if cn.expiresAt.UnixNano() < nowNs {
return false // Connection has exceeded max lifetime
}
}
// Most pools set ConnMaxIdleTime, and idle connections are common.
// Checking this first allows us to fail fast without expensive syscalls.
if p.cfg.ConnMaxIdleTime > 0 {
if nowNs-cn.UsedAtNs() >= int64(p.cfg.ConnMaxIdleTime) {
return false // Connection has been idle too long
}
}
// Only run this if the cheap checks passed.
if err := connCheck(cn.getNetConn()); err != nil {
// If there's unexpected data, it might be push notifications (RESP3)
if p.cfg.PushNotificationsEnabled && err == errUnexpectedRead {
// Peek at the reply type to check if it's a push notification.
// Use the readerMu-guarded peek: a concurrent handoff may be
// resetting cn.rd via SetNetConn on a connection popped by Get
// before the OnGet state check rejects it.
if replyType, err := cn.PeekReplyTypeForCheck(); err == nil && replyType == proto.RespPush {
// For RESP3 connections with push notifications, we allow some buffered data
// The client will process these notifications before using the connection.
// This is the normal healthy path for any client with server-side
// invalidation or maintenance notifications (client-side caching parks
// an invalidate frame on idle conns after every tracked write), so it
// logs at debug level only — at default level it would flood the log
// on every pool Get under a write-heavy tracked workload.
if internal.LogLevel.DebugOrAbove() {
internal.Logger.Printf(
context.Background(),
"push: conn[%d] has buffered data, likely push notifications - will be processed by client",
cn.GetID(),
)
}
// Update timestamp for healthy connection
cn.SetUsedAtNs(nowNs)
// Connection is healthy, client will handle notifications
return true
}
// Not a push notification - treat as unhealthy
return false
}
// Connection failed health check
return false
}
// Only update UsedAt if connection is healthy (avoids unnecessary atomic store)
cn.SetUsedAtNs(nowNs)
return true
}
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) {}
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 atomic.Int32
state atomic.Uint32
ch chan *Conn
// onFirstConn runs once when this sticky pool claims a connection from its
// parent. CSC uses it to revoke cache ownership before the connection leaves
// the parent's background drainer.
onFirstConn func(*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),
}
}
p.shared.Add(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 p.state.Load() {
case stateDefault:
cn, err := p.pool.Get(ctx)
if err != nil {
return nil, err
}
if p.state.CompareAndSwap(stateDefault, stateInited) {
if p.onFirstConn != nil {
p.onFirstConn(cn)
}
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")
}
// SetOnFirstConn configures a callback that runs when the sticky pool first
// claims a parent connection. It must be called before the pool is used.
func (p *StickyConnPool) SetOnFirstConn(fn func(*Conn)) {
p.onFirstConn = fn
}
func (p *StickyConnPool) Put(ctx context.Context, cn *Conn) {
defer func() {
if recover() != nil {
p.freeConn(ctx, cn)
}
}()
// A connection marked for removal on release (it may hold unread
// replies) must not be served to the next Get: record it as a bad
// connection — exactly like Remove — so Get refuses and the underlying
// connection is removed from the parent pool when the sticky pool
// unwinds (the parent's Put honors the same mark).
if reason := cn.CloseOnPutReason(); reason != "" {
p._badConnError.Store(BadConnError{wrapped: errors.New(reason)})
}
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 := p.shared.Add(-1); shared > 0 {
return nil
}
for i := 0; i < 1000; i++ {
state := p.state.Load()
if state == stateClosed {
return ErrClosed
}
if p.state.CompareAndSwap(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 !p.state.CompareAndSwap(stateInited, stateDefault) {
state := p.state.Load()
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 p.state.Load() {
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) {}
package pool
import (
"context"
"net"
"sync"
"sync/atomic"
)
// PubSubStats contains pub/sub connection pool stats.
//
// TODO(cxl): the uint32 fields below will be changed to atomic.Uint32 in v10,
// which is a breaking API change.
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),
}
}
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
}
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()
}
// Size returns the size of the underlying read buffer.
func (r *Reader) Size() int {
return r.rd.Size()
}
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
}
// MinRESP3ReadBufferSize is the minimum buffer size used when RESP3 push
// notifications must be inspected without consuming them.
const MinRESP3ReadBufferSize = 128
// ErrPushNotificationNameTooLong is returned when the push header does not fit
// in the bounded peek window. Callers should consume the frame with ReadReply.
var ErrPushNotificationNameTooLong = errors.New("redis: push notification name exceeds peek window")
// PeekPushNotificationName returns the notification name of the next RESP3
// push frame without consuming it. The caller is expected to have already
// verified that the next reply is a push notification (e.g. via PeekReplyType
// returning RespPush).
//
// To identify the name the method may block reading more bytes from the
// underlying connection, but only ever waits for one byte beyond the valid
// frame prefix it has already seen. That byte is guaranteed to arrive: an
// incomplete prefix means the server is still committed to sending the rest
// of the frame. Demanding any fixed amount instead can deadlock — a complete
// frame such as a subscribe confirmation for a short channel name can be
// smaller than the fixed window, and once it is buffered the server has
// nothing more to send (issue #3935). Blocking for in-flight bytes is
// preferred to a truncated peek, which would silently misidentify the
// notification and cause the caller's ReadReply to consume (and drop) the
// frame; see issue #3839.
func (r *Reader) PeekPushNotificationName() (string, error) {
c, err := r.rd.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")
}
const maxPushHeaderPeek = 4096
for {
// Parse from what is already buffered; this never blocks.
avail := r.rd.Buffered()
if avail > maxPushHeaderPeek {
avail = maxPushHeaderPeek
}
buf, peekErr := r.rd.Peek(avail)
if peekErr != nil {
return "", peekErr
}
name, complete, parseErr := parsePushNotificationName(buf)
if parseErr != nil {
return "", parseErr
}
if complete {
return name, nil
}
if avail >= maxPushHeaderPeek {
return "", ErrPushNotificationNameTooLong
}
// Valid but incomplete prefix: the rest of the frame is in flight.
// Block for exactly one more byte — the read that delivers it picks
// up whatever else has already arrived — then re-parse.
if _, err := r.rd.Peek(avail + 1); err != nil {
if errors.Is(err, bufio.ErrBufferFull) {
return "", ErrPushNotificationNameTooLong
}
return "", err
}
}
}
// parsePushNotificationName extracts the notification name from a buffered
// RESP3 push frame prefix. The three return values are:
//
// - (name, true, nil): the full name is in buf.
// - ("", false, nil): buf is a valid prefix but too short to determine the
// name; the caller should fetch more bytes and retry.
// - ("", _, err): buf is malformed.
//
// This split lets PeekPushNotificationName tell "incomplete header" apart
// from "corrupt frame" without ever returning a truncated string.
func parsePushNotificationName(buf []byte) (string, bool, error) {
// Need at least ">N\r" before any meaningful work.
if len(buf) < 3 {
return "", false, nil
}
if buf[0] != RespPush {
return "", false, fmt.Errorf("redis: can't parse push notification: %q", buf)
}
// Skip the array length line ">N\r\n".
const arrayLenStart = 1 // first byte after the '>' marker
pos, ok, err := skipDigitsThenCRLF(buf, arrayLenStart)
if err != nil {
return "", false, fmt.Errorf("redis: can't parse push notification: %w", err)
}
if !ok {
return "", false, nil
}
// Reject ">\r\n": RESP requires at least one digit for the array length.
// Without this check the empty length looks like a valid prefix and the
// caller would block fetching more bytes for a frame that is already
// malformed.
if pos-2 == arrayLenStart {
return "", false, fmt.Errorf("redis: empty push notification array length")
}
// First element type byte: '$' (bulk) or '+' (simple-string).
if pos >= len(buf) {
return "", false, nil
}
typeOfName := buf[pos]
if typeOfName != RespString && typeOfName != RespStatus {
return "", false, fmt.Errorf("redis: can't parse push notification name: %q", buf[pos:])
}
pos++
if typeOfName == RespString {
// Read "$M\r\n" then the M-byte name.
lenStart := pos
next, ok, err := skipDigitsThenCRLF(buf, pos)
if err != nil {
return "", false, fmt.Errorf("redis: can't parse push notification name length: %w", err)
}
if !ok {
return "", false, nil
}
if next-2 == lenStart {
return "", false, fmt.Errorf("redis: empty push notification name length")
}
nameLen, err := util.Atoi(buf[lenStart : next-2])
if err != nil {
return "", false, fmt.Errorf("redis: invalid push notification name length %q: %w", buf[lenStart:next-2], err)
}
if nameLen < 0 {
return "", false, fmt.Errorf("redis: negative push notification name length: %d", nameLen)
}
// Compare against the remaining bytes instead of computing
// next+nameLen: a hugely advertised length on malformed input could
// overflow int, wrap negative, slip past an "end > len(buf)" guard and
// panic the slice below. next <= len(buf) here, so the subtraction is
// safe.
if nameLen > len(buf)-next {
return "", false, nil
}
return util.BytesToString(buf[next : next+nameLen]), true, nil
}
// RespStatus: scan for the terminating CRLF.
for i := pos; i < len(buf)-1; i++ {
if buf[i] == '\r' && buf[i+1] == '\n' {
return util.BytesToString(buf[pos:i]), true, nil
}
}
return "", false, nil
}
// skipDigitsThenCRLF advances past zero-or-more ASCII digits and the
// terminating "\r\n" starting at offset start in buf. It returns the position
// after the "\r\n" and true on success; (pos, false, nil) if buf is too
// short; or an error if a non-digit non-CR byte is encountered before the CRLF.
func skipDigitsThenCRLF(buf []byte, start int) (int, bool, error) {
for pos := start; pos < len(buf)-1; pos++ {
if buf[pos] == '\r' && buf[pos+1] == '\n' {
return pos + 2, true, nil
}
if buf[pos] < '0' || buf[pos] > '9' {
return pos, false, fmt.Errorf("expected digit or CRLF, got %q", buf[pos])
}
}
return len(buf), false, nil
}
// ReadLine Return a valid reply, it will check the protocol or redis error,
// 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
}
// Reject unhashable keys (arrays/maps) before they are used as a map
// key, which would otherwise panic. This check must run before the
// value is read so it also guards the Nil and RedisError paths below,
// which write the key into the map and continue.
switch k.(type) {
case []interface{}, map[interface{}]interface{}:
return nil, fmt.Errorf("redis: RESP3 map key must be a scalar type, got %T", k)
}
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 strconv.ParseInt(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 strconv.ParseUint(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)
}
// ReadStringInto reads a string-typed reply directly into buf, avoiding the
// per-call allocation that ReadString incurs. It returns the number of bytes
// written to buf.
//
// Supported reply types:
// - $<n>\r\n<payload>\r\n bulk string (the GET path; payload is read
// straight into buf via bufio.Reader — for payloads larger than the
// bufio buffer this is effectively zero-copy from the socket)
// - +<status>\r\n simple string, copied from the header line
// - :<int>\r\n integer, copied as its ASCII representation
// - ,<float>\r\n float, copied as its ASCII representation
//
// Errors, nil, push notifications, and RESP3 attributes are intercepted
// by ReadLine and surfaced through err. RESP3 verbatim strings
// (=<n>\r\n<txt:payload>\r\n) are intentionally not handled — they are
// never returned by GET-family commands, and including them re-introduces
// a hazard class where the response-type byte read from a stale `line[0]`
// after a bufio refill can be misinterpreted as the verbatim format tag.
//
// If the bulk payload does not fit in buf, an error is returned and the
// payload plus the trailing CRLF are drained from the reader so the
// connection stays aligned for the next reply. For simple-string / integer
// / float responses the payload lives in the (already-consumed) header
// line, so no drain is needed.
func (r *Reader) ReadStringInto(buf []byte) (int, error) {
line, err := r.ReadLine()
if err != nil {
return 0, err
}
switch line[0] {
case RespStatus:
// Simple string — data is in the line itself.
s := line[1:]
if len(s) > len(buf) {
return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", len(s), len(buf))
}
return copy(buf, s), nil
case RespString:
n, err := replyLen(line)
if err != nil {
return 0, err
}
if n > len(buf) {
// Drain the payload + trailing \r\n so the next read on this
// connection sees the start of the next reply rather than the
// tail of this one. Otherwise the unread bytes corrupt the
// stream and the bad connection gets handed back to the pool.
if _, derr := r.rd.Discard(n + 2); derr != nil {
return 0, derr
}
return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", n, len(buf))
}
// Read data directly into the user's buffer through the bufio.Reader.
// bufio.Reader.Read first drains its internal buffer, then for
// remaining data larger than its buffer size reads directly from the
// underlying reader (socket) — effectively zero-copy.
//
// Fast path: when the caller VISIBLY hands over room for the trailing
// CRLF too (len(buf) >= n+2), read the payload and the CRLF in a
// single io.ReadFull. For large values this is one direct socket read
// instead of a big read followed by a tiny separate Discard(2) read,
// which is what makes GetToBuffer beat a regular Get (no payload
// allocation and the same number of reads). The 2 trailing bytes land
// past the returned length and are ignored.
//
// The gate is on len, NOT cap: a sub-slice of a larger buffer (e.g.
// packed segments big[i*slot:(i+1)*slot]) exposes trailing capacity
// that belongs to the caller's NEXT segment — writing the CRLF there
// would silently corrupt caller-owned memory outside the slice they
// passed. Callers who want the fast path pass len == payload+2 (the
// returned length is still the payload length).
if len(buf) >= n+2 {
full := buf[:n+2]
if _, err := io.ReadFull(r.rd, full); err != nil {
return 0, err
}
return n, nil
}
// Slow path: buffer is exactly large enough for the payload only, so
// read the payload into it and discard the CRLF separately.
if _, err := io.ReadFull(r.rd, buf[:n]); err != nil {
return 0, err
}
if _, err := r.rd.Discard(2); err != nil {
return 0, err
}
return n, nil
case RespInt, RespFloat:
s := line[1:]
if len(s) > len(buf) {
return 0, fmt.Errorf("redis: buffer too small: need %d bytes, have %d", len(s), len(buf))
}
return copy(buf, s), nil
}
return 0, fmt.Errorf("redis: can't parse reply=%.100q reading string into buffer", line)
}
func (r *Reader) ReadString() (string, error) {
line, err := r.ReadLine()
if err != nil {
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 {
if err == Nil {
// A nil reply ($-1, =-1, !-1, *-1, %-1) carries no payload; the
// header line was already consumed by readLine, so there is
// nothing to discard. Falling through would Discard(n+2)==2 bytes
// that belong to the next reply and desync the stream, matching
// how readRawReplyBuf/readRawReplyWriteTo already treat Nil.
return 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:
// Iterate over the n key/value pairs rather than n*2 elements: a count
// above MaxInt/2 makes n*2 overflow to a negative loop bound, which
// would skip the body entirely and return nil, leaving the map bytes in
// the stream for the next reply to consume (a silent desync).
for i := 0; i < n; i++ {
if err = r.DiscardNext(); err != nil {
return err
}
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; i++ {
for pair := 0; pair < 2; pair++ {
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. Iterate over pairs rather than
// n*2 elements so a count above MaxInt/2 can't overflow int to a
// negative loop bound and skip the body.
for i := 0; i < n; i++ {
for pair := 0; pair < 2; pair++ {
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; i++ {
for pair := 0; pair < 2; pair++ {
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. Iterate over pairs rather than
// count*2 elements so a count above MaxInt/2 can't overflow int to a
// negative loop bound and skip the body.
for i := 0; i < count; i++ {
for pair := 0; pair < 2; pair++ {
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)
}
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 ")
}
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 any) error {
switch v := v.(type) {
case nil:
return fmt.Errorf("redis: Scan(nil)")
case *string:
*v = util.BytesToString(b)
return nil
case *[]byte:
dest := make([]byte, len(b))
copy(dest, b)
*v = dest
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:
dest := make([]byte, len(b))
copy(dest, b)
return v.UnmarshalBinary(dest)
case *net.IP:
dest := make(net.IP, len(b))
copy(dest, b)
*v = dest
return nil
default:
return fmt.Errorf(
"redis: can't unmarshal %T (consider implementing BinaryUnmarshaler)", v)
}
}
func ScanSlice(data []string, slice any) error {
v := reflect.ValueOf(slice)
if !v.IsValid() {
return fmt.Errorf("redis: ScanSlice(nil)")
}
if v.Kind() != reflect.Pointer {
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(util.StringToBytes(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()
next := func() reflect.Value {
if v.Len() == v.Cap() {
v.Grow(1)
}
v.SetLen(v.Len() + 1)
return v.Index(v.Len() - 1)
}
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
return func() reflect.Value {
elem := next()
if elem.IsNil() {
elem.Set(reflect.New(elemType))
}
return elem.Elem()
}
}
return next
}
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.uint(0)
}
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')
}
package routing
import (
"errors"
"fmt"
"math"
"sync"
"sync/atomic"
"github.com/redis/go-redis/v9/internal/util"
uberAtomic "go.uber.org/atomic"
)
var (
ErrMaxAggregation = errors.New("redis: no valid results to aggregate for max operation")
ErrMinAggregation = errors.New("redis: no valid results to aggregate for min operation")
ErrAndAggregation = errors.New("redis: no valid results to aggregate for logical AND operation")
ErrOrAggregation = errors.New("redis: no valid results to aggregate for logical OR operation")
)
// ResponseAggregator defines the interface for aggregating responses from multiple shards.
type ResponseAggregator interface {
// Add processes a single shard response.
Add(result interface{}, err error) error
// AddWithKey processes a single shard response for a specific key (used by keyed aggregators).
AddWithKey(key string, result interface{}, err error) error
BatchAdd(map[string]AggregatorResErr) error
BatchSlice([]AggregatorResErr) error
// Result returns the final aggregated result and any error.
Result() (interface{}, error)
}
type AggregatorResErr struct {
Result interface{}
Err error
}
// NewResponseAggregator creates an aggregator based on the response policy.
func NewResponseAggregator(policy ResponsePolicy, cmdName string) ResponseAggregator {
switch policy {
case RespDefaultKeyless:
return &DefaultKeylessAggregator{results: make([]interface{}, 0)}
case RespDefaultHashSlot:
return &DefaultKeyedAggregator{results: make(map[string]interface{})}
case RespAllSucceeded:
return &AllSucceededAggregator{}
case RespOneSucceeded:
return &OneSucceededAggregator{}
case RespAggSum:
return &AggSumAggregator{
// res:
}
case RespAggMin:
return &AggMinAggregator{
res: util.NewAtomicMin(),
}
case RespAggMax:
return &AggMaxAggregator{
res: util.NewAtomicMax(),
}
case RespAggLogicalAnd:
andAgg := &AggLogicalAndAggregator{}
andAgg.res.Store(true)
return andAgg
case RespAggLogicalOr:
return &AggLogicalOrAggregator{}
case RespSpecial:
return NewSpecialAggregator(cmdName)
default:
return &AllSucceededAggregator{}
}
}
func NewDefaultAggregator(isKeyed bool) ResponseAggregator {
if isKeyed {
return &DefaultKeyedAggregator{
results: make(map[string]interface{}),
}
}
return &DefaultKeylessAggregator{}
}
// AllSucceededAggregator returns one non-error reply if every shard succeeded,
// propagates the first error otherwise.
type AllSucceededAggregator struct {
err atomic.Value
res atomic.Value
}
func (a *AllSucceededAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
if result != nil {
a.res.CompareAndSwap(nil, result)
}
return nil
}
func (a *AllSucceededAggregator) BatchAdd(results map[string]AggregatorResErr) error {
for _, res := range results {
err := a.Add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *AllSucceededAggregator) BatchSlice(results []AggregatorResErr) error {
for _, res := range results {
err := a.Add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *AllSucceededAggregator) Result() (interface{}, error) {
var err error
res, e := a.res.Load(), a.err.Load()
if e != nil {
err = e.(error)
}
return res, err
}
func (a *AllSucceededAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
// OneSucceededAggregator returns the first non-error reply,
// if all shards errored, returns any one of those errors.
type OneSucceededAggregator struct {
err atomic.Value
res atomic.Value
}
func (a *OneSucceededAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
if result != nil {
a.res.CompareAndSwap(nil, result)
}
return nil
}
func (a *OneSucceededAggregator) BatchAdd(results map[string]AggregatorResErr) error {
for _, res := range results {
err := a.Add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err == nil {
return nil
}
}
return nil
}
func (a *OneSucceededAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *OneSucceededAggregator) BatchSlice(results []AggregatorResErr) error {
for _, res := range results {
err := a.Add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err == nil {
return nil
}
}
return nil
}
func (a *OneSucceededAggregator) Result() (interface{}, error) {
res, e := a.res.Load(), a.err.Load()
if res == nil {
return nil, e.(error)
}
return res, nil
}
// AggSumAggregator sums numeric replies from all shards.
type AggSumAggregator struct {
err atomic.Value
res uberAtomic.Float64
}
func (a *AggSumAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
}
if result != nil {
val, err := toFloat64(result)
if err != nil {
a.err.CompareAndSwap(nil, err)
return err
}
a.res.Add(val)
}
return nil
}
func (a *AggSumAggregator) BatchAdd(results map[string]AggregatorResErr) error {
var sum int64
for _, res := range results {
if res.Err != nil {
return a.Add(res.Result, res.Err)
}
intRes, err := toInt64(res.Result)
if err != nil {
return a.Add(nil, err)
}
sum += intRes
}
return a.Add(sum, nil)
}
func (a *AggSumAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *AggSumAggregator) BatchSlice(results []AggregatorResErr) error {
var sum int64
for _, res := range results {
if res.Err != nil {
return a.Add(res.Result, res.Err)
}
intRes, err := toInt64(res.Result)
if err != nil {
return a.Add(nil, err)
}
sum += intRes
}
return a.Add(sum, nil)
}
func (a *AggSumAggregator) Result() (interface{}, error) {
res, err := a.res.Load(), a.err.Load()
if err != nil {
return nil, err.(error)
}
return res, nil
}
// AggMinAggregator returns the minimum numeric value from all shards.
type AggMinAggregator struct {
err atomic.Value
res *util.AtomicMin
}
func (a *AggMinAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
floatVal, e := toFloat64(result)
if e != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
a.res.Value(floatVal)
return nil
}
func (a *AggMinAggregator) BatchAdd(results map[string]AggregatorResErr) error {
min := int64(math.MaxInt64)
for _, res := range results {
if res.Err != nil {
_ = a.Add(nil, res.Err)
return nil
}
resInt, err := toInt64(res.Result)
if err != nil {
_ = a.Add(nil, res.Err)
return nil
}
if resInt < min {
min = resInt
}
}
return a.Add(min, nil)
}
func (a *AggMinAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *AggMinAggregator) BatchSlice(results []AggregatorResErr) error {
min := float64(math.MaxFloat64)
for _, res := range results {
if res.Err != nil {
_ = a.Add(nil, res.Err)
return nil
}
floatVal, err := toFloat64(res.Result)
if err != nil {
_ = a.Add(nil, res.Err)
return nil
}
if floatVal < min {
min = floatVal
}
}
return a.Add(min, nil)
}
func (a *AggMinAggregator) Result() (interface{}, error) {
err := a.err.Load()
if err != nil {
return nil, err.(error)
}
val, hasVal := a.res.Min()
if !hasVal {
return nil, ErrMinAggregation
}
return val, nil
}
// AggMaxAggregator returns the maximum numeric value from all shards.
type AggMaxAggregator struct {
err atomic.Value
res *util.AtomicMax
}
func (a *AggMaxAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
floatVal, e := toFloat64(result)
if e != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
a.res.Value(floatVal)
return nil
}
func (a *AggMaxAggregator) BatchAdd(results map[string]AggregatorResErr) error {
max := int64(math.MinInt64)
for _, res := range results {
if res.Err != nil {
_ = a.Add(nil, res.Err)
return nil
}
resInt, err := toInt64(res.Result)
if err != nil {
_ = a.Add(nil, res.Err)
return nil
}
if resInt > max {
max = resInt
}
}
return a.Add(max, nil)
}
func (a *AggMaxAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *AggMaxAggregator) BatchSlice(results []AggregatorResErr) error {
max := int64(math.MinInt64)
for _, res := range results {
if res.Err != nil {
_ = a.Add(nil, res.Err)
return nil
}
resInt, err := toInt64(res.Result)
if err != nil {
_ = a.Add(nil, res.Err)
return nil
}
if resInt > max {
max = resInt
}
}
return a.Add(max, nil)
}
func (a *AggMaxAggregator) Result() (interface{}, error) {
err := a.err.Load()
if err != nil {
return nil, err.(error)
}
val, hasVal := a.res.Max()
if !hasVal {
return nil, ErrMaxAggregation
}
return val, nil
}
// AggLogicalAndAggregator performs logical AND on boolean values.
type AggLogicalAndAggregator struct {
err atomic.Value
res atomic.Bool
hasResult atomic.Bool
}
func (a *AggLogicalAndAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
val, e := toBool(result)
if e != nil {
a.err.CompareAndSwap(nil, e)
return e
}
// Atomic AND operation: if val is false, result is always false
if !val {
a.res.Store(false)
}
a.hasResult.Store(true)
return nil
}
func (a *AggLogicalAndAggregator) BatchAdd(results map[string]AggregatorResErr) error {
result := true
for _, res := range results {
if res.Err != nil {
return a.Add(nil, res.Err)
}
boolRes, err := toBool(res.Result)
if err != nil {
return a.Add(nil, err)
}
result = result && boolRes
}
return a.Add(result, nil)
}
func (a *AggLogicalAndAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *AggLogicalAndAggregator) BatchSlice(results []AggregatorResErr) error {
result := true
for _, res := range results {
if res.Err != nil {
return a.Add(nil, res.Err)
}
boolRes, err := toBool(res.Result)
if err != nil {
return a.Add(nil, err)
}
result = result && boolRes
}
return a.Add(result, nil)
}
func (a *AggLogicalAndAggregator) Result() (interface{}, error) {
err := a.err.Load()
if err != nil {
return nil, err.(error)
}
if !a.hasResult.Load() {
return nil, ErrAndAggregation
}
return a.res.Load(), nil
}
// AggLogicalOrAggregator performs logical OR on boolean values.
type AggLogicalOrAggregator struct {
err atomic.Value
res atomic.Bool
hasResult atomic.Bool
}
func (a *AggLogicalOrAggregator) Add(result interface{}, err error) error {
if err != nil {
a.err.CompareAndSwap(nil, err)
return nil
}
val, e := toBool(result)
if e != nil {
a.err.CompareAndSwap(nil, e)
return e
}
// Atomic OR operation: if val is true, result is always true
if val {
a.res.Store(true)
}
a.hasResult.Store(true)
return nil
}
func (a *AggLogicalOrAggregator) BatchAdd(results map[string]AggregatorResErr) error {
result := false
for _, res := range results {
if res.Err != nil {
return a.Add(nil, res.Err)
}
boolRes, err := toBool(res.Result)
if err != nil {
return a.Add(nil, err)
}
result = result || boolRes
}
return a.Add(result, nil)
}
func (a *AggLogicalOrAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *AggLogicalOrAggregator) BatchSlice(results []AggregatorResErr) error {
result := false
for _, res := range results {
if res.Err != nil {
return a.Add(nil, res.Err)
}
boolRes, err := toBool(res.Result)
if err != nil {
return a.Add(nil, err)
}
result = result || boolRes
}
return a.Add(result, nil)
}
func (a *AggLogicalOrAggregator) Result() (interface{}, error) {
err := a.err.Load()
if err != nil {
return nil, err.(error)
}
if !a.hasResult.Load() {
return nil, ErrOrAggregation
}
return a.res.Load(), nil
}
func toInt64(val interface{}) (int64, error) {
if val == nil {
return 0, nil
}
switch v := val.(type) {
case int64:
return v, nil
case int:
return int64(v), nil
case int32:
return int64(v), nil
case float64:
if v != math.Trunc(v) {
return 0, fmt.Errorf("cannot convert float %f to int64", v)
}
return int64(v), nil
default:
return 0, fmt.Errorf("cannot convert %T to int64", val)
}
}
func toFloat64(val interface{}) (float64, error) {
if val == nil {
return 0, nil
}
switch v := val.(type) {
case float64:
return v, nil
case int:
return float64(v), nil
case int32:
return float64(v), nil
case int64:
return float64(v), nil
case float32:
return float64(v), nil
default:
return 0, fmt.Errorf("cannot convert %T to float64", val)
}
}
func toBool(val interface{}) (bool, error) {
if val == nil {
return false, nil
}
switch v := val.(type) {
case bool:
return v, nil
case int64:
return v != 0, nil
case int:
return v != 0, nil
default:
return false, fmt.Errorf("cannot convert %T to bool", val)
}
}
// DefaultKeylessAggregator collects all results in an array, order doesn't matter.
type DefaultKeylessAggregator struct {
mu sync.Mutex
results []interface{}
firstErr error
}
func (a *DefaultKeylessAggregator) add(result interface{}, err error) error {
if err != nil && a.firstErr == nil {
a.firstErr = err
return nil
}
if err == nil {
a.results = append(a.results, result)
}
return nil
}
func (a *DefaultKeylessAggregator) Add(result interface{}, err error) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.add(result, err)
}
func (a *DefaultKeylessAggregator) BatchAdd(results map[string]AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *DefaultKeylessAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *DefaultKeylessAggregator) BatchSlice(results []AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *DefaultKeylessAggregator) Result() (interface{}, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.firstErr != nil {
return nil, a.firstErr
}
return a.results, nil
}
// DefaultKeyedAggregator reassembles replies in the exact key order of the original request.
type DefaultKeyedAggregator struct {
mu sync.Mutex
results map[string]interface{}
keyOrder []string
firstErr error
}
func NewDefaultKeyedAggregator(keyOrder []string) *DefaultKeyedAggregator {
return &DefaultKeyedAggregator{
results: make(map[string]interface{}),
keyOrder: keyOrder,
}
}
func (a *DefaultKeyedAggregator) add(result interface{}, err error) error {
if err != nil && a.firstErr == nil {
a.firstErr = err
return nil
}
// For non-keyed Add, just collect the result without ordering
if err == nil {
a.results["__default__"] = result
}
return nil
}
func (a *DefaultKeyedAggregator) Add(result interface{}, err error) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.add(result, err)
}
func (a *DefaultKeyedAggregator) BatchAdd(results map[string]AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *DefaultKeyedAggregator) addWithKey(key string, result interface{}, err error) error {
if err != nil && a.firstErr == nil {
a.firstErr = err
return nil
}
if err == nil {
a.results[key] = result
}
return nil
}
func (a *DefaultKeyedAggregator) AddWithKey(key string, result interface{}, err error) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.addWithKey(key, result, err)
}
func (a *DefaultKeyedAggregator) BatchAddWithKeyOrder(results map[string]AggregatorResErr, keyOrder []string) error {
a.mu.Lock()
defer a.mu.Unlock()
a.keyOrder = keyOrder
for key, res := range results {
err := a.addWithKey(key, res.Result, res.Err)
if err != nil {
return nil
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *DefaultKeyedAggregator) SetKeyOrder(keyOrder []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.keyOrder = keyOrder
}
func (a *DefaultKeyedAggregator) BatchSlice(results []AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *DefaultKeyedAggregator) Result() (interface{}, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.firstErr != nil {
return nil, a.firstErr
}
// If no explicit key order is set, return results in any order
if len(a.keyOrder) == 0 {
orderedResults := make([]interface{}, 0, len(a.results))
for _, result := range a.results {
orderedResults = append(orderedResults, result)
}
return orderedResults, nil
}
// Return results in the exact key order
orderedResults := make([]interface{}, len(a.keyOrder))
for i, key := range a.keyOrder {
if result, exists := a.results[key]; exists {
orderedResults[i] = result
}
}
return orderedResults, nil
}
// SpecialAggregator provides a registry for command-specific aggregation logic.
type SpecialAggregator struct {
mu sync.Mutex
aggregatorFunc func([]interface{}, []error) (interface{}, error)
results []interface{}
errors []error
}
func (a *SpecialAggregator) add(result interface{}, err error) error {
a.results = append(a.results, result)
a.errors = append(a.errors, err)
return nil
}
func (a *SpecialAggregator) Add(result interface{}, err error) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.add(result, err)
}
func (a *SpecialAggregator) BatchAdd(results map[string]AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *SpecialAggregator) AddWithKey(key string, result interface{}, err error) error {
return a.Add(result, err)
}
func (a *SpecialAggregator) BatchSlice(results []AggregatorResErr) error {
a.mu.Lock()
defer a.mu.Unlock()
for _, res := range results {
err := a.add(res.Result, res.Err)
if err != nil {
return err
}
if res.Err != nil {
return nil
}
}
return nil
}
func (a *SpecialAggregator) Result() (interface{}, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.aggregatorFunc != nil {
return a.aggregatorFunc(a.results, a.errors)
}
// Default behavior: return first non-error result or first error
for i, err := range a.errors {
if err == nil {
return a.results[i], nil
}
}
if len(a.errors) > 0 {
return nil, a.errors[0]
}
return nil, nil
}
// SpecialAggregatorRegistry holds custom aggregation functions for specific commands.
var SpecialAggregatorRegistry = make(map[string]func([]interface{}, []error) (interface{}, error))
// RegisterSpecialAggregator registers a custom aggregation function for a command.
func RegisterSpecialAggregator(cmdName string, fn func([]interface{}, []error) (interface{}, error)) {
SpecialAggregatorRegistry[cmdName] = fn
}
// NewSpecialAggregator creates a special aggregator with command-specific logic if available.
func NewSpecialAggregator(cmdName string) *SpecialAggregator {
agg := &SpecialAggregator{}
if fn, exists := SpecialAggregatorRegistry[cmdName]; exists {
agg.aggregatorFunc = fn
}
return agg
}
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
}
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)
}
package internal
import (
"context"
"sync"
"time"
)
var semTimers = sync.Pool{
New: func() interface{} {
t := time.NewTimer(time.Hour)
t.Stop()
return t
},
}
// putSemTimer stops a pooled timer and drains a stale fire before reuse,
// portably across both timer-channel semantics (the drain must never block):
//
// - main module on go >= 1.23 (synchronous channels): Stop returns TRUE for
// an expired-but-undelivered fire — the delivery is aborted — and false
// only once the value was actually received. With the sole receiver
// being Acquire's own select, the drain branch is unreachable; the
// select-with-default is a safety net so a future semantics shift cannot
// turn it into a blocking receive (reviewed on #3942).
// - GODEBUG=asynctimerchan=1 (consumer main module on go < 1.23, old
// buffered channels): Stop returns false and the fired value sits in the
// buffer; the drain consumes it so the timer is clean for Reset-reuse.
func putSemTimer(t *time.Timer) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
semTimers.Put(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,
}
}
// Available returns the number of tokens currently free (an approximation under
// concurrency). Zero means every turn is taken — by an in-use connection or an
// in-flight dial — so the next Acquire would block. Used as a non-blocking
// capacity probe; not a synchronization primitive.
func (s *FastSemaphore) Available() int {
return len(s.tokens)
}
// 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 putSemTimer(timer)
timer.Reset(timeout)
select {
case <-s.tokens:
return nil
case <-ctx.Done():
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 putSemTimer(timer)
timer.Reset(timeout)
select {
case <-s.tokens:
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return timeoutErr
}
}
// Release releases a token back to the semaphore.
func (s *FIFOSemaphore) Release() {
s.tokens <- struct{}{}
}
package internal
import (
"context"
"math"
"net"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9/internal/util"
)
// String representations of special float values.
// Values are lowercase for consistency with Redis RESP2 protocol responses.
const (
NaN = "nan" // Not a Number
Inf = "inf" // Positive infinity
NInf = "-inf" // Negative infinity
)
// FormatFloat formats a float64 to string, normalizing special values
// (NaN, Inf) to lowercase for consistency with Redis RESP2 protocol.
func FormatFloat(f float64) string {
switch {
case math.IsNaN(f):
return NaN
case math.IsInf(f, 1):
return Inf
case math.IsInf(f, -1):
return NInf
default:
return strconv.FormatFloat(f, 'f', -1, 64)
}
}
func Sleep(ctx context.Context, dur time.Duration) error {
t := time.NewTimer(dur)
defer t.Stop()
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
}
/*
© 2023–present 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() }
package util
/*
© 2023–present 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() }
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
}
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)
}
package util
func ToPtr[T any](v T) *T {
return &v
}
//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))
}
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
}
// Await the fetch before reading page/cursor: on the deferred
// autopipeline face process() only enqueues, and reading the raw
// fields of a not-yet-executed command would spin re-issuing SCANs
// with a stale cursor forever. Err() blocks until executed there and
// is a no-op read everywhere else.
if err := it.cmd.Err(); 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
}
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
JSONSetWithArgs(ctx context.Context, key, path string, value interface{}, options *JSONSetArgsOptions) *StatusCmd
JSONStrAppend(ctx context.Context, key, path, value string) *IntPointerSliceCmd
JSONStrLen(ctx context.Context, key, path string) *IntPointerSliceCmd
JSONToggle(ctx context.Context, key, path string) *IntPointerSliceCmd
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
}
// FPHAType is the floating-point type used for storing FP homogeneous arrays
// in JSON.SET (Redis 8.8+).
type FPHAType string
const (
FPHATypeBF16 FPHAType = "BF16"
FPHATypeFP16 FPHAType = "FP16"
FPHATypeFP32 FPHAType = "FP32"
FPHATypeFP64 FPHAType = "FP64"
)
// JSONSetArgsOptions are the optional arguments for JSONSetWithArgs.
// Mode is "NX" or "XX" (case-insensitive). FPHA, when set, forces Redis to
// store all FP homogeneous arrays using the specified floating-point type.
type JSONSetArgsOptions struct {
Mode string
FPHA FPHAType
}
type JSONCmd struct {
baseCmd
val string
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 {
cmd.await()
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 {
cmd.await()
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) {
cmd.await()
return cmd.Val(), cmd.Err()
}
// Expanded returns the result of the JSON.GET command as unmarshalled JSON.
func (cmd *JSONCmd) Expanded() (interface{}, error) {
cmd.await()
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.
// Use rawErr() (not Err()): readReply runs inside the batch's Exec, before
// the autopipeline batch's done channel is closed, so Err()->await() would
// deadlock on the very Exec that is calling readReply.
if cmd.baseCmd.rawErr() == Nil {
cmd.val = ""
return Nil
}
// Handle other base command errors
if cmd.baseCmd.rawErr() != nil {
return cmd.baseCmd.rawErr()
}
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 {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *JSONSliceCmd) SetVal(val []interface{}) {
cmd.val = val
}
func (cmd *JSONSliceCmd) Val() []interface{} {
cmd.await()
return cmd.val
}
func (cmd *JSONSliceCmd) Result() ([]interface{}, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *JSONSliceCmd) readReply(rd *proto.Reader) error {
// rawErr(), not Err(): readReply runs inside Exec before the batch's done
// channel closes, so Err()->await() would deadlock (see JSONCmd.readReply).
if cmd.baseCmd.rawErr() == 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 err
} 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 {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *IntPointerSliceCmd) SetVal(val []*int64) {
cmd.val = val
}
func (cmd *IntPointerSliceCmd) Val() []*int64 {
cmd.await()
return cmd.val
}
func (cmd *IntPointerSliceCmd) Result() ([]*int64, error) {
cmd.await()
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 {
return c.JSONSetWithArgs(ctx, key, path, value, &JSONSetArgsOptions{Mode: mode})
}
// JSONSetWithArgs sets the JSON value at the given path in the given key with optional arguments
// for setting mode (NX/XX) and the FPHA (Floating-Point Homogeneous Array) type used for storing
// FP arrays. The value must be something that can be marshaled to JSON (using encoding/JSON) unless
// the argument is a string or []byte when we assume that it can be passed directly as JSON.
// For more information, see https://redis.io/commands/json.set
func (c cmdable) JSONSetWithArgs(ctx context.Context, key, path string, value interface{}, options *JSONSetArgsOptions) *StatusCmd {
var bytes []byte
var err error
switch v := value.(type) {
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 options != nil {
if options.Mode != "" {
switch strings.ToUpper(options.Mode) {
case "XX", "NX":
args = append(args, strings.ToUpper(options.Mode))
default:
panic("redis: JSON.SET mode must be NX or XX")
}
}
if options.FPHA != "" {
args = append(args, "FPHA", string(options.FPHA))
}
}
cmd := NewStatusCmd(ctx, args...)
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
}
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
LMoveM(ctx context.Context, source, destination, srcpos, destpos string, args LMoveMArgs) *StringSliceCmd
BLMoveM(ctx context.Context, source, destination, srcpos, destpos string, timeout time.Duration, args LMoveMArgs) *StringSliceCmd
}
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
}
// LMoveMMode is the count semantics for LMOVEM/BLMOVEM.
type LMoveMMode string
const (
LMoveMCount LMoveMMode = "COUNT" // up to Count
LMoveMExactly LMoveMMode = "EXACTLY" // exactly Count, or nothing
)
// LMoveMOrder is the destination ordering for LMOVEM/BLMOVEM.
type LMoveMOrder string
const (
LMoveMOBO LMoveMOrder = "OBO" // one-by-one, order reversed
LMoveMBulk LMoveMOrder = "BULK" // preserve order
)
// LMoveMArgs configures the optional count group of LMOVEM/BLMOVEM.
// Count <= 0 moves a single element. Mode defaults to COUNT, Order to BULK.
type LMoveMArgs struct {
Mode LMoveMMode
Count int64
Order LMoveMOrder
}
func (a LMoveMArgs) appendArgs(args []interface{}) []interface{} {
if a.Count <= 0 {
return args
}
mode := a.Mode
if mode == "" {
mode = LMoveMCount
}
order := a.Order
if order == "" {
order = LMoveMBulk
}
return append(args, string(mode), a.Count, string(order))
}
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
}
// LMoveM atomically moves multiple elements between lists (Redis 8.10+).
// srcpos/destpos are "LEFT" or "RIGHT". Returns moved elements, or redis.Nil if none.
func (c cmdable) LMoveM(ctx context.Context, source, destination, srcpos, destpos string, a LMoveMArgs) *StringSliceCmd {
args := make([]interface{}, 5, 8)
args[0], args[1], args[2], args[3], args[4] = "lmovem", source, destination, srcpos, destpos
args = a.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BLMoveM is the blocking variant of LMoveM (Redis 8.10+); timeout 0 blocks forever.
// Returns moved elements, or redis.Nil on timeout.
func (c cmdable) BLMoveM(ctx context.Context, source, destination, srcpos, destpos string, timeout time.Duration, a LMoveMArgs) *StringSliceCmd {
args := make([]interface{}, 6, 9)
args[0], args[1], args[2], args[3], args[4] = "blmovem", source, destination, srcpos, destpos
args[5] = formatSec(ctx, timeout)
args = a.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
package redis
import (
"context"
"math"
"sync"
"sync/atomic"
"time"
)
// cacheEntryState tracks the lifecycle of a local cache entry.
type cacheEntryState uint8
const (
// cacheEntryInProgress marks a placeholder entry while a value is being fetched.
cacheEntryInProgress cacheEntryState = iota
// cacheEntryValid marks an entry that contains a value that can be returned.
cacheEntryValid
)
// cacheEntry represents a cached command reply and its Redis-key associations.
type cacheEntry struct {
cacheKey string
redisKeys []string
value []byte
state cacheEntryState
token uint64
sizeBytes int64
reservedAt time.Time
waitCh chan struct{}
waitClosed bool
// lastAccessNs is a recency token for LRU eviction: a global atomic counter
// bumped on every access, stored atomically so the read path can mark a
// touch under the shard's RLock without upgrading to a write lock.
lastAccessNs atomic.Int64
// validAt retains time.Now's monotonic component for the MaxStaleness
// backstop, so wall-clock corrections cannot extend an entry's lifetime.
// Written under Lock (Set/Fulfill), read under RLock (get).
validAt time.Time
// fetchSeq is the global cscFetchSeq value at the moment this entry's fetch was
// ISSUED (Reserve), carried unchanged through fulfill. It lets a batched
// invalidation tell "this value predates me" from "this value was refetched
// after me": an invalidation snapshots cscFetchSeq at OBSERVE time, and a delete
// is skipped when entry.fetchSeq > that snapshot (the fetch was issued after the
// invalidate, so it reached a server that had already applied the write). Fetch-
// ISSUE order is used, not fulfill-COMPLETION order, because the invalidation and
// the reply travel on different connections with no ordering — a stale reply can
// fulfill after the invalidate is observed (see deleteByRedisKey). Set/read under
// the shard Lock.
fetchSeq uint64
// ownerConnID is the conn that fetched this entry (set by FulfillOwned; 0 =
// none). Default CLIENT TRACKING sends a key's invalidation only to that
// conn, so the entry must be evicted when it goes away (see EvictByConn).
ownerConnID uint64
}
// lruSequence is the global monotonic counter feeding lastAccessNs. It totally
// orders recency across all entries in all shards for approximate-LRU eviction.
var lruSequence atomic.Int64
// nextLRUToken returns the next strictly-greater LRU token.
func nextLRUToken() int64 {
return lruSequence.Add(1)
}
// cscFetchSeq is the global monotonic counter feeding cacheEntry.fetchSeq. It
// totally orders fetch-ISSUE (Reserve) events against invalidation OBSERVE
// events so a batched delete can skip an entry refetched after the invalidation
// (see cacheEntry.fetchSeq).
var cscFetchSeq atomic.Uint64
// nextFetchSeq returns the next strictly-greater fetch-issue sequence.
func nextFetchSeq() uint64 {
return cscFetchSeq.Add(1)
}
// CacheSizer calculates estimated memory usage in bytes for a cache entry.
//
// Experimental: this API may change in a minor release.
type CacheSizer func(cacheKey string, redisKeys []string, value []byte) int64
// CacheConfig configures a local cache instance.
//
// Experimental: this API may change in a minor release.
type CacheConfig struct {
// MaxEntries limits the number of entries. Zero or negative means unlimited.
MaxEntries int
// MaxMemoryBytes limits estimated memory usage in bytes. Zero or negative means unlimited.
//
// If both MaxEntries and MaxMemoryBytes are unlimited, MaxEntries defaults to
// defaultCacheMaxEntries so the cache cannot grow without bound. The cache is
// sharded 16 ways (above small thresholds) and each shard enforces its 1/16
// share, so an entry larger than MaxMemoryBytes/16 is never admitted —
// size it to at least 16× your largest reply.
MaxMemoryBytes int64
// Sizer estimates memory usage per entry. If nil, a built-in approximation is used.
//
// Sizer may be invoked concurrently from multiple goroutines and must be
// thread-safe. It must return quickly and must not call back into the
// cache (Get, Set, Delete*, Flush, etc.): some call sites hold an internal
// shard lock, so re-entry can deadlock.
Sizer CacheSizer
// StaleTimeout is the duration after which an IN_PROGRESS placeholder is
// considered stale and eligible for takeover by a new Reserve call.
// If zero, defaults to defaultStaleTimeout (5s).
StaleTimeout time.Duration
// DrainInterval is the background-drainer period (default 5ms; zero uses the
// default): how often idle pool conns are swept for buffered "invalidate"
// frames, roughly bounding cache-hit staleness. Values below 1ms are clamped
// to 1ms.
DrainInterval time.Duration
// MaxStaleness caps how long a cached entry is served after it became valid,
// regardless of invalidation. It is a correctness
// BACKSTOP for lost invalidations or connection-lifecycle gaps ("Window 2"), not
// the primary freshness mechanism. Keep it well above the invalidation round-trip
// (e.g. seconds); per-entry refetch overhead scales ~1/MaxStaleness.
//
// Default: 0 (disabled).
MaxStaleness time.Duration
}
// Cache is the thread-safe storage contract used by client-side caching.
//
// All methods may be called concurrently. Cache keys and Redis keys are opaque
// strings and must be preserved exactly. Removing a reservation must wake any
// Get calls waiting for it.
//
// Reserve must allow only one caller to fetch a missing key and return a token
// that is valid until FulfillOwned, Cancel, or an eviction removes that
// reservation. FulfillOwned and Cancel must modify only a reservation with the
// matching token. Get may wait for an in-progress reservation and must stop
// waiting when ctx is done.
//
// Experimental: this API may change in a minor release.
type Cache interface {
Get(ctx context.Context, cacheKey string) ([]byte, bool)
Reserve(cacheKey string, redisKeys []string) (token uint64, shouldFetch bool)
// FulfillOwned publishes a reserved value and records the connection that
// fetched it so the entry can be evicted if that connection loses tracking.
FulfillOwned(cacheKey string, token, ownerConnID uint64, value []byte) bool
Cancel(cacheKey string, token uint64) bool
DeleteByRedisKey(redisKey string) int
DeleteByCacheKey(cacheKey string) bool
// EvictByConn removes every entry fetched by connID.
EvictByConn(connID uint64) int
Flush() int
}
const (
defaultStaleTimeout = 5 * time.Second
defaultCacheShardCount = 16
// defaultCacheMaxEntries bounds the cache when the config leaves both
// MaxEntries and MaxMemoryBytes unlimited (matches the 10k-entry default
// other Redis clients use, e.g. redis-py).
defaultCacheMaxEntries = 10000
// shardingThresholdEntries / shardingThresholdBytes: caches with capacity
// below these thresholds fall back to a single shard so global LRU /
// memory-cap semantics behave exactly as a non-sharded cache would.
shardingThresholdEntries = 64
shardingThresholdBytes = 64 * 1024
)
// NewLocalCache creates a thread-safe local cache with approximate-LRU
// eviction. The cache is internally sharded by cache-key hash to reduce
// mutex contention under high concurrent access.
//
// Experimental: this API may change in a minor release.
func NewLocalCache(cfg CacheConfig) *LocalCache {
sizer := cfg.Sizer
if sizer == nil {
sizer = defaultCacheSizer
}
staleTimeout := cfg.StaleTimeout
if staleTimeout <= 0 {
staleTimeout = defaultStaleTimeout
}
maxEntries := cfg.MaxEntries
maxMemoryBytes := cfg.MaxMemoryBytes
// An unbounded cache can grow until the process OOMs; require at least
// one limit.
if maxEntries <= 0 && maxMemoryBytes <= 0 {
maxEntries = defaultCacheMaxEntries
}
shardCount := defaultCacheShardCount
if maxEntries > 0 && maxEntries < shardingThresholdEntries {
shardCount = 1
}
if maxMemoryBytes > 0 && maxMemoryBytes < int64(shardingThresholdBytes) {
shardCount = 1
}
c := &LocalCache{
shards: make([]cacheShard, shardCount),
shardCount: uint32(shardCount),
shardMask: uint32(shardCount - 1),
sizer: sizer,
}
for i := range c.shards {
s := &c.shards[i]
s.entries = make(map[string]*cacheEntry)
s.byRedisKey = make(map[string]map[string]struct{})
s.byConnID = make(map[uint64]map[string]struct{})
// Distribute capacity so the per-shard caps sum to exactly the
// configured limits; a ceil-per-shard split would let total residency
// exceed MaxEntries/MaxMemoryBytes.
if maxEntries > 0 {
s.maxEntries = maxEntries / shardCount
if i < maxEntries%shardCount {
s.maxEntries++
}
}
if maxMemoryBytes > 0 {
s.maxMemoryBytes = maxMemoryBytes / int64(shardCount)
if int64(i) < maxMemoryBytes%int64(shardCount) {
s.maxMemoryBytes++
}
}
s.maxStaleness = cfg.MaxStaleness
s.sizer = sizer
s.staleTimeout = staleTimeout
}
return c
}
// effectiveMaxStaleness reports the cache's staleness bound (0 = none). Every
// shard carries the same value, so shard 0 is authoritative. Used by
// Options.init to run the batch-window-vs-staleness sanity warning for an
// INJECTED *LocalCache too, where no ClientSideCacheConfig exists to read.
func (c *LocalCache) effectiveMaxStaleness() time.Duration {
if len(c.shards) == 0 {
return 0
}
return c.shards[0].maxStaleness
}
// LocalCache is the built-in sharded approximate-LRU cache.
//
// Experimental: this API may change in a minor release.
type LocalCache struct {
shards []cacheShard
shardCount uint32
shardMask uint32
sizer CacheSizer
nextToken atomic.Uint64
hits atomic.Uint64
misses atomic.Uint64
// Invalidation accounting for refresh-on-invalidate (see CSCRefreshStats).
// invalidations counts keys named in INCOMING pushes, tallied once at the
// handler choke point before dedup/batching. deletions/deletionsNoop count
// APPLIED deletes (post-dedup) and the subset that matched no live entry. The
// gap between invalidations and deletions is the direct measure of dedup +
// duplicate invalidations (and, under a flood, the spill-cap full-Flush).
invalidations atomic.Uint64
deletions atomic.Uint64
deletionsNoop atomic.Uint64
}
var _ Cache = (*LocalCache)(nil)
// cacheShard holds the state for one shard of LocalCache. The mutex
// protects entries, byRedisKey, byConnID, and usedBytes.
type cacheShard struct {
mu sync.RWMutex
entries map[string]*cacheEntry
byRedisKey map[string]map[string]struct{}
// byConnID is the owning-conn reverse index (twin of byRedisKey): conn id ->
// its cache keys. Populated by FulfillOwned, cleaned in removeEntryLocked,
// consumed by EvictByConn.
byConnID map[uint64]map[string]struct{}
usedBytes int64
maxEntries int
maxMemoryBytes int64
maxStaleness time.Duration
sizer CacheSizer
staleTimeout time.Duration
}
// shardFor returns the shard responsible for cacheKey.
func (c *LocalCache) shardFor(cacheKey string) *cacheShard {
if c.shardCount == 1 {
return &c.shards[0]
}
return &c.shards[fnv1a32(cacheKey)&c.shardMask]
}
// fnv1a32 returns the FNV-1a 32-bit hash of s. Allocation-free.
func fnv1a32(s string) uint32 {
const (
offset uint32 = 2166136261
prime uint32 = 16777619
)
h := offset
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= prime
}
return h
}
const defaultCacheEntryOverhead int64 = 96
func defaultCacheSizer(cacheKey string, redisKeys []string, value []byte) int64 {
size := defaultCacheEntryOverhead + int64(len(cacheKey)+len(value))
for _, key := range redisKeys {
size += int64(len(key)) + 16
}
if size < 0 {
return 0
}
return size
}
// Get returns a copy of a cached value, waiting for an in-progress fetch when
// necessary.
func (c *LocalCache) Get(ctx context.Context, cacheKey string) ([]byte, bool) {
if ctx == nil {
ctx = context.Background()
}
value, ok := c.shardFor(cacheKey).get(ctx, cacheKey)
if ok {
c.hits.Add(1)
} else {
c.misses.Add(1)
}
return value, ok
}
// get is the read-side hot path. Holds only the shard's read lock; updates
// the LRU recency timestamp via atomic store on the entry — no write-lock
// upgrade is needed.
func (s *cacheShard) get(ctx context.Context, cacheKey string) ([]byte, bool) {
for {
s.mu.RLock()
entry, ok := s.entries[cacheKey]
if !ok {
s.mu.RUnlock()
return nil, false
}
if entry.state == cacheEntryInProgress {
waitCh := entry.waitCh
// Bound the wait by the placeholder's remaining stale window so an
// abandoned reservation cannot block waiters indefinitely.
remaining := s.staleTimeout - time.Since(entry.reservedAt)
s.mu.RUnlock()
if waitCh == nil {
// Defensive: treat a missing waitCh as a miss to avoid busy-looping.
return nil, false
}
if remaining <= 0 {
// Placeholder already stale; miss so the caller refetches.
return nil, false
}
// Wait for the in-flight fetch to either publish (Fulfill) or abort (Cancel/Delete/Flush).
timer := time.NewTimer(remaining)
select {
case <-waitCh:
timer.Stop()
case <-ctx.Done():
timer.Stop()
return nil, false
case <-timer.C:
return nil, false
}
continue
}
if entry.state != cacheEntryValid {
s.mu.RUnlock()
return nil, false
}
// Max-staleness backstop: a Valid entry older than maxStaleness is treated
// as a miss and evicted, so a lost invalidation or connection-lifecycle
// staleness (Window 2) cannot keep a stale value resident past MaxStaleness.
// Evict under the write lock so the next access re-fetches — a stale-but-present
// entry would otherwise suppress the re-fetch via Reserve.
if s.maxStaleness > 0 && time.Since(entry.validAt) > s.maxStaleness {
s.mu.RUnlock()
s.mu.Lock()
if cur, ok := s.entries[cacheKey]; ok && cur == entry {
s.removeEntryLocked(cacheKey)
}
s.mu.Unlock()
return nil, false
}
value := cloneBytes(entry.value)
// Record access timestamp without upgrading the lock. Last writer
// wins; cross-goroutine ordering of timestamps is fine for
// approximate-LRU semantics.
entry.lastAccessNs.Store(nextLRUToken())
s.mu.RUnlock()
return value, true
}
}
// Stats returns cumulative activity and current residency.
func (c *LocalCache) Stats() CSCStats {
return CSCStats{
Hits: c.hits.Load(),
Misses: c.misses.Load(),
Entries: c.Len(),
MemoryUsageBytes: c.MemoryUsage(),
}
}
// Reserve claims a missing cache key for fetching.
func (c *LocalCache) Reserve(cacheKey string, redisKeys []string) (token uint64, shouldFetch bool) {
keysCopy := cloneStrings(redisKeys)
waitCh := make(chan struct{})
reservedAt := time.Now()
sizeBytes := c.sizer(cacheKey, keysCopy, nil)
if sizeBytes < 0 {
sizeBytes = 0
}
newToken := c.nextToken.Add(1)
s := c.shardFor(cacheKey)
s.mu.Lock()
defer s.mu.Unlock()
if entry, ok := s.entries[cacheKey]; ok {
switch entry.state {
case cacheEntryValid:
// Existing-VALID hit: record access; caller will re-Get to
// retrieve.
entry.lastAccessNs.Store(nextLRUToken())
return 0, false
case cacheEntryInProgress:
if time.Since(entry.reservedAt) < s.staleTimeout {
return 0, false
}
s.removeEntryLocked(cacheKey)
default:
return 0, false
}
}
if s.maxMemoryBytes > 0 && sizeBytes > s.maxMemoryBytes {
return 0, true
}
entry := &cacheEntry{
cacheKey: cacheKey,
redisKeys: keysCopy,
state: cacheEntryInProgress,
token: newToken,
reservedAt: reservedAt,
waitCh: waitCh,
sizeBytes: sizeBytes,
// Stamp fetch-ISSUE order now so a later invalidation can tell a value
// refetched after it (keep) from one that predates it (evict). Carried
// through fulfill unchanged. See cacheEntry.fetchSeq.
fetchSeq: nextFetchSeq(),
}
entry.lastAccessNs.Store(nextLRUToken())
s.setEntryLocked(entry)
// Evict only Valid victims. If still over capacity the shard holds only
// in-flight placeholders: rather than abort a peer's fetch, drop this
// reservation (the caller fetches uncached). The hard cap holds either way.
s.evictValidLocked()
if s.overCapacityLocked() {
s.removeEntryLocked(cacheKey)
return 0, true
}
if s.entries[cacheKey] != entry {
return 0, true
}
return newToken, true
}
// FulfillOwned publishes a reserved value and records ownerConnID so
// EvictByConn can drop it when that connection is removed. ownerConnID == 0
// leaves the value unowned.
func (c *LocalCache) FulfillOwned(cacheKey string, token, ownerConnID uint64, value []byte) bool {
return c.fulfill(cacheKey, token, ownerConnID, value)
}
func (c *LocalCache) fulfill(cacheKey string, token, ownerConnID uint64, value []byte) bool {
valueCopy := cloneBytes(value)
s := c.shardFor(cacheKey)
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[cacheKey]
if !ok || entry.state != cacheEntryInProgress || entry.token != token {
return false
}
valueSize := s.sizer(cacheKey, entry.redisKeys, valueCopy)
if valueSize < 0 {
valueSize = 0
}
if s.maxMemoryBytes > 0 && valueSize > s.maxMemoryBytes {
s.removeEntryLocked(cacheKey)
return false
}
s.usedBytes += valueSize - entry.sizeBytes
entry.value = valueCopy
entry.sizeBytes = valueSize
entry.state = cacheEntryValid
entry.validAt = time.Now()
entry.token = 0
entry.lastAccessNs.Store(nextLRUToken())
if ownerConnID != 0 {
entry.ownerConnID = ownerConnID
s.indexConnLocked(ownerConnID, cacheKey)
}
s.closeWaitersLocked(entry)
s.evictIfNeededLocked()
current, stillExists := s.entries[cacheKey]
return stillExists && current == entry && entry.state == cacheEntryValid
}
// restoreAccessToken resets a Valid entry's reader-access recency (lastAccessNs)
// to accessNs, undoing the fresh token fulfill stamps. The refresh republish calls
// this so a background refresh does NOT count as a reader access: otherwise the
// refreshed key stays above the refresh horizon and every later invalidation
// refreshes it again even after all readers stop — a self-sustaining refetch loop
// contrary to the cold-key guard. A reader that get()s between the fulfill and this
// call has its newer token overwritten, which only UNDER-refreshes that key (the
// next reader refetches), never serves stale. No-op if the entry is gone or not
// Valid.
func (c *LocalCache) restoreAccessToken(cacheKey string, accessNs int64) {
s := c.shardFor(cacheKey)
s.mu.Lock()
defer s.mu.Unlock()
if entry, ok := s.entries[cacheKey]; ok && entry.state == cacheEntryValid {
entry.lastAccessNs.Store(accessNs)
}
}
// EvictByConn removes every entry fetched by connID and returns the count.
// Called when a conn is removed/swapped: the server stops delivering those
// keys' invalidations, so keeping them risks stale serves. Errs toward a miss.
func (c *LocalCache) EvictByConn(connID uint64) int {
if connID == 0 {
return 0
}
removed := 0
for i := range c.shards {
removed += c.shards[i].evictByConn(connID)
}
return removed
}
func (s *cacheShard) evictByConn(connID uint64) int {
s.mu.Lock()
defer s.mu.Unlock()
cacheKeys, ok := s.byConnID[connID]
if !ok {
return 0
}
toRemove := make([]string, 0, len(cacheKeys))
for cacheKey := range cacheKeys {
toRemove = append(toRemove, cacheKey)
}
removed := 0
for _, cacheKey := range toRemove {
if s.removeEntryLocked(cacheKey) {
removed++
}
}
return removed
}
// indexConnLocked records cacheKey under connID in the owning-connection index.
func (s *cacheShard) indexConnLocked(connID uint64, cacheKey string) {
cacheKeys := s.byConnID[connID]
if cacheKeys == nil {
cacheKeys = make(map[string]struct{})
s.byConnID[connID] = cacheKeys
}
cacheKeys[cacheKey] = struct{}{}
}
// Cancel removes the reservation matching token.
func (c *LocalCache) Cancel(cacheKey string, token uint64) bool {
s := c.shardFor(cacheKey)
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[cacheKey]
if !ok || entry.state != cacheEntryInProgress || entry.token != token {
return false
}
s.removeEntryLocked(cacheKey)
return true
}
// DeleteByRedisKey removes entries associated with redisKey. It is the applied-
// delete path used when refresh-on-invalidate is off (the collecting variant is
// used when it is on); counting deletions here keeps DeletionStats accurate on
// both paths. The two are disjoint — neither calls the other — so no double count.
func (c *LocalCache) DeleteByRedisKey(redisKey string) int {
removed := 0
for i := range c.shards {
removed += c.shards[i].deleteByRedisKey(redisKey)
}
c.deletions.Add(1)
if removed == 0 {
c.deletionsNoop.Add(1)
}
return removed
}
func (s *cacheShard) deleteByRedisKey(redisKey string) int {
s.mu.Lock()
defer s.mu.Unlock()
cacheKeys, ok := s.byRedisKey[redisKey]
if !ok {
return 0
}
// Remove IN_PROGRESS placeholders too: an invalidation can arrive on a
// different stream than the in-flight reply (the background drainer), so the
// fetch may predate the write. Removing makes the racing Fulfill fail and
// waiters refetch, so a raced-invalidation value is never published.
toRemove := make([]string, 0, len(cacheKeys))
for cacheKey := range cacheKeys {
toRemove = append(toRemove, cacheKey)
}
removed := 0
for _, cacheKey := range toRemove {
if s.removeEntryLocked(cacheKey) {
removed++
}
}
return removed
}
// DeleteByCacheKey removes one entry by its internal cache key.
func (c *LocalCache) DeleteByCacheKey(cacheKey string) bool {
s := c.shardFor(cacheKey)
s.mu.Lock()
defer s.mu.Unlock()
return s.removeEntryLocked(cacheKey)
}
// Flush removes all entries.
func (c *LocalCache) Flush() int {
removed := 0
for i := range c.shards {
removed += c.shards[i].flush()
}
return removed
}
func (s *cacheShard) flush() int {
s.mu.Lock()
defer s.mu.Unlock()
// Flush placeholders too (see deleteByRedisKey): a flush (FLUSHDB, or the
// owned-cache flush on Close) means everything, including in-flight fetches,
// may be stale.
removed := 0
for cacheKey := range s.entries {
if s.removeEntryLocked(cacheKey) {
removed++
}
}
return removed
}
// Len returns the current number of entries and reservations.
func (c *LocalCache) Len() int {
n := 0
for i := range c.shards {
s := &c.shards[i]
s.mu.RLock()
n += len(s.entries)
s.mu.RUnlock()
}
return n
}
// MemoryUsage returns the cache's estimated memory usage in bytes.
func (c *LocalCache) MemoryUsage() int64 {
var total int64
for i := range c.shards {
s := &c.shards[i]
s.mu.RLock()
total += s.usedBytes
s.mu.RUnlock()
}
return total
}
func (s *cacheShard) setEntryLocked(entry *cacheEntry) {
if old, exists := s.entries[entry.cacheKey]; exists {
s.removeEntryLocked(old.cacheKey)
}
s.entries[entry.cacheKey] = entry
s.usedBytes += entry.sizeBytes
for _, redisKey := range entry.redisKeys {
cacheKeys := s.byRedisKey[redisKey]
if cacheKeys == nil {
cacheKeys = make(map[string]struct{})
s.byRedisKey[redisKey] = cacheKeys
}
cacheKeys[entry.cacheKey] = struct{}{}
}
}
func (s *cacheShard) removeEntryLocked(cacheKey string) bool {
entry, exists := s.entries[cacheKey]
if !exists {
return false
}
delete(s.entries, cacheKey)
s.usedBytes -= entry.sizeBytes
if s.usedBytes < 0 {
s.usedBytes = 0
}
for _, redisKey := range entry.redisKeys {
cacheKeys := s.byRedisKey[redisKey]
if cacheKeys == nil {
continue
}
delete(cacheKeys, cacheKey)
if len(cacheKeys) == 0 {
delete(s.byRedisKey, redisKey)
}
}
if entry.ownerConnID != 0 {
if cacheKeys := s.byConnID[entry.ownerConnID]; cacheKeys != nil {
delete(cacheKeys, cacheKey)
if len(cacheKeys) == 0 {
delete(s.byConnID, entry.ownerConnID)
}
}
}
s.closeWaitersLocked(entry)
return true
}
func (s *cacheShard) closeWaitersLocked(entry *cacheEntry) {
if entry.waitCh != nil && !entry.waitClosed {
close(entry.waitCh)
entry.waitClosed = true
}
}
func (s *cacheShard) overCapacityLocked() bool {
if s.maxEntries > 0 && len(s.entries) > s.maxEntries {
return true
}
if s.maxMemoryBytes > 0 && s.usedBytes > s.maxMemoryBytes {
return true
}
return false
}
// evictIfNeededLocked evicts by approximate LRU (O(N) scan; rare in
// well-sized caches) until under capacity. Used by Set/Fulfill: it prefers a
// Valid victim but falls back to the oldest IN_PROGRESS placeholder to keep the
// hard cap (that placeholder's Fulfill then fails and its waiters refetch).
func (s *cacheShard) evictIfNeededLocked() {
for s.overCapacityLocked() {
victim := s.oldestLocked(cacheEntryValid)
if victim == nil {
victim = s.oldestLocked(cacheEntryInProgress)
}
if victim == nil {
return
}
s.removeEntryLocked(victim.cacheKey)
}
}
// evictValidLocked evicts only Valid entries until under capacity. Unlike
// evictIfNeededLocked it never evicts a placeholder, so Reserve can't abort a
// peer's in-flight fetch.
func (s *cacheShard) evictValidLocked() {
for s.overCapacityLocked() {
victim := s.oldestLocked(cacheEntryValid)
if victim == nil {
return
}
s.removeEntryLocked(victim.cacheKey)
}
}
// oldestLocked returns the entry in the given state with the smallest
// lastAccessNs (the least-recently-used), or nil when none exists.
func (s *cacheShard) oldestLocked(state cacheEntryState) *cacheEntry {
var victim *cacheEntry
var oldestNs int64 = math.MaxInt64
for _, e := range s.entries {
if e.state != state {
continue
}
if ns := e.lastAccessNs.Load(); ns < oldestNs {
oldestNs = ns
victim = e
}
}
return victim
}
func cloneBytes(src []byte) []byte {
if src == nil {
return nil
}
dst := make([]byte, len(src))
copy(dst, src)
return dst
}
func cloneStrings(src []string) []string {
if len(src) == 0 {
return nil
}
dst := make([]string, len(src))
copy(dst, src)
return dst
}
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
})
}
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
}
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)
}
}
}
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))
}
}
}
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}
}
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
// shutdownTimeout bounds each pool hook's Shutdown during Close. A field
// (not a constant) so tests can exercise the failed-Close-then-retry
// path without waiting out the real budget.
shutdownTimeout time.Duration
// Notification hooks for extensibility
hooks []NotificationHook
hooksMu sync.RWMutex // Protects hooks slice
poolHooksRef *PoolHook
// additionalPoolHooks are pool hooks bound to pools other than the primary
// one (e.g. a dedicated pipeline connection pool). Each is an independent
// *PoolHook bound to its own pool because the hook's failed-handoff removal
// target (HandoffRequest.Pool) is taken from the hook's single pool field,
// so one hook cannot safely serve two pools. They share this Manager as
// their operations manager, keeping MOVING/MIGRATING tracking centralized.
additionalPoolHooks []additionalPoolHook
// Connections that successfully enabled maintnotifications. These need to be
// retired before the pool-level listeners are removed.
maintNotificationsConns sync.Map // connID -> *pool.Conn
// Cluster state reload callback for SMIGRATED notifications.
// Stored atomically because it is set from the OnNewNode hook while a node
// client is being created and read from the SMIGRATED push handler on that
// node's connections, which can overlap during connection init.
clusterStateReloadCallback atomic.Pointer[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),
shutdownTimeout: 10 * time.Second,
}
// 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)
}
// additionalPoolHook pairs a pool hook with the pool it was attached to so the
// manager can shut it down and detach it on Close.
type additionalPoolHook struct {
pool pool.Pooler
hook *PoolHook
}
// InitPoolHookForPool attaches a maintnotifications pool hook to an additional
// pool (e.g. a client's dedicated pipeline connection pool). A fresh, independent
// *PoolHook is created and bound to the given pool so that connections which fail
// handoff are removed from the correct pool — the hook's removal target is its own
// single pool field, so the primary hook cannot be reused for a second pool. The
// new hook shares this Manager as its operations manager, so MOVING/MIGRATING
// tracking and notification handling stay centralized across both pools.
func (hm *Manager) InitPoolHookForPool(p pool.Pooler, baseDialer func(context.Context, string, string) (net.Conn, error)) {
if p == nil {
return
}
poolSize := 0
network := ""
if hm.options != nil {
poolSize = hm.options.GetPoolSize()
network = hm.options.GetNetwork()
}
hook := NewPoolHookWithPoolSize(baseDialer, network, hm.config, hm, poolSize)
hook.SetPool(p)
// The closed check, the append, AND the AddPoolHook must all be atomic with
// respect to Close's snapshot: hold the lock across all three so either Close
// runs first (closed==true here, so we neither register nor attach) or we
// register+attach first (Close's snapshot then includes this hook and tears
// it down). Attaching outside the lock left a window where Close could
// snapshot/tear-down between the append and the attach, then AddPoolHook
// would re-attach to a closed manager's pool — leaking an active hook.
// p.AddPoolHook is lock-free (atomic swap on the pool's own hook manager) and
// never calls back into this manager, so holding hooksMu across it is safe.
hm.hooksMu.Lock()
defer hm.hooksMu.Unlock()
if hm.closed.Load() {
return
}
hm.additionalPoolHooks = append(hm.additionalPoolHooks, additionalPoolHook{pool: p, hook: hook})
p.AddPoolHook(hook)
}
// hookForConn returns the pool hook that owns cn's pool: an additional hook
// when the connection came from a secondary pool (e.g. a client's dedicated
// pipeline pool), the primary hook otherwise. Handoffs must be queued through
// the owning hook — the HandoffRequest carries that hook's pool, and a failed
// handoff removes the connection from it, so queuing a pipeline-pool
// connection on the primary hook would close the connection without freeing
// its slot in the pipeline pool's bookkeeping.
func (hm *Manager) hookForConn(cn *pool.Conn) *PoolHook {
if cn == nil {
return hm.poolHooksRef
}
name := cn.PoolName()
if name == "" {
return hm.poolHooksRef
}
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
for _, ah := range hm.additionalPoolHooks {
// Pooler does not expose the name; the concrete pool does. A Pooler
// implementation without it simply never matches and falls through to
// the primary hook — the pre-existing behavior.
if np, ok := ah.pool.(interface{ Name() string }); ok && np.Name() == name {
return ah.hook
}
}
return hm.poolHooksRef
}
// 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
}
// TrackMaintNotificationsConn records a connection that successfully enabled
// maintnotifications so it can be retired if the feature is later disabled for
// the pool.
func (hm *Manager) TrackMaintNotificationsConn(cn *pool.Conn) {
if cn == nil {
return
}
hm.maintNotificationsConns.Store(cn.GetID(), cn)
}
// UntrackMaintNotificationsConn removes a connection from the enabled
// maintnotifications set.
func (hm *Manager) UntrackMaintNotificationsConn(connID uint64) {
hm.maintNotificationsConns.Delete(connID)
}
func (hm *Manager) maintNotificationsConnSnapshot() []*pool.Conn {
var conns []*pool.Conn
hm.maintNotificationsConns.Range(func(_, value interface{}) bool {
if cn, ok := value.(*pool.Conn); ok {
conns = append(conns, cn)
}
return true
})
return conns
}
func (hm *Manager) retireMaintNotificationsConns(ctx context.Context) {
conns := hm.maintNotificationsConnSnapshot()
if len(conns) == 0 {
return
}
// Tracked connections can live in the primary pool OR in any additional
// pool this manager attached a hook to (e.g. a client's dedicated pipeline
// connection pool — its conns run initConn and are tracked exactly like
// primary ones). Retire through every pool: RetireConns skips connections
// a pool does not own, so offering the full snapshot to each pool is safe.
// Missing the additional pools left pipeline connections in service with
// maintnotifications enabled but no hook attached after a runtime
// downgrade — pushes on them were silently dropped.
pools := make([]pool.Pooler, 0, 1+len(hm.additionalPoolHooks))
if hm.pool != nil {
pools = append(pools, hm.pool)
}
hm.hooksMu.RLock()
for _, ah := range hm.additionalPoolHooks {
if ah.pool != nil {
pools = append(pools, ah.pool)
}
}
hm.hooksMu.RUnlock()
for _, pl := range pools {
if retirer, ok := pl.(pool.ConnRetirer); ok {
retirer.RetireConns(ctx, conns, pool.CloseReasonMaintNotificationsDisabled)
continue
}
for _, cn := range conns {
_ = pl.CloseConn(ctx, cn, pool.CloseReasonMaintNotificationsDisabled, pool.MetricStateIdle)
}
}
}
// 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
}
// Retire connections that enabled maintnotifications before removing the
// pool-level listeners that process those push notifications.
hm.retireMaintNotificationsConns(context.Background())
// Shutdown the pool hook if it exists
if hm.poolHooksRef != nil {
// Use a timeout to prevent hanging indefinitely
shutdownCtx, cancel := context.WithTimeout(context.Background(), hm.shutdownTimeout)
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)
}
}
// Shutdown and detach any hooks bound to additional pools (e.g. a dedicated
// pipeline pool). Snapshot under the lock so we don't iterate concurrently
// with a registering InitPoolHookForPool; Shutdown itself runs unlocked.
hm.hooksMu.Lock()
additional := hm.additionalPoolHooks
hm.additionalPoolHooks = nil
hm.hooksMu.Unlock()
for i, ah := range additional {
shutdownCtx, cancel := context.WithTimeout(context.Background(), hm.shutdownTimeout)
err := ah.hook.Shutdown(shutdownCtx)
cancel()
if err != nil {
// Could not cleanly shut down this hook. Put it and the ones not
// yet processed back so a retried Close still sees them, then stay
// open so the caller can retry, matching the primary-hook behavior
// above. Hooks before i already shut down and detached.
hm.hooksMu.Lock()
remaining := make([]additionalPoolHook, 0, len(additional)-i+len(hm.additionalPoolHooks))
remaining = append(remaining, additional[i:]...)
remaining = append(remaining, hm.additionalPoolHooks...)
hm.additionalPoolHooks = remaining
hm.hooksMu.Unlock()
hm.closed.Store(false)
return err
}
if ah.pool != nil {
ah.pool.RemovePoolHook(ah.hook)
}
}
// 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.Store(&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 cb := hm.clusterStateReloadCallback.Load(); cb != nil {
(*cb)(ctx, hostPort, slotRanges)
}
}
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)
}
type maintNotificationsConnTracker interface {
UntrackMaintNotificationsConn(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 {
// Marking can fail if a worker advanced the connection's state between
// our queueHandoff above and here. Re-check ShouldHandoff: with the CAS
// rollback in Conn.MarkQueuedForHandoff, a worker that already cleared
// the handoff state is no longer misreported as ShouldHandoff=true, so a
// cleared connection is reliably detected and pooled here.
if !conn.ShouldHandoff() {
// Handoff was processed - this is normal, pool the connection.
return true, false, nil
}
// Still marked for handoff in an ambiguous state — remove it rather than
// returning a connection a queued worker may still close or replace.
return false, true, nil
}
internal.Logger.Printf(ctx, logs.MarkedForHandoff(conn.GetID()))
return true, false, nil
}
func (ph *PoolHook) OnRemove(_ context.Context, conn *pool.Conn, _ error) {
if tracker, ok := ph.operationsManager.(maintNotificationsConnTracker); ok && conn != nil {
tracker.UntrackMaintNotificationsConn(conn.GetID())
}
}
// Shutdown gracefully shuts down the processor, waiting for workers to complete
func (ph *PoolHook) Shutdown(ctx context.Context) error {
return ph.workerManager.shutdownWorkers(ctx)
}
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 {
// Queue on the hook that owns this connection's pool, not
// unconditionally on the primary one: the request carries the
// hook's pool, and a failed handoff removes the connection
// from it — for a dedicated pipeline-pool connection the
// primary pool cannot do that, which would close the
// connection while leaving a dead slot behind.
owner := snh.manager.hookForConn(poolConn)
if owner != nil && owner.workerManager != nil {
if err := owner.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
}
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"
}
}
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"
"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: 10 milliseconds
MinRetryBackoff time.Duration
// MaxRetryBackoff is the maximum backoff between each retry.
// -1 disables backoff.
// default: 1 second;
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.
// A value <= 0 uses the default.
//
// 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: 5 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: 5 seconds (same as ReadTimeout, which it follows when unset)
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
// PipelineReadBufferSize is the size of the bufio.Reader buffer for pipeline
// connections — the dedicated pipeline pool that serves Pipeline, AutoPipeline
// and AsyncAutoPipeline. That pool always exists (see PipelinePoolSize); this
// field only sizes its read buffers.
//
// This allows you to use large buffers for pipelining (to reduce syscalls and improve
// throughput) while keeping regular command buffers small (to save memory).
//
// If not set (0), the pipeline pool's read buffer is the larger of
// ReadBufferSize and DefaultPipelineBufferSize (128 KiB). The pipeline pool is
// always created and a pipeline uses it whenever it has a free turn; when it
// is saturated the pipeline spills to the regular pool without waiting (a
// non-blocking TryGet), and that connection has the regular ReadBufferSize.
// Size the pipeline pool (PipelinePoolSize) for the pipeline concurrency you
// expect if every pipeline must get this buffer.
//
// Recommended: 64–128 KiB for high-throughput pipelining. The benefit here is
// on the READ side: a batch's replies arrive as one large stream, and a bigger
// buffer consumes them in fewer syscalls instead of refilling repeatedly
// mid-batch. Size it to roughly the reply volume of a typical batch — which
// for read-heavy pipelines is dominated by value sizes, not command count.
// (The write-side counterpart, sizing to the outgoing wire bytes so the batch
// flushes without overflowing mid-write, belongs to PipelineWriteBufferSize.)
// Benchmarks show throughput climbs from the 32 KiB default up to ~64 KiB and
// then plateaus; going beyond ~128 KiB gives no further gain and very large
// buffers (≥512 KiB) can regress throughput and waste memory. Bigger is not
// better.
//
// Example:
// client := redis.NewClient(&redis.Options{
// Addr: "localhost:6379",
// ReadBufferSize: 32 * 1024, // 32 KiB for regular commands
// PipelineReadBufferSize: 128 * 1024, // 128 KiB for pipelining
// PipelineWriteBufferSize: 128 * 1024,
// })
//
// Memory impact: With PoolSize=100 and PipelinePoolSize=10:
// - Raising ReadBufferSize to 128 KiB instead: 100 conns × 128 KiB = 12.8 MB
// - Leaving it at 32 KiB, pipeline pool at its 128 KiB default:
// (100 × 32 KiB) + (10 × 128 KiB) = 4.5 MB (~65% savings)
//
// default: 0 (the larger of ReadBufferSize and DefaultPipelineBufferSize)
PipelineReadBufferSize int
// PipelineWriteBufferSize is the size of the bufio.Writer buffer for pipeline
// connections — the dedicated pipeline pool that serves Pipeline, AutoPipeline
// and AsyncAutoPipeline. That pool always exists (see PipelinePoolSize); this
// field only sizes its write buffers.
//
// This allows you to use large buffers for pipelining (to reduce syscalls and improve
// throughput) while keeping regular command buffers small (to save memory).
//
// If not set (0), the pipeline pool's write buffer is the larger of
// WriteBufferSize and DefaultPipelineBufferSize (128 KiB). As with the read
// buffer, a pipeline that finds the pipeline pool saturated spills to the
// regular pool without waiting and then writes through the regular
// WriteBufferSize; size PipelinePoolSize for your pipeline concurrency if
// every pipeline must get this buffer.
//
// Recommended: 64–128 KiB for high-throughput pipelining (size to roughly
// MaxBatchSize × average-command-bytes). Throughput plateaus past ~64 KiB and
// gains nothing beyond ~128 KiB; very large buffers (≥512 KiB) can regress it.
// See PipelineReadBufferSize for the full rationale.
//
// default: 0 (the larger of WriteBufferSize and DefaultPipelineBufferSize)
PipelineWriteBufferSize int
// PipelinePoolSize is the pool size for the separate pipeline connection pool.
// Setting this alone still sizes the (now always-created) dedicated pipeline
// pool; its buffers default to the larger of the regular buffer size and
// DefaultPipelineBufferSize (128 KiB), unless PipelineReadBufferSize /
// PipelineWriteBufferSize are set.
//
// Pipelining typically needs fewer connections than regular operations because
// batching reduces connection contention. A smaller pool saves memory while
// maintaining high throughput.
//
// The dedicated pipeline pool is created unconditionally at NewClient —
// like the pubsub pool — so pipelines never compete with regular commands
// for main-pool connections. It never pre-dials (MinIdleConns is forced
// to 0 on it), so the size is a cap on burst capacity, not a standing
// footprint: an unused pipeline pool holds zero connections. A burst of
// concurrent pipelines wider than the cap spills to the main pool IMMEDIATELY
// (a non-blocking TryGet on the pipeline pool) rather than waiting a grace
// period — so a saturated pipeline pool never adds latency before falling back,
// and DefaultPipelinePoolTimeout does not gate that spill. Its connections use
// DefaultPipelineBufferSize buffers unless
// the pipeline buffer sizes are set explicitly. It does not inherit
// MaxActiveConns: rather than the ~2x total ceiling that inheriting it
// verbatim would allow, the pipeline pool adds at most PipelinePoolSize
// connections on top of the main pool's MaxActiveConns (so the effective
// ceiling is MaxActiveConns + PipelinePoolSize — a small, bounded addition),
// and the main pool the burst spills to still enforces MaxActiveConns.
//
// Set to a negative value to opt out of the dedicated pool entirely:
// pipelines then run on the main pool, as they did before the pool
// existed.
//
// default: DefaultPipelinePoolSize (10) connections
PipelinePoolSize int
// AutoPipelineOptions is the default config for BOTH autopipeliner faces:
// AutoPipeline and AsyncAutoPipeline use it when called without an
// explicit config, falling back to their per-face defaults
// (DefaultBlockingAutoPipelineOptions / DefaultAutoPipelineOptions) when it
// is nil. Pass a config to either method to override. Commands issued
// through an autopipeliner are batched into pipelines to cut round-trips
// and raise throughput.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
AutoPipelineOptions *AutoPipelineOptions
// 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
// maxConcurrentDialsSet records whether MaxConcurrentDials was set explicitly
// by the caller (>0) BEFORE init() normalized it. init() rewrites a 0 to
// PoolSize, which makes an explicit MaxConcurrentDials==PoolSize afterward
// indistinguishable from the default; pipelinePoolOptions consults this to
// preserve an explicit dial cap for the pipeline pool instead of expanding it.
maxConcurrentDialsSet bool
// maxConcurrentDialsInit latches maxConcurrentDialsSet on the first init().
// A caller may reuse one *Options across more than one NewClient call. The
// first init() normalizes an unset MaxConcurrentDials (0) to PoolSize, so a
// second init() would recompute maxConcurrentDialsSet from the normalized value
// and wrongly mark it explicit, which stops the pipeline pool from widening its
// dial cap. The latch preserves the first decision. Clones copy both flags.
maxConcurrentDialsInit bool
// 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
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// Push notifications are always enabled for RESP3 connections (Protocol: 3)
// 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.
// With client-side caching, a custom processor runs while an idle connection
// is borrowed from the pool and should return promptly.
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
// ClientSideCacheConfig enables client-side caching when non-nil. Together
// with ClientSideCache it is the on/off switch for the feature: leave both
// nil to disable CSC, set either one to enable it. If ClientSideCache is also set, it
// takes precedence over this config.
//
// Client-side caching is disabled when CredentialsProvider,
// CredentialsProviderContext, or StreamingCredentialsProvider is set:
// provider-backed credentials can change the ACL identity after the cache
// namespace is selected. Fixed Username/Password values are supported and
// included in the cache namespace.
//
// Invalidation freshness: a server invalidation is applied when the client
// next reads from the connection that carries it — at arrival on a
// full-duplex autopipeline connection, at the next command on an active
// connection, and at the next background drainer tick on an idle pooled
// connection. In every case a cached entry is never served past the cache's
// MaxStaleness, which is the hard upper bound.
//
// Requires the built-in push processor. Setting a custom
// PushNotificationProcessor together with CSC is not supported: the
// invalidation mechanism relies on the built-in processor to consume
// kernel-only readability and tolerate boundary read timeouts, so behavior is
// otherwise undefined (the client logs a warning at init in that case).
//
// Experimental: this API may change in a minor release.
ClientSideCacheConfig *ClientSideCacheConfig
// ClientSideCache is an explicit Cache implementation used for client-side
// caching. When set, it overrides ClientSideCacheConfig. Intended for
// advanced users that want to share a cache across clients or supply a
// custom implementation.
//
// A shared Cache is only safe across clients on the same server and DB.
// Clients with different fixed Username/Password values are isolated by a
// username namespace.
// Client-side caching is restricted to DB 0 and disabled with a warning
// otherwise. It is also disabled with any credential provider; see
// ClientSideCacheConfig.
//
// Experimental: this API may change in a minor release.
ClientSideCache Cache
// ClientSideCacheStrategy selects the invalidation architecture used when
// client-side caching is enabled (via ClientSideCacheConfig or
// ClientSideCache); it is ignored when CSC is disabled. The zero value is
// CSCStrategySharedTracking, currently the only implemented strategy.
//
// Experimental: this API may change in a minor release.
ClientSideCacheStrategy CSCStrategy
// ClientSideCacheRefreshOnInvalidate re-fetches recently-read keys as soon as
// their invalidation arrives, instead of waiting for a reader to miss.
//
// Requires the built-in cache (ClientSideCacheConfig, or ClientSideCache set
// to a *LocalCache), like the other CSC knobs: the refresher's hot-entry
// collection and publish path are LocalCache-specific. With a custom Cache
// implementation the option is ignored.
//
// Experimental: this API may change in a minor release.
ClientSideCacheRefreshOnInvalidate bool
// ClientSideCacheRefreshRecencyWindow bounds ClientSideCacheRefreshOnInvalidate
// to keys read within this window before their invalidation arrived. 0
// (default) refreshes every invalidated Valid entry, regardless of how long
// ago it was last read. A positive value only refreshes entries whose last
// read falls inside the window; an entry outside it is just evicted (an
// ordinary miss on the next read, same as refresh being off).
//
// Tradeoff: refreshing re-registers the key with the server's tracking
// table, which manufactures the NEXT invalidation on the next write — so
// the default (refresh everything) turns a write-heavy, rarely-read key
// into a self-sustaining refresh loop driven by write traffic, not read
// traffic, for as long as its entry survives capacity eviction. Set a
// window to bound that blast radius to keys actually being read.
//
// Recency is tracked at the existing 200ms tick resolution
// (cscRefreshRecencyTick): a nonzero window is rounded up to the tick
// boundary that guarantees AT LEAST the requested window is covered
// regardless of where an invalidation lands relative to the tick phase, so
// the enforced window is somewhere in [window, window+200ms). It also takes
// up to one tick to reach full accuracy right after the refresher starts.
//
// Ignored unless ClientSideCacheRefreshOnInvalidate is set, and requires
// the built-in cache like that option does.
//
// Experimental: this API may change in a minor release.
ClientSideCacheRefreshRecencyWindow time.Duration
// ClientSideCacheCoalesceMisses coalesces concurrent cache misses so they
// stream on a held tracked full-duplex connection instead of each taking a
// pool connection: a lone miss is written immediately (no batching delay —
// the caller is waiting), concurrent misses share writes opportunistically,
// and new misses go out while earlier replies are still in flight (~1 RTT
// per miss, no batch phase-lock). Cuts pool contention and the churn p99
// tail at a small pool.
//
// Requires the built-in cache (ClientSideCacheConfig, or ClientSideCache set
// to a *LocalCache): the coalescer's publish path (fetch capture, refresh
// integration, hot-entry collection) is LocalCache-specific. With a custom
// Cache implementation the option is ignored and every miss fetches on its
// caller's connection as usual.
//
// Pool sizing: under sustained miss traffic the engine holds one pool
// connection (released after a 1s idle gap and at the recycle age). Size
// PoolSize for that held connection — with PoolSize 1, continuous misses
// can make unrelated non-cacheable commands wait on the pool.
//
// Limiter: a coalescer session holds ONE connection and serves MANY misses on
// it, so Options.Limiter is admitted (Allow/ReportResult) once PER SESSION —
// per held connection — not per coalesced miss, unlike the plain per-command
// path. This is inherent to the held-connection model (a per-miss Allow would
// defeat the coalescing and re-admit a connection already held). A caller that
// needs strict per-command admission or circuit-breaking should not enable
// miss coalescing.
//
// Experimental: this API may change in a minor release.
ClientSideCacheCoalesceMisses bool
// ClientSideCacheInvalidationBatchWindow coalesces invalidation-driven cache
// deletes into windowed background batches instead of applying them inline on
// the connection reader. 0 (default) applies invalidations inline. Set it no
// larger than the cache MaxStaleness: deferring a delete by up to the window
// lets a reader see the pre-invalidation value for up to that long.
//
// Requires the built-in cache (ClientSideCacheConfig, or ClientSideCache set
// to a *LocalCache), like ClientSideCacheCoalesceMisses: the batcher's
// hot-entry refresh integration is LocalCache-specific. With a custom Cache
// implementation the window is ignored and deletes apply inline.
//
// Experimental: this API may change in a minor release.
ClientSideCacheInvalidationBatchWindow time.Duration
}
// CSCStrategy selects the client-side caching invalidation architecture. Set via
// Options.ClientSideCacheStrategy; fixed for the client's lifetime.
//
// CSCStrategySharedTracking is currently the only implemented strategy; the type
// exists as an extension point for additional architectures (e.g. a BCAST sidecar)
// without a breaking API change.
//
// Experimental: this API may change in a minor release.
type CSCStrategy int
const (
// CSCStrategySharedTracking (default, the zero value): one shared cache; every
// pool connection runs plain CLIENT TRACKING ON and a background drainer applies
// buffered invalidations. Portable (no BCAST), and matches the other Redis clients.
CSCStrategySharedTracking CSCStrategy = iota
)
// DefaultPipelinePoolSize is the pipeline pool size used when
// PipelinePoolSize is not set. Pipelining batches many commands per round
// trip, so it needs far fewer connections than regular traffic. The pool is
// pure burst capacity: it never pre-dials idle connections (MinIdleConns is
// forced to 0 on it), so an unused pipeline pool holds no connections at all
// and the size is only a cap — bursts wider than it spill to the main pool.
const DefaultPipelinePoolSize = 10
// DefaultPipelineBufferSize is the per-connection read/write buffer size for
// the dedicated pipeline pool when no explicit pipeline buffer size is set
// (the larger of this and the regular buffer size is used). Pipeline
// connections move whole batches per round trip, so they earn bigger buffers
// than regular per-command traffic: measured on the autopipeline engine,
// throughput plateaus around 64 KiB and gains nothing past ~128 KiB for
// TYPICAL (small-command) traffic, while very large buffers (>=512 KiB) can
// regress it.
//
// Set to 128 KiB anyway: the extra headroom is not about typical-traffic
// throughput but about full-duplex's large-payload backpressure guardrail
// (see MaxBatchBytes's default) — more bufio headroom before a write() to a
// slow-draining peer blocks widens the margin before that guardrail's cap is
// reached. The aggregate memory cost stays negligible because the pool this
// backs holds few connections: the dedicated pipeline pool never pre-dials
// (DefaultPipelinePoolSize, MinIdleConns forced to 0), and full-duplex holds
// exactly one connection per node regardless of pool size.
const DefaultPipelineBufferSize = 128 * 1024
// DefaultPipelinePoolTimeout is the dedicated pipeline pool's PoolTimeout
// (pipelinePoolOptions caps the pipeline clone's PoolTimeout at this value but honors
// a caller's SHORTER PoolTimeout). It does NOT gate the spill to the main pool:
// withPipelineConn acquires with a non-blocking TryGet, so a burst wider than the
// pipeline pool's cap spills to the main pool IMMEDIATELY (see the pipeline-pool note
// in Options and withPipelineConn), never waiting this timeout, and the spilled op
// then uses the MAIN pool's own PoolTimeout, not this one.
//
// It currently has NO other live effect either. Every acquisition against the
// dedicated pipeline pool — withPipelineConn's per-round-trip borrow above, and
// the full-duplex engine's own session lease (autopipeline_fullduplex.go) — uses
// TryGet, never the blocking Get. TryGet's non-wait branch returns ErrPoolTryFull
// at once for BOTH a saturated pool turn and an active maintnotifications drainer
// claim, before waitForDrainer or this deadline is ever consulted (see
// ConnPool.getConn/waitTurn in internal/pool). A previous version of this doc
// claimed a residual drainer-handoff budget; that was inaccurate (codex on #4002)
// — there is no code path that currently waits out this value. It is kept short
// anyway in case a future acquisition path, or a caller that obtains the pool via
// getPipelinePool's exported pool.Pooler interface, uses the blocking Get.
const DefaultPipelinePoolTimeout = 100 * time.Millisecond
func (opt *Options) init() {
if opt.Addr == "" {
opt.Addr = "localhost:6379"
}
// An unknown strategy would thread the CSC gates inconsistently (e.g. tracking
// on with no drainer), serving stale data. Clamp to the only supported value.
switch opt.ClientSideCacheStrategy {
case CSCStrategySharedTracking:
default:
internal.Logger.Printf(context.Background(),
"redis: unknown ClientSideCacheStrategy %d; falling back to CSCStrategySharedTracking",
opt.ClientSideCacheStrategy)
opt.ClientSideCacheStrategy = CSCStrategySharedTracking
}
// Deferring invalidation deletes by more than the cache's MaxStaleness lets a
// reader see a value past the point a received invalidation should have evicted
// it — beyond the staleness contract. Warn (not clamp): the field is
// experimental and a caller may accept it knowingly, but a window larger than
// MaxStaleness is almost always a misconfiguration. Checkable for the built-in
// cache via its config AND for an injected *LocalCache via its effective bound;
// a custom Cache implementation has no MaxStaleness to compare against.
if opt.ClientSideCacheInvalidationBatchWindow > 0 {
staleness := time.Duration(0)
if opt.ClientSideCacheConfig != nil {
staleness = opt.ClientSideCacheConfig.MaxStaleness
} else if lc, ok := opt.ClientSideCache.(*LocalCache); ok && lc != nil {
staleness = lc.effectiveMaxStaleness()
}
if staleness > 0 && opt.ClientSideCacheInvalidationBatchWindow > staleness {
internal.Logger.Printf(context.Background(),
"redis: ClientSideCacheInvalidationBatchWindow (%s) exceeds the cache MaxStaleness (%s); "+
"invalidations can be deferred past the staleness bound, serving stale values",
opt.ClientSideCacheInvalidationBatchWindow, staleness)
}
}
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
}
// <= 0, not == 0: the pool already treats a nonpositive value as the default
// (internal/pool ConnPool.dialConn), so normalize here too — code that
// derives a budget from opt.DialerRetries (the miss-coalescer's acquire
// deadline) must see the same count the pool will actually use.
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)
}
// Record explicit-vs-default BEFORE normalizing (a 0 becomes PoolSize below),
// so pipelinePoolOptions can tell an explicit MaxConcurrentDials==PoolSize from
// the default. Latch on the FIRST init only: a caller may reuse one *Options
// across NewClient calls, and a second init() would see the already-normalized
// value and wrongly mark it explicit. Clones copy the latched flags.
if !opt.maxConcurrentDialsInit {
opt.maxConcurrentDialsSet = opt.MaxConcurrentDials > 0
opt.maxConcurrentDialsInit = true
}
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
} else if opt.Protocol == 3 && opt.ReadBufferSize < proto.MinRESP3ReadBufferSize {
// Too small to hold a push header, the processor would consume frames before
// knowing their name and could swallow a Pub/Sub frame. Clamp to the minimum.
internal.Logger.Printf(context.Background(),
"redis: ReadBufferSize=%d is below the RESP3 minimum %d; clamping.",
opt.ReadBufferSize, proto.MinRESP3ReadBufferSize)
opt.ReadBufferSize = proto.MinRESP3ReadBufferSize
}
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 = 5 * 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 = 10 * time.Millisecond
}
switch opt.MaxRetryBackoff {
case -1:
opt.MaxRetryBackoff = 0
case 0:
opt.MaxRetryBackoff = time.Second
}
if opt.FailingTimeoutSeconds == 0 {
opt.FailingTimeoutSeconds = 15
}
if opt.Protocol == 2 && (opt.ClientSideCache != nil || opt.ClientSideCacheConfig != nil) {
internal.Logger.Printf(context.Background(),
"redis: client-side caching requires Protocol: 3 (RESP3); caching is disabled")
}
// Maintnotifications defaults (handoff workers, queue depth) must cover
// every pool the manager hooks, not just the main one: the dedicated
// pipeline pool adds up to PipelinePoolSize connections whose handoffs run
// through hooks sized from this config (see enableMaintNotificationsUpgrades),
// so derive the defaults from the combined connection ceiling.
maintPoolSize := opt.PoolSize
maintMaxActive := opt.MaxActiveConns
if opt.PipelinePoolSize >= 0 {
pps := opt.PipelinePoolSize
if pps == 0 {
pps = DefaultPipelinePoolSize
}
maintPoolSize += pps
if maintMaxActive > 0 {
// The pipeline pool sits outside MaxActiveConns; its ceiling is
// MaxActiveConns + PipelinePoolSize (see the PipelinePoolSize doc).
maintMaxActive += pps
}
}
opt.MaintNotificationsConfig = opt.MaintNotificationsConfig.ApplyDefaultsWithPoolConfig(maintPoolSize, maintMaxActive)
// skip endpoint detection when maint notifications are disabled.
if opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled {
endpointType := opt.MaintNotificationsConfig.EndpointType
// auto-detect endpoint type if not specified
if endpointType == "" || endpointType == maintnotifications.EndpointTypeAuto {
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)
}
// defaultKeepAliveConfig is the TCP keep-alive policy of the default dialers
// here and in sentinel.go: start probing after 30s idle (below typical LB/NAT
// idle timeouts), then declare the peer dead after 3 unanswered probes 5s
// apart.
var defaultKeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: 30 * time.Second,
Interval: 5 * time.Second,
Count: 3,
}
// 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,
KeepAliveConfig: defaultKeepAliveConfig,
}
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) {
// u.Hostname and u.Port strip the surrounding brackets from IPv6 literals
// (e.g. "[::1]" -> "::1") and handle the missing-port case, which
// net.SplitHostPort instead reports as an error. Relying on them avoids
// leaving the brackets on the host, which the caller's net.JoinHostPort
// would wrap again and turn "redis://[::1]" into "[[::1]]:6379".
host, port := u.Hostname(), u.Port()
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 {
if dur <= 0 {
// disable timeouts
return -1
}
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")
// Pipeline pool (created by default): allow URL-configured clients to opt out
// (pipeline_pool_size=-1) or tune it, otherwise these would be rejected as
// unexpected options. q.int accepts a negative value.
o.PipelinePoolSize = q.int("pipeline_pool_size")
o.PipelineReadBufferSize = q.int("pipeline_read_buffer_size")
o.PipelineWriteBufferSize = q.int("pipeline_write_buffer_size")
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
}
package redis
import (
"cmp"
"context"
"crypto/tls"
"errors"
"fmt"
"math"
"math/rand"
"net"
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/hashtag"
"github.com/redis/go-redis/v9/internal/otel"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/internal/routing"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/redis/go-redis/v9/push"
)
const (
minLatencyMeasurementInterval = 10 * time.Second
)
var (
errClusterNoNodes = errors.New("redis: cluster has no nodes")
errNoWatchKeys = errors.New("redis: Watch requires at least one key")
errWatchCrosslot = errors.New("redis: Watch requires all keys to be in the same slot")
)
// ClusterOptions are used to configure a cluster client and should be
// passed to NewClusterClient.
type ClusterOptions struct {
// A seed list of host:port addresses of cluster nodes.
Addrs []string
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
ClientName string
// NewClient creates a cluster node client with provided name and options.
// If NewClient is set by the user, the user is responsible for handling maintnotifications upgrades and push notifications.
NewClient func(opt *Options) *Client
// The maximum number of retries before giving up. Command is retried
// on network errors and MOVED/ASK redirects.
// Default is 3 retries.
MaxRedirects int
// Enables read-only commands on slave nodes.
ReadOnly bool
// Allows routing read-only commands to the closest master or slave node.
// It automatically enables ReadOnly.
RouteByLatency bool
// RouteByLatencyTolerance widens RouteByLatency from "the single fastest node" to
// "any node within this much of the fastest", round-robining between them.
//
// RouteByLatency alone takes a strict minimum, so when several nodes are equally
// close - replicas sharing an availability zone, say - every client picks the same
// one and the others take no read traffic. Latency is estimated from ten pings and
// refreshed at most every 10s, so a difference well inside the noise can decide the
// whole read load for the next interval.
//
// Zero keeps the strict-minimum behaviour. Has no effect unless RouteByLatency is set.
RouteByLatencyTolerance time.Duration
// Allows routing read-only commands to the random master or slave node.
// It automatically enables ReadOnly.
RouteRandomly bool
// Optional function that returns cluster slots information.
// It is useful to manually create cluster of standalone Redis servers
// and load-balance read/write operations between master and slaves.
// It can use service like ZooKeeper to maintain configuration information
// and Cluster.ReloadState to manually trigger state reloading.
ClusterSlots func(context.Context) ([]ClusterSlot, error)
// Following options are copied from Options struct.
Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
OnConnect func(ctx context.Context, cn *Conn) error
Protocol int
Username string
Password string
CredentialsProvider func() (username string, password string)
CredentialsProviderContext func(ctx context.Context) (username string, password string, err error)
StreamingCredentialsProvider auth.StreamingCredentialsProvider
// MaxRetries is the maximum number of retries before giving up.
// For ClusterClient, retries are disabled by default (set to -1),
// because the cluster client handles all kinds of retries internally.
// This is intentional and differs from the standalone Options default.
MaxRetries int
MinRetryBackoff time.Duration
MaxRetryBackoff time.Duration
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.
// See Options.DialerRetryBackoff for details.
DialerRetryBackoff func(attempt int) time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
ContextTimeoutEnabled bool
// MaxConcurrentDials is the maximum number of concurrent connection creation goroutines.
// If <= 0, each node's pool defaults it to that node's PoolSize. If > PoolSize, it
// is capped at PoolSize. Note: ClusterOptions itself leaves the field un-normalized
// (introspecting it after init still reports the zero value); resolution happens in
// each node's Options so the pipeline pool can widen its own dial cap.
MaxConcurrentDials int
PoolFIFO bool
PoolSize int // applies per cluster node and not for the whole cluster
PoolTimeout time.Duration
MinIdleConns int
MaxIdleConns int
MaxActiveConns int // applies per cluster node and not for the whole cluster
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
ConnMaxLifetimeJitter time.Duration
// 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
// PipelineReadBufferSize, PipelineWriteBufferSize and PipelinePoolSize
// configure an optional separate connection pool used for pipelining on
// each node, with its own (typically larger) buffers. See the same-named
// fields on Options for details. The pool is created only when PipelineReadBufferSize or PipelineWriteBufferSize is set (PipelinePoolSize alone does not enable it).
PipelineReadBufferSize int
PipelineWriteBufferSize int
PipelinePoolSize int
// AutoPipelineOptions is the default config for BOTH autopipeliner faces
// (AutoPipeline and AsyncAutoPipeline), applied when they are called
// without explicit options. See Options.AutoPipelineOptions.
AutoPipelineOptions *AutoPipelineOptions
TLSConfig *tls.Config
// DisableRoutingPolicies disables the request/response policy routing system.
// When disabled, all commands use the legacy routing behavior.
// Experimental. Will be removed when shard picker is fully implemented.
DisableRoutingPolicies 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
IdentitySuffix string // Add suffix to client name. Default is empty.
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.
// 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 upgrades.
// 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 upgrades are in "auto" mode and will be enabled if the server supports it.
// The ClusterClient supports SMIGRATING and SMIGRATED notifications for cluster state management.
// Individual node clients handle other maintenance notifications (MOVING, MIGRATING, etc.).
MaintNotificationsConfig *maintnotifications.Config
// ShardPicker is used to pick a shard when the request_policy is
// ReqDefault and the command has no keys.
ShardPicker routing.ShardPicker
// ClusterStateReloadInterval is the interval for reloading the cluster state.
// MOVED/ASK redirects still trigger an immediate reactive reload, so this
// only bounds how stale a topology can get without traffic errors.
// Default is 60 seconds.
ClusterStateReloadInterval time.Duration
}
func (opt *ClusterOptions) init() {
switch opt.MaxRedirects {
case -1:
opt.MaxRedirects = 0
case 0:
opt.MaxRedirects = 3
}
if opt.RouteByLatency || opt.RouteRandomly {
opt.ReadOnly = true
}
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.PoolSize == 0 {
opt.PoolSize = 5 * runtime.GOMAXPROCS(0)
}
// Do NOT normalize MaxConcurrentDials here. clientOptions() copies this value
// into each node's Options, and the per-node Options.init() normalizes it for
// the node's main pool. Setting it here would make the per-node init treat an
// unset value as explicit (maxConcurrentDialsSet), which makes the pipeline
// pool inherit the node PoolSize as its dial cap instead of widening to
// PipelinePoolSize, and so serializes pipeline dials.
if opt.ReadBufferSize == 0 {
opt.ReadBufferSize = proto.DefaultBufferSize
}
if opt.WriteBufferSize == 0 {
opt.WriteBufferSize = proto.DefaultBufferSize
}
switch opt.ReadTimeout {
case -1:
opt.ReadTimeout = 0
case 0:
opt.ReadTimeout = 5 * time.Second
}
switch opt.WriteTimeout {
case -1:
opt.WriteTimeout = 0
case 0:
opt.WriteTimeout = opt.ReadTimeout
}
if opt.MaxRetries == 0 {
opt.MaxRetries = -1
}
switch opt.MinRetryBackoff {
case -1:
opt.MinRetryBackoff = 0
case 0:
opt.MinRetryBackoff = 10 * time.Millisecond
}
switch opt.MaxRetryBackoff {
case -1:
opt.MaxRetryBackoff = 0
case 0:
opt.MaxRetryBackoff = time.Second
}
if opt.NewClient == nil {
opt.NewClient = NewClient
}
if opt.FailingTimeoutSeconds == 0 {
opt.FailingTimeoutSeconds = 15
}
if opt.ShardPicker == nil {
opt.ShardPicker = &routing.RoundRobinPicker{}
}
if opt.ClusterStateReloadInterval == 0 {
opt.ClusterStateReloadInterval = 60 * time.Second
}
}
// ParseClusterURL parses a URL into ClusterOptions that can be used to connect to Redis.
// The URL must be in the form:
//
// redis://<user>:<password>@<host>:<port>
// or
// rediss://<user>:<password>@<host>:<port>
//
// To add additional addresses, specify the query parameter, "addr" one or more times. e.g:
//
// redis://<user>:<password>@<host>:<port>?addr=<host2>:<port2>&addr=<host3>:<port3>
// or
// rediss://<user>:<password>@<host>:<port>?addr=<host2>:<port2>&addr=<host3>:<port3>
//
// 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
//
// Example:
//
// redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791
// is equivalent to:
// &ClusterOptions{
// Addr: ["localhost:6789", "localhost:6790", "localhost:6791"]
// DialTimeout: 3 * time.Second, // no time unit = seconds
// ReadTimeout: 6 * time.Second,
// }
func ParseClusterURL(redisURL string) (*ClusterOptions, error) {
o := &ClusterOptions{}
u, err := url.Parse(redisURL)
if err != nil {
return nil, err
}
// add base URL to the array of addresses
// more addresses may be added through the URL params
h, p := getHostPortWithDefaults(u)
o.Addrs = append(o.Addrs, net.JoinHostPort(h, p))
// setup username, password, and other configurations
o, err = setupClusterConn(u, h, o)
if err != nil {
return nil, err
}
return o, nil
}
// setupClusterConn gets the username and password from the URL and the query parameters.
func setupClusterConn(u *url.URL, host string, o *ClusterOptions) (*ClusterOptions, error) {
switch u.Scheme {
case "rediss":
o.TLSConfig = &tls.Config{ServerName: host}
fallthrough
case "redis":
o.Username, o.Password = getUserPassword(u)
default:
return nil, fmt.Errorf("redis: invalid URL scheme: %s", u.Scheme)
}
// retrieve the configuration from the query parameters
o, err := setupClusterQueryParams(u, o)
if err != nil {
return nil, err
}
return o, nil
}
// setupClusterQueryParams converts query parameters in u to option value in o.
func setupClusterQueryParams(u *url.URL, o *ClusterOptions) (*ClusterOptions, error) {
q := queryOptions{q: u.Query()}
o.Protocol = q.int("protocol")
o.ClientName = q.string("client_name")
o.MaxRedirects = q.int("max_redirects")
o.ReadOnly = q.bool("read_only")
o.RouteByLatency = q.bool("route_by_latency")
o.RouteByLatencyTolerance = q.duration("route_by_latency_tolerance")
o.RouteRandomly = q.bool("route_randomly")
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.DialerRetries = q.int("dialer_retries")
o.DialerRetryTimeout = q.duration("dialer_retry_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.MaxConcurrentDials = q.int("max_concurrent_dials")
o.MinIdleConns = q.int("min_idle_conns")
o.MaxIdleConns = q.int("max_idle_conns")
o.MaxActiveConns = q.int("max_active_conns")
// Pipeline pool (per node, created by default): allow URL opt-out
// (pipeline_pool_size=-1) / tuning, else rejected as unexpected options.
o.PipelinePoolSize = q.int("pipeline_pool_size")
o.PipelineReadBufferSize = q.int("pipeline_read_buffer_size")
o.PipelineWriteBufferSize = q.int("pipeline_write_buffer_size")
o.PoolTimeout = q.duration("pool_timeout")
o.ConnMaxLifetime = q.duration("conn_max_lifetime")
if q.has("conn_max_lifetime_jitter") {
o.ConnMaxLifetimeJitter = min(q.duration("conn_max_lifetime_jitter"), o.ConnMaxLifetime)
}
o.ConnMaxIdleTime = q.duration("conn_max_idle_time")
o.FailingTimeoutSeconds = q.int("failing_timeout_seconds")
if q.err != nil {
return nil, q.err
}
// addr can be specified as many times as needed
addrs := q.strings("addr")
for _, addr := range addrs {
h, p, err := net.SplitHostPort(addr)
if err != nil || h == "" || p == "" {
return nil, fmt.Errorf("redis: unable to parse addr param: %s", addr)
}
o.Addrs = append(o.Addrs, net.JoinHostPort(h, p))
}
// 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 (opt *ClusterOptions) clientOptions() *Options {
// Clone MaintNotificationsConfig to avoid sharing between cluster node clients
var maintNotificationsConfig *maintnotifications.Config
if opt.MaintNotificationsConfig != nil {
configClone := *opt.MaintNotificationsConfig
maintNotificationsConfig = &configClone
}
return &Options{
ClientName: opt.ClientName,
Dialer: opt.Dialer,
OnConnect: opt.OnConnect,
Protocol: opt.Protocol,
Username: opt.Username,
Password: opt.Password,
CredentialsProvider: opt.CredentialsProvider,
CredentialsProviderContext: opt.CredentialsProviderContext,
StreamingCredentialsProvider: opt.StreamingCredentialsProvider,
MaxRetries: opt.MaxRetries,
MinRetryBackoff: opt.MinRetryBackoff,
MaxRetryBackoff: opt.MaxRetryBackoff,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
ContextTimeoutEnabled: opt.ContextTimeoutEnabled,
PoolFIFO: opt.PoolFIFO,
PoolSize: opt.PoolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
MinIdleConns: opt.MinIdleConns,
MaxIdleConns: opt.MaxIdleConns,
MaxActiveConns: opt.MaxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
ReadBufferSize: opt.ReadBufferSize,
WriteBufferSize: opt.WriteBufferSize,
PipelineReadBufferSize: opt.PipelineReadBufferSize,
PipelineWriteBufferSize: opt.PipelineWriteBufferSize,
PipelinePoolSize: opt.PipelinePoolSize,
DisableIdentity: opt.DisableIdentity,
DisableIndentity: opt.DisableIndentity,
IdentitySuffix: opt.IdentitySuffix,
FailingTimeoutSeconds: opt.FailingTimeoutSeconds,
TLSConfig: opt.TLSConfig,
// If ClusterSlots is populated, then we probably have an artificial
// cluster whose nodes are not in clustering mode (otherwise there isn't
// much use for ClusterSlots config). This means we cannot execute the
// READONLY command against that node -- setting readOnly to false in such
// situations in the options below will prevent that from happening.
readOnly: opt.ReadOnly && opt.ClusterSlots == nil,
UnstableResp3: opt.UnstableResp3,
MaintNotificationsConfig: maintNotificationsConfig,
PushNotificationProcessor: opt.PushNotificationProcessor,
}
}
//------------------------------------------------------------------------------
type clusterNode struct {
Client *Client
latency atomic.Uint32
generation atomic.Uint32
failing atomic.Uint32
loaded atomic.Uint32
// last time the latency measurement was performed for the node, stored in nanoseconds from epoch
lastLatencyMeasurement atomic.Int64
}
func newClusterNodeWithNodeAddress(clOpt *ClusterOptions, addr, nodeAddress string) *clusterNode {
opt := clOpt.clientOptions()
opt.Addr = addr
opt.NodeAddress = nodeAddress
node := clusterNode{
Client: clOpt.NewClient(opt),
}
node.latency.Store(unmeasuredNodeLatencyMicros)
if clOpt.RouteByLatency {
go node.updateLatency()
}
return &node
}
func (n *clusterNode) String() string {
return n.Client.String()
}
func (n *clusterNode) Close() error {
return n.Client.Close()
}
const maximumNodeLatency = 1 * time.Minute
// Latency held by a node from creation until its first probe completes. Deliberately distinct
// from maximumNodeLatency, which marks a node whose pings all failed - the two must stay
// separable, so this is compared for equality rather than as a ">= huge" threshold.
const (
unmeasuredNodeLatencyMicros uint32 = math.MaxUint32
unmeasuredNodeLatency = time.Duration(unmeasuredNodeLatencyMicros) * time.Microsecond
)
func (n *clusterNode) updateLatency() {
const numProbe = 10
var dur uint64
successes := 0
for i := 0; i < numProbe; i++ {
time.Sleep(time.Duration(10+rand.Intn(10)) * time.Millisecond)
start := time.Now()
err := n.Client.Ping(context.TODO()).Err()
if err == nil {
dur += uint64(time.Since(start) / time.Microsecond)
successes++
}
}
var latency float64
if successes == 0 {
// If none of the pings worked, set latency to some arbitrarily high value so this node gets
// least priority.
latency = float64(maximumNodeLatency / time.Microsecond)
} else {
latency = float64(dur) / float64(successes)
}
n.latency.Store(uint32(latency + 0.5))
n.SetLastLatencyMeasurement(time.Now())
}
func (n *clusterNode) Latency() time.Duration {
latency := n.latency.Load()
return time.Duration(latency) * time.Microsecond
}
func (n *clusterNode) MarkAsFailing() {
n.failing.Store(uint32(time.Now().Unix()))
n.loaded.Store(0)
}
func (n *clusterNode) Failing() bool {
timeout := int64(n.Client.opt.FailingTimeoutSeconds)
failing := n.failing.Load()
if failing == 0 {
return false
}
if time.Now().Unix()-int64(failing) < timeout {
return true
}
n.failing.Store(0)
return false
}
func (n *clusterNode) Generation() uint32 {
return n.generation.Load()
}
func (n *clusterNode) LastLatencyMeasurement() int64 {
return n.lastLatencyMeasurement.Load()
}
func (n *clusterNode) SetGeneration(gen uint32) {
for {
v := n.generation.Load()
if gen < v || n.generation.CompareAndSwap(v, gen) {
break
}
}
}
func (n *clusterNode) SetLastLatencyMeasurement(t time.Time) {
for {
v := n.lastLatencyMeasurement.Load()
if t.UnixNano() < v || n.lastLatencyMeasurement.CompareAndSwap(v, t.UnixNano()) {
break
}
}
}
func (n *clusterNode) Loading() bool {
loaded := n.loaded.Load()
if loaded == 1 {
return false
}
// check if the node is loading
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := n.Client.Ping(ctx).Err()
loading := err != nil && isLoadingError(err)
if !loading {
n.loaded.Store(1)
}
return loading
}
//------------------------------------------------------------------------------
type clusterNodes struct {
opt *ClusterOptions
mu sync.RWMutex
addrs []string
nodes map[string]*clusterNode
activeAddrs []string
closed bool
onNewNode []func(rdb *Client)
generation atomic.Uint32
}
func newClusterNodes(opt *ClusterOptions) *clusterNodes {
return &clusterNodes{
opt: opt,
addrs: opt.Addrs,
nodes: make(map[string]*clusterNode),
}
}
func (c *clusterNodes) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
var firstErr error
for _, node := range c.nodes {
if err := node.Client.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
c.nodes = nil
c.activeAddrs = nil
return firstErr
}
func (c *clusterNodes) OnNewNode(fn func(rdb *Client)) {
c.mu.Lock()
c.onNewNode = append(c.onNewNode, fn)
c.mu.Unlock()
}
func (c *clusterNodes) Addrs() ([]string, error) {
var addrs []string
c.mu.RLock()
closed := c.closed //nolint:ifshort
if !closed {
if len(c.activeAddrs) > 0 {
addrs = make([]string, len(c.activeAddrs))
copy(addrs, c.activeAddrs)
} else {
addrs = make([]string, len(c.addrs))
copy(addrs, c.addrs)
}
}
c.mu.RUnlock()
if closed {
return nil, pool.ErrClosed
}
if len(addrs) == 0 {
return nil, errClusterNoNodes
}
return addrs, nil
}
func (c *clusterNodes) NextGeneration() uint32 {
return c.generation.Add(1)
}
// GC removes unused nodes.
func (c *clusterNodes) GC(generation uint32) {
var collected []*clusterNode
c.mu.Lock()
c.activeAddrs = c.activeAddrs[:0]
now := time.Now()
for addr, node := range c.nodes {
if node.Generation() >= generation {
c.activeAddrs = append(c.activeAddrs, addr)
if c.opt.RouteByLatency && node.LastLatencyMeasurement() < now.Add(-minLatencyMeasurementInterval).UnixNano() {
go node.updateLatency()
}
continue
}
delete(c.nodes, addr)
collected = append(collected, node)
}
c.mu.Unlock()
for _, node := range collected {
_ = node.Client.Close()
}
}
func (c *clusterNodes) GetOrCreate(addr string) (*clusterNode, error) {
return c.GetOrCreateWithNodeAddress(addr, "")
}
func (c *clusterNodes) GetOrCreateWithNodeAddress(addr, nodeAddress string) (*clusterNode, error) {
node, err := c.get(addr)
if err != nil {
return nil, err
}
if node != nil {
return node, nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil, pool.ErrClosed
}
node, ok := c.nodes[addr]
if ok {
return node, nil
}
node = newClusterNodeWithNodeAddress(c.opt, addr, nodeAddress)
for _, fn := range c.onNewNode {
fn(node.Client)
}
c.addrs = appendIfNotExist(c.addrs, addr)
c.nodes[addr] = node
return node, nil
}
func (c *clusterNodes) get(addr string) (*clusterNode, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.closed {
return nil, pool.ErrClosed
}
return c.nodes[addr], nil
}
func (c *clusterNodes) All() ([]*clusterNode, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.closed {
return nil, pool.ErrClosed
}
cp := make([]*clusterNode, 0, len(c.nodes))
for _, node := range c.nodes {
cp = append(cp, node)
}
return cp, nil
}
func (c *clusterNodes) Random() (*clusterNode, error) {
addrs, err := c.Addrs()
if err != nil {
return nil, err
}
n := rand.Intn(len(addrs))
return c.GetOrCreate(addrs[n])
}
//------------------------------------------------------------------------------
type clusterSlot struct {
start int
end int
nodes []*clusterNode
// Round-robin cursor over the nodes inside the RouteByLatencyTolerance band. Latency
// decides only which nodes are in the band; within it they are treated as equally close
// and picked in turn, so this does not order them. Per slot on purpose: a counter shared
// across slots is advanced by the other slots between two visits to this one, so with a
// regular interleaving each slot keeps landing on the same candidate index.
latencyBandNodeCursor atomic.Uint32
}
type clusterState struct {
nodes *clusterNodes
Masters []*clusterNode
Slaves []*clusterNode
slots []*clusterSlot
generation uint32
createdAt time.Time
}
func newClusterState(
nodes *clusterNodes, slots []ClusterSlot, origin string,
) (*clusterState, error) {
c := clusterState{
nodes: nodes,
slots: make([]*clusterSlot, 0, len(slots)),
generation: nodes.NextGeneration(),
createdAt: time.Now(),
}
originHost, originPort, _ := net.SplitHostPort(origin)
isLoopbackOrigin := isLoopback(originHost)
for _, slot := range slots {
var nodes []*clusterNode
for i, slotNode := range slot.Nodes {
// slotNode.Addr is the node address from CLUSTER SLOTS
nodeAddress := slotNode.Addr
addr := nodeAddress
if !isLoopbackOrigin {
addr = replaceLoopbackHost(addr, originHost)
}
// TLS-only clusters (`--port 0 --tls-port 6379`) report port 0
// in CLUSTER SLOTS. Fall back to the origin port — by definition
// reachable, since it is the port that returned this slot map.
// See https://github.com/redis/go-redis/issues/3726.
addr = replaceZeroPort(addr, originPort)
node, err := c.nodes.GetOrCreateWithNodeAddress(addr, nodeAddress)
if err != nil {
return nil, err
}
node.SetGeneration(c.generation)
nodes = append(nodes, node)
if i == 0 {
c.Masters = appendIfNotExist(c.Masters, node)
} else {
c.Slaves = appendIfNotExist(c.Slaves, node)
}
}
c.slots = append(c.slots, &clusterSlot{
start: slot.Start,
end: slot.End,
nodes: nodes,
})
}
slices.SortFunc(c.slots, func(a, b *clusterSlot) int {
return cmp.Compare(a.start, b.start)
})
time.AfterFunc(time.Minute, func() {
nodes.GC(c.generation)
})
return &c, nil
}
func replaceLoopbackHost(nodeAddr, originHost string) string {
nodeHost, nodePort, err := net.SplitHostPort(nodeAddr)
if err != nil {
return nodeAddr
}
nodeIP := net.ParseIP(nodeHost)
if nodeIP == nil {
return nodeAddr
}
if !nodeIP.IsLoopback() {
return nodeAddr
}
// Use origin host which is not loopback and node port.
return net.JoinHostPort(originHost, nodePort)
}
// replaceZeroPort substitutes originPort for a node port of "0", which is
// what CLUSTER SLOTS reports for TLS-only clusters started with
// `--port 0 --tls-port <port>`. Non-zero ports and addresses without a
// recoverable origin port are returned unchanged.
func replaceZeroPort(nodeAddr, originPort string) string {
if originPort == "" || originPort == "0" {
return nodeAddr
}
nodeHost, nodePort, err := net.SplitHostPort(nodeAddr)
if err != nil || nodePort != "0" {
return nodeAddr
}
return net.JoinHostPort(nodeHost, originPort)
}
// isLoopback returns true if the host is a loopback address.
// For IP addresses, it uses net.IP.IsLoopback().
// For hostnames, it recognizes well-known loopback hostnames like "localhost"
// and Docker-specific loopback patterns like "*.docker.internal".
func isLoopback(host string) bool {
ip := net.ParseIP(host)
if ip != nil {
return ip.IsLoopback()
}
if strings.ToLower(host) == "localhost" {
return true
}
if strings.HasSuffix(strings.ToLower(host), ".docker.internal") {
return true
}
return false
}
func (c *clusterState) slotMasterNode(slot int) (*clusterNode, error) {
nodes := c.slotNodes(slot)
if len(nodes) > 0 {
return nodes[0], nil
}
return c.nodes.Random()
}
func (c *clusterState) slotSlaveNode(slot int) (*clusterNode, error) {
nodes := c.slotNodes(slot)
switch len(nodes) {
case 0:
return c.nodes.Random()
case 1:
return nodes[0], nil
case 2:
slave := nodes[1]
if !slave.Failing() && !slave.Loading() {
return slave, nil
}
return nodes[0], nil
default:
var slave *clusterNode
for i := 0; i < 10; i++ {
n := rand.Intn(len(nodes)-1) + 1
slave = nodes[n]
if !slave.Failing() && !slave.Loading() {
return slave, nil
}
}
// All slaves are loading - use master.
return nodes[0], nil
}
}
func (c *clusterState) slotClosestNode(slot int) (*clusterNode, error) {
nodes := c.slotNodes(slot)
if len(nodes) == 0 {
return c.nodes.Random()
}
allNodesFailing := true
var (
closestNonFailingNode *clusterNode
closestNode *clusterNode
minLatency time.Duration
)
// setting the max possible duration as zerovalue for minlatency
minLatency = time.Duration(math.MaxInt64)
minNonFailingLatency := time.Duration(math.MaxInt64)
for _, n := range nodes {
// Sampled once: Latency() reads an atomic the background probe writes, so two
// reads in one iteration can disagree.
latency := n.Latency()
if closestNode == nil || latency < minLatency {
closestNode = n
minLatency = latency
}
// Tracked independently of the minimum above. Nesting this inside that branch
// meant a healthy node was only ever considered when it was also the outright
// fastest - so a failing node with lower latency (a refused connection fails
// fast, and often has the lowest measured latency of the slot) hid every
// healthy node behind it, and the slot fell through to the all-failing path.
if !n.Failing() && (closestNonFailingNode == nil || latency < minNonFailingLatency) {
closestNonFailingNode = n
minNonFailingLatency = latency
allNodesFailing = false
}
}
// pick the healthly node with the lowest latency
if !allNodesFailing && closestNonFailingNode != nil {
return closestNonFailingNode, nil
}
// if all nodes are failing, we will pick the temporarily failing node with lowest latency
if minLatency < maximumNodeLatency && closestNode != nil {
internal.Logger.Printf(context.TODO(), "redis: all nodes are marked as failed, picking the temporarily failing node with lowest latency")
return closestNode, nil
}
// If all nodes are having the maximum latency(all pings are failing) - return a random node across the cluster
internal.Logger.Printf(context.TODO(), "redis: pings to all nodes are failing, picking a random node across the cluster")
return c.nodes.Random()
}
// slotNodeWithinLatency picks from the healthy nodes whose latency is within tolerance of the
// fastest one, rotating across them so equally-close nodes share the read traffic. Used only
// when RouteByLatencyTolerance is set; slotClosestNode keeps the strict-minimum behaviour and
// is left untouched for everyone else.
func (c *clusterState) slotNodeWithinLatency(slot int, tolerance time.Duration) (*clusterNode, error) {
entry := c.slotEntry(slot)
if entry == nil || len(entry.nodes) == 0 {
return c.nodes.Random()
}
nodes := entry.nodes
// Latency and health are sampled once per node. The background probe updates them
// concurrently, so re-reading would let the candidate set disagree with the minimum it
// is compared against - and could leave that set empty.
type sample struct {
node *clusterNode
latency time.Duration
}
var (
healthy = make([]sample, 0, len(nodes))
closestNode *clusterNode
closestHealthyNode *clusterNode
minLatency = time.Duration(math.MaxInt64)
minHealthyLatency = time.Duration(math.MaxInt64)
anyHealthyMeasured bool
)
for _, n := range nodes {
latency := n.Latency()
if latency < minLatency {
closestNode, minLatency = n, latency
}
if n.Failing() {
continue
}
healthy = append(healthy, sample{node: n, latency: latency})
// Derived from the latency just captured, not from a second read of the node.
// updateLatency publishes the latency and its timestamp as two separate stores, so
// reading the timestamp here could observe a probe that landed after the latency
// above was sampled - marking the set measured while every sampled latency is still
// a sentinel, which is exactly the case this guards.
if latency != unmeasuredNodeLatency {
anyHealthyMeasured = true
}
// Tracked separately: a healthy node that is not the outright fastest must still be
// preferred over a failing one.
if latency < minHealthyLatency {
closestHealthyNode, minHealthyLatency = n, latency
}
}
if closestHealthyNode != nil {
// Until the first probe lands, every node still holds the sentinel latency stored by
// newClusterNodeWithNodeAddress, so every difference is zero and any positive tolerance
// would admit the whole slot - scattering startup reads across distant zones instead of
// preserving locality. Keep strict selection until at least one measurement exists; a
// mix needs no special case, since an unmeasured node's sentinel latency puts it far
// outside the band of any measured one.
if !anyHealthyMeasured {
return closestHealthyNode, nil
}
candidates := make([]*clusterNode, 0, len(healthy))
for _, s := range healthy {
// Subtraction rather than minHealthyLatency+tolerance, which would overflow for
// a very large tolerance. Always true for closestHealthyNode, so candidates is
// never empty here.
if s.latency-minHealthyLatency <= tolerance {
candidates = append(candidates, s.node)
}
}
// Drop nodes that are still loading, matching slotSlaveNode. Checked here rather than
// in the pass above so Loading() - which can cost a Ping when the node is not known
// loaded - is only paid for nodes actually eligible for this read. Widening the
// candidate set is what makes this matter: under a strict minimum a loading replica
// has to be the fastest to be picked, within a tolerance band it merely has to be
// close, and a replica is loading precisely after the full resync that a too-small
// replication backlog causes.
ready := candidates[:0]
for _, n := range candidates {
if !n.Loading() {
ready = append(ready, n)
}
}
if len(ready) == 0 {
// Every node inside the band is loading. closestHealthyNode is itself in the band,
// so returning it hands back a node we just observed loading. Widen the search to
// the healthy nodes outside the band and take the closest ready one - which in a
// multi-AZ layout is typically the master, still serving while the local replicas
// reload together after a resync. Only reached in this exceptional case, so the
// extra Loading() calls are off the hot path, and the latency comparison is
// evaluated first so most of them are skipped.
var (
fallback *clusterNode
fallbackLatency = time.Duration(math.MaxInt64)
)
for _, s := range healthy {
if s.latency-minHealthyLatency <= tolerance {
continue // in the band, already known to be loading
}
if s.latency < fallbackLatency && !s.node.Loading() {
fallback, fallbackLatency = s.node, s.latency
}
}
if fallback != nil {
return fallback, nil
}
// Nothing is ready anywhere. Return the closest healthy node so the caller still
// gets a node to retry against, which is what this path did before the filter.
return closestHealthyNode, nil
}
if len(ready) == 1 {
return ready[0], nil
}
// Reduced in the unsigned domain: the cursor wraps, and on 32-bit builds converting a
// value past 2^31 to int before the modulo would make the index negative.
return ready[(entry.latencyBandNodeCursor.Add(1)-1)%uint32(len(ready))], nil
}
// Every node is failing. Fall back to the least-slow one, so a transient failure does
// not take the slot down.
if minLatency < maximumNodeLatency && closestNode != nil {
internal.Logger.Printf(context.TODO(), "redis: all nodes are marked as failed, picking the temporarily failing node with lowest latency")
return closestNode, nil
}
// If all nodes are having the maximum latency(all pings are failing) - return a random node across the cluster
internal.Logger.Printf(context.TODO(), "redis: pings to all nodes are failing, picking a random node across the cluster")
return c.nodes.Random()
}
func (c *clusterState) slotRandomNode(slot int) (*clusterNode, error) {
nodes := c.slotNodes(slot)
if len(nodes) == 0 {
return c.nodes.Random()
}
if len(nodes) == 1 {
return nodes[0], nil
}
randomNodes := rand.Perm(len(nodes))
for _, idx := range randomNodes {
if node := nodes[idx]; !node.Failing() {
return node, nil
}
}
return nodes[randomNodes[0]], nil
}
func (c *clusterState) slotShardPickerSlaveNode(slot int, shardPicker routing.ShardPicker) (*clusterNode, error) {
nodes := c.slotNodes(slot)
if len(nodes) == 0 {
return c.nodes.Random()
}
// nodes[0] is master, nodes[1:] are slaves
// First, try all slave nodes for this slot using ShardPicker order
slaves := nodes[1:]
if len(slaves) > 0 {
for i := 0; i < len(slaves); i++ {
idx := shardPicker.Next(len(slaves))
slave := slaves[idx]
if !slave.Failing() && !slave.Loading() {
return slave, nil
}
}
}
// All slaves are failing or loading - return master
return nodes[0], nil
}
func (c *clusterState) slotEntry(slot int) *clusterSlot {
i := sort.Search(len(c.slots), func(i int) bool {
return c.slots[i].end >= slot
})
if i >= len(c.slots) {
return nil
}
x := c.slots[i]
if slot >= x.start && slot <= x.end {
return x
}
return nil
}
func (c *clusterState) slotNodes(slot int) []*clusterNode {
if x := c.slotEntry(slot); x != nil {
return x.nodes
}
return nil
}
//------------------------------------------------------------------------------
type clusterStateHolder struct {
load func(ctx context.Context) (*clusterState, error)
reloadInterval time.Duration
state atomic.Value
reloading atomic.Uint32
reloadPending atomic.Uint32 // set to 1 when reload is requested during active reload
}
func newClusterStateHolder(load func(ctx context.Context) (*clusterState, error), reloadInterval time.Duration) *clusterStateHolder {
return &clusterStateHolder{
load: load,
reloadInterval: reloadInterval,
}
}
func (c *clusterStateHolder) Reload(ctx context.Context) (*clusterState, error) {
state, err := c.load(ctx)
if err != nil {
return nil, err
}
c.state.Store(state)
return state, nil
}
func (c *clusterStateHolder) LazyReload() {
// If already reloading, mark that another reload is pending
if !c.reloading.CompareAndSwap(0, 1) {
c.reloadPending.Store(1)
return
}
go func() {
for {
_, err := c.Reload(context.Background())
if err != nil {
c.reloadPending.Store(0)
c.reloading.Store(0)
return
}
// Clear pending flag after reload completes, before cooldown
// This captures notifications that arrived during the reload
c.reloadPending.Store(0)
// Wait cooldown period
time.Sleep(200 * time.Millisecond)
// Check if another reload was requested during cooldown
if c.reloadPending.Load() == 0 {
// No pending reload, we're done
c.reloading.Store(0)
return
}
// Pending reload requested, loop to reload again
}
}()
}
func (c *clusterStateHolder) Get(ctx context.Context) (*clusterState, error) {
v := c.state.Load()
if v == nil {
return c.Reload(ctx)
}
state := v.(*clusterState)
if time.Since(state.createdAt) > c.reloadInterval {
c.LazyReload()
}
return state, nil
}
func (c *clusterStateHolder) ReloadOrGet(ctx context.Context) (*clusterState, error) {
state, err := c.Reload(ctx)
if err == nil {
return state, nil
}
return c.Get(ctx)
}
//------------------------------------------------------------------------------
// ClusterClient is a Redis Cluster client representing a pool of zero
// or more underlying connections. It's safe for concurrent use by
// multiple goroutines.
type ClusterClient struct {
opt *ClusterOptions
nodes *clusterNodes
state *clusterStateHolder
cmdsInfoCache *cmdsInfoCache
cmdInfoResolver *commandInfoResolver
cmdable
hooksMixin
// himport is the cluster-wide HIMPORT fieldset registry, shared with
// every node client (masters and replicas alike — roles change with the
// topology) so any connection serving an HIMPORT SET can lazily replay
// the PREPARE (see himport.go, himport_cluster.go).
himport *himportRegistry
autopipelinerMu *sync.Mutex // guards the autopipeliner fields against concurrent first-call creation
autopipeliner *AutoPipeliner // blocking face (ClusterClient.AutoPipeline)
asyncAutopipeliner *AutoPipeliner // deferred face (ClusterClient.AsyncAutoPipeline)
autopipelinerClosed bool // set by Close: refuse to resurrect a pipeliner on a closed client
}
// NewClusterClient returns a Redis Cluster client as described in
// https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec.
// Passing nil ClusterOptions will cause a panic.
func NewClusterClient(opt *ClusterOptions) *ClusterClient {
if opt == nil {
panic("redis: NewClusterClient nil options")
}
opt.init()
c := &ClusterClient{
opt: opt,
nodes: newClusterNodes(opt),
himport: newHImportRegistry(),
autopipelinerMu: &sync.Mutex{},
}
// Every node client shares the cluster-wide fieldset registry, replicas
// included: a promoted replica's connections carry no prepared flags, so
// the first HIMPORT SET routed to it replays the PREPARE lazily.
c.nodes.OnNewNode(func(nodeClient *Client) {
nodeClient.himport = c.himport
})
c.cmdsInfoCache = newCmdsInfoCache(c.cmdsInfo)
c.state = newClusterStateHolder(c.loadState, opt.ClusterStateReloadInterval)
c.SetCommandInfoResolver(NewDefaultCommandPolicyResolver())
c.cmdable = c.Process
c.initHooks(hooks{
dial: nil,
process: c.process,
pipeline: c.processPipeline,
txPipeline: c.processTxPipeline,
})
// Set up SMIGRATED notification handling for cluster state reload
// When a node client receives a SMIGRATED notification, it should trigger
// cluster state reload on the parent ClusterClient
if opt.MaintNotificationsConfig != nil {
c.nodes.OnNewNode(func(nodeClient *Client) {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.SetClusterStateReloadCallback(func(ctx context.Context, hostPort string, slotRanges []string) {
// Log the migration details for now
if internal.LogLevel.InfoOrAbove() {
internal.Logger.Printf(ctx, "cluster: slots %v migrated to %s, reloading cluster state", slotRanges, hostPort)
}
// Currently we reload the entire cluster state
// In the future, this could be optimized to reload only the specific slots
c.state.LazyReload()
})
}
})
}
return c
}
// Options returns read-only *ClusterOptions that were used to create the client.
// Any alteration of the returned *ClusterOptions may result in undefined behaviour.
func (c *ClusterClient) Options() *ClusterOptions {
return c.opt
}
// ReloadState reloads cluster state. If available it calls ClusterSlots func
// to get cluster slots information.
func (c *ClusterClient) ReloadState(ctx context.Context) {
c.state.LazyReload()
}
// Close closes the cluster client, releasing any open resources.
//
// It is rare to Close a ClusterClient, as the ClusterClient is meant
// to be long-lived and shared between many goroutines.
func (c *ClusterClient) Close() error {
// Stop both cached autopipeliners (blocking and async faces) before
// closing nodes, so its background flusher goroutines don't outlive the
// client. AutoPipeliner.Close is idempotent and nil-safe here.
c.autopipelinerMu.Lock()
ap, async := c.autopipeliner, c.asyncAutopipeliner
c.autopipeliner, c.asyncAutopipeliner = nil, nil
c.autopipelinerClosed = true // getters refuse to resurrect on a closed client
c.autopipelinerMu.Unlock()
var firstErr error
for _, p := range []*AutoPipeliner{ap, async} {
if p != nil {
if err := p.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
}
if err := c.nodes.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
func (c *ClusterClient) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
func (c *ClusterClient) process(ctx context.Context, cmd Cmder) error {
slot := c.cmdSlot(cmd, -1)
var node *clusterNode
var moved bool
var ask bool
var lastErr error
for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ {
// MOVED and ASK responses are not transient errors that require retry delay; they
// should be attempted immediately.
if attempt > 0 && !moved && !ask {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
return err
}
}
if node == nil {
var err error
if !c.opt.DisableRoutingPolicies && c.opt.ShardPicker != nil {
node, err = c.cmdNodeWithShardPicker(ctx, cmd.Name(), slot, c.opt.ShardPicker)
} else {
node, err = c.cmdNode(ctx, cmd.Name(), slot)
}
if err != nil {
return err
}
}
if ask {
ask = false
pipe := node.Client.Pipeline()
_ = pipe.Process(ctx, NewCmd(ctx, "asking"))
_ = pipe.Process(ctx, cmd)
_, lastErr = pipe.Exec(ctx)
} else {
if !c.opt.DisableRoutingPolicies {
lastErr = c.routeAndRun(ctx, cmd, node)
} else {
lastErr = node.Client.Process(ctx, cmd)
}
}
// If there is no error - we are done.
if lastErr == nil {
return nil
}
if isReadOnly := isReadOnlyError(lastErr); isReadOnly || lastErr == pool.ErrClosed {
if isReadOnly {
c.state.LazyReload()
}
node = nil
continue
}
// If slave is loading - pick another node.
if c.opt.ReadOnly && isLoadingError(lastErr) {
node.MarkAsFailing()
node = nil
continue
}
var addr string
moved, ask, addr = isMovedError(lastErr)
if moved || ask {
c.state.LazyReload()
// Record error metrics
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType := "MOVED"
statusCode := "MOVED"
if ask {
errorType = "ASK"
statusCode = "ASK"
}
// MOVED/ASK are not internal errors, and this is the first attempt (retry count = 0)
errorCallback(ctx, errorType, nil, statusCode, false, 0)
}
var err error
node, err = c.nodes.GetOrCreate(addr)
if err != nil {
return err
}
continue
}
if shouldRetry(lastErr, cmd.readTimeout() == nil) && !cmd.NoRetry() {
// First retry the same node.
if attempt == 0 {
continue
}
// Second try another node.
node.MarkAsFailing()
node = nil
continue
}
return lastErr
}
return lastErr
}
func (c *ClusterClient) OnNewNode(fn func(rdb *Client)) {
c.nodes.OnNewNode(fn)
}
// ForEachMaster concurrently calls the fn on each master node in the cluster.
// It returns the first error if any.
func (c *ClusterClient) ForEachMaster(
ctx context.Context,
fn func(ctx context.Context, client *Client) error,
) error {
state, err := c.state.ReloadOrGet(ctx)
if err != nil {
return err
}
var wg sync.WaitGroup
errCh := make(chan error, 1)
for _, master := range state.Masters {
wg.Add(1)
go func(node *clusterNode) {
defer wg.Done()
err := fn(ctx, node.Client)
if err != nil {
select {
case errCh <- err:
default:
}
}
}(master)
}
wg.Wait()
select {
case err := <-errCh:
return err
default:
return nil
}
}
// ForEachSlave concurrently calls the fn on each slave node in the cluster.
// It returns the first error if any.
func (c *ClusterClient) ForEachSlave(
ctx context.Context,
fn func(ctx context.Context, client *Client) error,
) error {
state, err := c.state.ReloadOrGet(ctx)
if err != nil {
return err
}
var wg sync.WaitGroup
errCh := make(chan error, 1)
for _, slave := range state.Slaves {
wg.Add(1)
go func(node *clusterNode) {
defer wg.Done()
err := fn(ctx, node.Client)
if err != nil {
select {
case errCh <- err:
default:
}
}
}(slave)
}
wg.Wait()
select {
case err := <-errCh:
return err
default:
return nil
}
}
// ForEachShard concurrently calls the fn on each known node in the cluster.
// It returns the first error if any.
func (c *ClusterClient) ForEachShard(
ctx context.Context,
fn func(ctx context.Context, client *Client) error,
) error {
state, err := c.state.ReloadOrGet(ctx)
if err != nil {
return err
}
var wg sync.WaitGroup
errCh := make(chan error, 1)
worker := func(node *clusterNode) {
defer wg.Done()
err := fn(ctx, node.Client)
if err != nil {
select {
case errCh <- err:
default:
}
}
}
for _, node := range state.Masters {
wg.Add(1)
go worker(node)
}
for _, node := range state.Slaves {
wg.Add(1)
go worker(node)
}
wg.Wait()
select {
case err := <-errCh:
return err
default:
return nil
}
}
// PoolStats returns accumulated connection pool stats.
func (c *ClusterClient) PoolStats() *PoolStats {
var acc PoolStats
var pipe pool.Stats
havePipe := false
state, _ := c.state.Get(context.TODO())
if state == nil {
return &acc
}
foldNode := func(client *Client) {
s := client.connPool.Stats()
acc.Hits += s.Hits
acc.Misses += s.Misses
acc.Timeouts += s.Timeouts
acc.WaitCount += s.WaitCount
acc.WaitDurationNs += s.WaitDurationNs
acc.TotalConns += s.TotalConns
acc.IdleConns += s.IdleConns
acc.StaleConns += s.StaleConns
// The dedicated pipeline pool is now created per node by default; fold its
// stats into acc.PipelineStats so cluster monitoring reflects it too.
if pp := client.getPipelinePool(); pp != nil {
ps := pp.Stats()
pipe.Hits += ps.Hits
pipe.Misses += ps.Misses
pipe.Timeouts += ps.Timeouts
pipe.WaitCount += ps.WaitCount
pipe.WaitDurationNs += ps.WaitDurationNs
pipe.TotalConns += ps.TotalConns
pipe.IdleConns += ps.IdleConns
pipe.StaleConns += ps.StaleConns
havePipe = true
}
}
for _, node := range state.Masters {
foldNode(node.Client)
}
for _, node := range state.Slaves {
foldNode(node.Client)
}
if havePipe {
acc.PipelineStats = &pipe
}
return &acc
}
func (c *ClusterClient) loadState(ctx context.Context) (*clusterState, error) {
if c.opt.ClusterSlots != nil {
slots, err := c.opt.ClusterSlots(ctx)
if err != nil {
return nil, err
}
return newClusterState(c.nodes, slots, "")
}
addrs, err := c.nodes.Addrs()
if err != nil {
return nil, err
}
var firstErr error
for _, idx := range rand.Perm(len(addrs)) {
addr := addrs[idx]
node, err := c.nodes.GetOrCreate(addr)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
slots, err := node.Client.ClusterSlots(ctx).Result()
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return newClusterState(c.nodes, slots, addr)
}
/*
* No node is connectable. It's possible that all nodes' IP has changed.
* Clear activeAddrs to let client be able to re-connect using the initial
* setting of the addresses (e.g. [redis-cluster-0:6379, redis-cluster-1:6379]),
* which might have chance to resolve domain name and get updated IP address.
*/
c.nodes.mu.Lock()
c.nodes.activeAddrs = nil
c.nodes.mu.Unlock()
return nil, firstErr
}
func (c *ClusterClient) Pipeline() Pipeliner {
pipe := Pipeline{
exec: pipelineExecer(c.processPipelineHook),
}
pipe.init()
return &pipe
}
// clusterAutoPipelineOptions applies the cluster shard-count default: commands
// are routed to shards by slot (see installAutoPipelineSharding), so unlike a
// standalone client — which defaults to a single deep queue — a cluster client
// wants several shards to keep concurrent nodes' batches separate. The caller's
// config is copied before the default is filled in, never mutated.
func clusterAutoPipelineOptions(cfg *AutoPipelineOptions) *AutoPipelineOptions {
c2 := *cfg
if c2.NumShards == 0 {
c2.NumShards = numAutoPipelineShards()
}
// A cluster always routes by slot, so per-key order holds regardless of shard
// count; mark it so construction's NumShards ordering check (which targets
// round-robin sharding) does not reject the cluster default or an explicit
// NumShards on the deferred (async) face.
c2.contentSharded = true
return &c2
}
// AutoPipeline returns the blocking autopipeliner for this cluster client: each
// command call blocks until executed (drop-in shape) while the engine batches
// concurrent callers into pipelines. Commands keep per-goroutine order; across
// nodes, ordering is per key (slot routing keeps a key on one shard and node
// sub-pipelines execute concurrently). Use AutoPipelineWithOptions to override
// DefaultBlockingAutoPipelineOptions. Cached/shared; first call's config wins.
// Close it (or the client) to release its goroutines.
//
// It returns an error if the supplied config is invalid (e.g. MaxConcurrentBatches>1
// without Unordered, or a negative size); on error no instance is cached.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *ClusterClient) AutoPipeline() (*AutoPipeliner, error) {
return c.AutoPipelineWithOptions(nil)
}
// AutoPipelineWithOptions is AutoPipeline with explicit options instead of
// ClusterOptions.AutoPipelineOptions / the default. Cached/shared; first call wins.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *ClusterClient) AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return getOrCreateAutoPipeliner(c.autopipelinerMu, &c.autopipeliner, &c.autopipelinerClosed, nil, nil, "", config,
func() *AutoPipelineOptions {
if c.opt.AutoPipelineOptions != nil {
return c.opt.AutoPipelineOptions
}
return DefaultBlockingAutoPipelineOptions()
},
func(cfg *AutoPipelineOptions) (*AutoPipeliner, error) {
ap, err := newAutoPipeliner(c, clusterAutoPipelineOptions(cfg), true)
if err != nil {
return nil, err
}
c.installAutoPipelineSharding(ap)
return ap, nil
})
}
// installAutoPipelineSharding routes commands to shards by cluster slot so each
// shard's batch lands on a single master node, keeping per-node pipelines deep
// instead of splitting every batch across all nodes at flush. Cluster slots are
// contiguous per node, so bucketing by slot range (slot*shards/16384) keeps a
// node's slots together. Keyless commands hash to slot -1 → bucket 0; multi-node
// commands are already rejected from pipelines, so only single-node commands
// reach here.
func (c *ClusterClient) installAutoPipelineSharding(ap *AutoPipeliner) {
// Reject commands whose request policy cannot ride a pipeline (ReqAllNodes/
// ReqAllShards/ReqMultiShard) at submit, BEFORE they can join a merged
// batch: mapCmdsByNode fails a whole mapping on such a command (user
// pipelines are all-or-nothing), and one autopipeline caller must not be
// able to poison unrelated callers' batches. Rejecting here also keeps the
// lone-command fast path consistent with batched dispatch — the command is
// refused regardless of what it happens to coalesce with.
ap.setPreflight(func(ctx context.Context, cmd Cmder) error {
if c.cmdInfoResolver == nil {
return nil
}
if policy := c.cmdInfoResolver.GetCommandPolicy(ctx, cmd); policy != nil && !policy.CanBeUsedInPipeline() {
return fmt.Errorf(
"redis: cannot pipeline command %q with request policy ReqAllNodes/ReqAllShards/ReqMultiShard; Note: This behavior is subject to change in the future", cmd.Name(),
)
}
return nil
})
// Commands whose routing is not slot-derived must not be coalesced: a solo
// flush reaches ClusterClient.process and its special handling (FT.CURSOR
// READ/DEL are sticky to the node holding the cursor), but inside a batch
// mapCmdsByNode routes by slot and can hit the wrong shard — visible only
// under concurrent traffic, which is the worst way to find it. Divert them
// instead of rejecting: they work fine on their own connection (review
// finding by codex on #3942).
ap.setMustDivert(func(ctx context.Context, cmd Cmder) bool {
if c.cmdInfoResolver == nil {
return false
}
policy := c.cmdInfoResolver.GetCommandPolicy(ctx, cmd)
return policy != nil && policy.Request == routing.ReqSpecial
})
const slots = 16384
n := ap.numShards()
ap.setShardFn(func(cmd Cmder) int {
// Compute the exact slot once and cache it on the command; the flush
// router (mapCmdsByNode) reuses the cached value, so the slot is resolved
// once per command, not twice. Keyless (slot -1) buckets to shard 0.
slot := c.cmdSlot(cmd, -1)
if slot < 0 {
return 0
}
return slot * n / slots
})
}
// AsyncAutoPipeline returns the deferred autopipeliner: command calls return
// immediately and the result accessors block. Submit a window then read results
// for the highest throughput. By default,
// ClusterOptions.AutoPipelineOptions is used if set, otherwise
// DefaultAutoPipelineOptions. Ordering across nodes is per key: slot routing
// keeps a key on one shard, and node sub-pipelines execute concurrently. Use
// AsyncAutoPipelineWithOptions to override. Cached/shared; first call's config wins.
//
// It returns an error if the supplied config is invalid (e.g. MaxConcurrentBatches>1
// without Unordered, or a negative size); on error no instance is cached.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *ClusterClient) AsyncAutoPipeline() (*AutoPipeliner, error) {
return c.AsyncAutoPipelineWithOptions(nil)
}
// AsyncAutoPipelineWithOptions is AsyncAutoPipeline with an explicit config
// instead of ClusterOptions.AutoPipelineOptions / the default. Cached/shared.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *ClusterClient) AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return getOrCreateAutoPipeliner(c.autopipelinerMu, &c.asyncAutopipeliner, &c.autopipelinerClosed, nil, nil, "", config,
func() *AutoPipelineOptions {
if c.opt.AutoPipelineOptions != nil {
return c.opt.AutoPipelineOptions
}
return DefaultAutoPipelineOptions()
},
func(cfg *AutoPipelineOptions) (*AutoPipeliner, error) {
ap, err := newAutoPipeliner(c, clusterAutoPipelineOptions(cfg), false)
if err != nil {
return nil, err
}
c.installAutoPipelineSharding(ap)
return ap, nil
})
}
func (c *ClusterClient) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipeline().Pipelined(ctx, fn)
}
func (c *ClusterClient) processPipeline(ctx context.Context, cmds []Cmder) error {
// Only call time.Now() if pipeline operation duration callback is set to avoid overhead
var operationStart time.Time
pipelineOpDurationCallback := otel.GetPipelineOperationDurationCallback()
if pipelineOpDurationCallback != nil {
operationStart = time.Now()
}
totalAttempts := 0
cmdsMap := newCmdsMap()
if err := c.mapCmdsByNode(ctx, cmdsMap, cmds); err != nil {
setCmdsErr(cmds, err)
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
pipelineOpDurationCallback(ctx, operationDuration, "PIPELINE", len(cmds), 1, err, nil, 0)
}
return err
}
var lastErr error
for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ {
totalAttempts++
if attempt > 0 {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
setCmdsErr(cmds, err)
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
pipelineOpDurationCallback(ctx, operationDuration, "PIPELINE", len(cmds), totalAttempts, err, nil, 0)
}
return err
}
}
failedCmds := newCmdsMap()
var wg sync.WaitGroup
for node, cmds := range cmdsMap.m {
wg.Add(1)
go func(node *clusterNode, cmds []Cmder) {
defer wg.Done()
c.processPipelineNode(ctx, node, cmds, failedCmds)
}(node, cmds)
}
wg.Wait()
if len(failedCmds.m) == 0 {
break
}
cmdsMap = failedCmds
lastErr = cmdsFirstErr(cmds)
}
// Record pipeline operation duration
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
finalErr := cmdsFirstErr(cmds)
if finalErr == nil {
finalErr = lastErr
}
pipelineOpDurationCallback(ctx, operationDuration, "PIPELINE", len(cmds), totalAttempts, finalErr, nil, 0)
}
return cmdsFirstErr(cmds)
}
func (c *ClusterClient) mapCmdsByNode(ctx context.Context, cmdsMap *cmdsMap, cmds []Cmder) error {
state, err := c.state.Get(ctx)
if err != nil {
return err
}
if c.opt.ReadOnly && c.cmdsAreReadOnly(ctx, cmds) {
for _, cmd := range cmds {
var policy *routing.CommandPolicy
if c.cmdInfoResolver != nil {
policy = c.cmdInfoResolver.GetCommandPolicy(ctx, cmd)
}
if policy != nil && !policy.CanBeUsedInPipeline() {
// All-or-nothing: a user Pipeline() relies on the whole batch
// either dispatching or failing before anything executes, so a
// non-pipelineable command fails the entire mapping pre-dispatch.
// Autopipeline batches never reach here with such a command: the
// cluster face rejects them at submit (see the preflight installed
// by installAutoPipelineSharding), so one caller's bad command
// cannot poison a merged batch.
err := fmt.Errorf(
"redis: cannot pipeline command %q with request policy ReqAllNodes/ReqAllShards/ReqMultiShard; Note: This behavior is subject to change in the future", cmd.Name(),
)
setCmdsErr(cmds, err)
return err
}
slot := c.cmdSlot(cmd, -1)
var node *clusterNode
// For keyless commands (slot == -1), use ShardPicker if routing policies are enabled
if slot == -1 && !c.opt.DisableRoutingPolicies && c.opt.ShardPicker != nil {
if len(state.Masters) == 0 {
return errClusterNoNodes
}
// For read-only keyless commands, pick from all nodes (masters + slaves).
// Index directly instead of building a combined slice, which would
// append into the shared snapshot's spare capacity and race.
idx := c.opt.ShardPicker.Next(len(state.Masters) + len(state.Slaves))
if idx < len(state.Masters) {
node = state.Masters[idx]
} else {
node = state.Slaves[idx-len(state.Masters)]
}
} else {
node, err = c.slotReadOnlyNode(state, slot)
if err != nil {
return err
}
}
cmdsMap.Add(node, cmd)
}
return nil
}
for _, cmd := range cmds {
var policy *routing.CommandPolicy
if c.cmdInfoResolver != nil {
policy = c.cmdInfoResolver.GetCommandPolicy(ctx, cmd)
}
if policy != nil && !policy.CanBeUsedInPipeline() {
// All-or-nothing: a user Pipeline() relies on the whole batch
// either dispatching or failing before anything executes, so a
// non-pipelineable command fails the entire mapping pre-dispatch.
// Autopipeline batches never reach here with such a command: the
// cluster face rejects them at submit (see the preflight installed
// by installAutoPipelineSharding), so one caller's bad command
// cannot poison a merged batch.
err := fmt.Errorf(
"redis: cannot pipeline command %q with request policy ReqAllNodes/ReqAllShards/ReqMultiShard; Note: This behavior is subject to change in the future", cmd.Name(),
)
setCmdsErr(cmds, err)
return err
}
slot := c.cmdSlot(cmd, -1)
var node *clusterNode
// For keyless commands (slot == -1), use ShardPicker if routing policies are enabled
if slot == -1 && !c.opt.DisableRoutingPolicies && c.opt.ShardPicker != nil {
if len(state.Masters) == 0 {
return errClusterNoNodes
}
idx := c.opt.ShardPicker.Next(len(state.Masters))
node = state.Masters[idx]
} else {
node, err = state.slotMasterNode(slot)
if err != nil {
return err
}
}
cmdsMap.Add(node, cmd)
}
return nil
}
func (c *ClusterClient) cmdsAreReadOnly(ctx context.Context, cmds []Cmder) bool {
for _, cmd := range cmds {
cmdInfo := c.cmdInfo(ctx, cmd.Name())
if cmdInfo == nil || !cmdInfo.ReadOnly {
return false
}
}
return true
}
func (c *ClusterClient) processPipelineNode(
ctx context.Context, node *clusterNode, cmds []Cmder, failedCmds *cmdsMap,
) {
// This call runs on a per-node fan-out goroutine, so register it as an
// executor of every deferred-face batch among cmds: a NODE-level hook
// (OnNewNode — redisotel's tracing) reading a result before next() must
// get the not-yet-executed view from the accessor guards instead of
// blocking on a batch only this call chain completes (reproduced as a
// permanent wedge with a rediscmd-shaped Err() peek).
unregister := registerBatchExecutors(cmds)
defer unregister()
// executed guards against a node-level hook short-circuiting (returning
// without calling next): the inner callback then never runs, and without
// surfacing the chain's error the cluster pipeline would report success
// for commands that were never sent.
executed := false
err := node.Client.withProcessPipelineHook(ctx, cmds, func(ctx context.Context, cmds []Cmder) error {
executed = true
// Acquire through the node's dedicated pipeline pool when one is
// configured (Pipeline*BufferSize propagate to node clients via
// clientOptions); withPipelineConn falls back to the main pool
// otherwise, preserving the previous behavior. entered distinguishes
// an acquisition failure (fn never ran) from an execution error.
entered := false
err := node.Client.withPipelineConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
entered = true
return c.processPipelineNodeConn(ctx, node, cn, cmds, failedCmds)
})
if err != nil && !entered {
if !isContextError(err) {
node.MarkAsFailing()
}
_ = c.mapCmdsByNode(ctx, failedCmds, cmds)
setCmdsErr(cmds, err)
}
return err
})
if !executed {
// A hook returned without calling next. If it supplied an error that is
// a deliberate abort: set it and do not remap for retry (a retry would
// re-run the same hook). If it returned nil it short-circuited
// SUCCESSFULLY, having served the batch itself — the same thing a plain
// Pipeline hook may do — so setCmdsErr(nil) leaves the values it set
// intact (review finding by codex on #3942).
setCmdsErr(cmds, err)
return
}
if err != nil && cmdsFirstErr(cmds) == nil {
// Post-next verdict from a node-level hook on an all-clean sub-batch:
// the exec fully succeeded, so the error can only be the hook's own —
// apply it, mirroring AutoPipeliner.dispatchCmds. On a mixed batch the
// exec-recorded outcomes win (hooks conventionally echo next's error,
// and stamping the echo would overwrite successful replies). No remap:
// retrying would re-run the same hook.
setCmdsErr(cmds, err)
}
}
func (c *ClusterClient) processPipelineNodeConn(
ctx context.Context, node *clusterNode, cn *pool.Conn, cmds []Cmder, failedCmds *cmdsMap,
) error {
// HIMPORT bookkeeping: pending discards for this session and PREPAREs
// for registered fieldsets the batch references get written ahead of
// the batch (see himport.go).
injected := node.Client.himportInjectedCmds(ctx, cn, cmds)
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmds(wr, cmds)
}); err != nil {
if isBadConn(err, false, node.Client.getAddr()) {
node.MarkAsFailing()
}
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
_ = c.mapCmdsByNode(ctx, failedCmds, cmds)
}
setCmdsErr(cmds, err)
return err
}
return cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
if err := node.Client.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
// Transport error with the batch replies unread: same handling
// as a write error — the batch may be retried on a fresh
// connection.
if isBadConn(err, false, node.Client.getAddr()) {
node.MarkAsFailing()
}
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
_ = c.mapCmdsByNode(ctx, failedCmds, cmds)
}
setCmdsErr(cmds, err)
return err
}
err := c.pipelineReadCmds(ctx, node, cn, rd, cmds, failedCmds)
if err == nil || isRedisError(err) {
node.Client.himportAfterBatch(cn, injected, cmds)
// SETs of registered fieldsets that lost their session state
// re-queue for the next attempt, which re-prepares lazily —
// the cluster equivalent of himportRetryFailedSets, bounded by
// the pipeline's attempt budget. A non-nil redis error here
// means pipelineReadCmds already re-queued the whole batch
// (retryable first-command error); adding the SETs again would
// duplicate them in the next attempt.
if err == nil {
c.himportRequeueFailedSets(ctx, cmds, failedCmds)
}
}
return err
})
}
func (c *ClusterClient) pipelineReadCmds(
ctx context.Context,
node *clusterNode,
cn *pool.Conn,
rd *proto.Reader,
cmds []Cmder,
failedCmds *cmdsMap,
) error {
for i, cmd := range cmds {
// Drain any buffered RESP3 push notifications before reading each
// reply — otherwise a push frame (e.g. a maintnotifications MOVING
// notification) is consumed AS the command's reply and every
// subsequent reply in the pipeline shifts by one command. The
// standalone pipeline and the cluster TxPipeline read loops already
// do this; this loop was the only push-blind reader, and the
// autopipeliner routes all cluster traffic through it.
if err := node.Client.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
err := cmd.readReply(rd)
cmd.SetErr(err)
if err == nil {
continue
}
if c.checkMovedErr(ctx, cmd, err, failedCmds) {
continue
}
if c.opt.ReadOnly && isBadConn(err, false, node.Client.getAddr()) {
node.MarkAsFailing()
}
if !isRedisError(err) {
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
_ = c.mapCmdsByNode(ctx, failedCmds, cmds)
}
setCmdsErr(cmds[i+1:], err)
return err
}
}
// rawErr: execution path; never await an async command's batch here.
if err := cmds[0].rawErr(); err != nil && shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
_ = c.mapCmdsByNode(ctx, failedCmds, cmds)
return err
}
return nil
}
func (c *ClusterClient) checkMovedErr(
ctx context.Context, cmd Cmder, err error, failedCmds *cmdsMap,
) bool {
moved, ask, addr := isMovedError(err)
if !moved && !ask {
return false
}
node, err := c.nodes.GetOrCreate(addr)
if err != nil {
return false
}
if moved {
c.state.LazyReload()
failedCmds.Add(node, cmd)
return true
}
if ask {
failedCmds.Add(node, NewCmd(ctx, "asking"), cmd)
return true
}
panic("not reached")
}
// TxPipeline acts like Pipeline, but wraps queued commands with MULTI/EXEC.
func (c *ClusterClient) TxPipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
cmds = wrapMultiExec(ctx, cmds)
return c.processTxPipelineHook(ctx, cmds)
},
}
pipe.init()
return &pipe
}
func (c *ClusterClient) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.TxPipeline().Pipelined(ctx, fn)
}
// A cluster tx pipeline sends MULTI, c1..cN, EXEC — N+2 commands, or N+3 with a
// leading ASKING — and always receives exactly that many replies, so every
// redirect/abort path leaves the connection clean.
//
// Possible reply sequences:
// 1. Slot owned here, no migration:
// +OK, +QUEUED x N, *N (array of N results) -> success
// 2. Slot already migrated away:
// +OK, -MOVED x N, -EXECABORT -> re-route whole tx
// 3. Slot in migrating state (still owned here, keys draining out). Per
// cmd, the queue reply is +QUEUED / -ASK / -TRYAGAIN (keys present /
// all gone / some gone); any -ASK or -TRYAGAIN dirties the tx, so
// EXEC is -EXECABORT. Still N+2 replies, like the cases above:
// +OK, (+QUEUED|-ASK|-TRYAGAIN) x N, -EXECABORT -> follow first redirect
// 4. Narrow race (all +QUEUED, slot moves before EXEC):
// +OK, +QUEUED x N, -MOVED <slot> <addr> -> re-route whole tx
// 5. Non-cluster command error (arity / ACL / unknown):
// +OK, +QUEUED..., -ERR..., -EXECABORT -> surface, not retryable
// 6. Narrow race (all +QUEUED, slot still migrating, keys drain before EXEC):
// +OK, +QUEUED x N, -ASK / -TRYAGAIN -> re-route on -ASK, back off on -TRYAGAIN
//
// EXEC reply — the reply that decides the outcome:
//
// *N success; read N per-command results
// -EXECABORT a queue-stage command failed; follow the first queue
// redirect (MOVED/ASK/TRYAGAIN), else surface the trigger
// -MOVED <slot> <addr> case 4; re-route whole tx to addr, reload topology
// -ASK <slot> <addr> race: slot entered migrating state; re-route to addr
// with a top-level ASKING before MULTI
// -TRYAGAIN race: migrating with split keys, or slot being trimmed
// (CLUSTER_REDIR_TRIMMING on a write); back off and retry
// the whole tx (same node still owns it)
// -CLUSTERDOWN cluster degraded; back off and retry whole tx
//
// ASK retry: the ASKING flag is NOT cleared between commands inside a MULTI
// so one top-level ASKING before MULTI covers the whole tx and lets the importing
// slot serve at EXEC. ASKING placed inside the MULTI would be queued and leave
// the flag unset during queueing, so the keyed commands would still get MOVED.
//
// Out of scope: WATCH's null-array EXEC and -CROSSSLOT;
// cluster TxPipeline is not used with WATCH and cross-slot is rejected client-side.
type txOutcomeKind int
const (
txSuccess txOutcomeKind = iota // transaction executed; per-command results are set
txRetryMoved // MOVED: reload topology and re-route the whole tx
txRetryAsk // ASK: re-route to the target with a top-level ASKING
txRetryTryAgain // TRYAGAIN: back off and re-route the whole tx
txRetryConn // connection/write/read failure: re-route the whole tx
txFatal // non-retryable error; surface to the caller
)
// txOutcome is the result of a single tx attempt. err is the error to report
// when the redirect/retry loop is exhausted (or the fatal error to surface);
// addr is the ASK target; execErr is the EXEC reply error used to mark
// aborted commands; unreadReplies forces the connection to be discarded
// when the read loop exited before consuming all N+2 replies, leaving bytes
// on the wire.
type txOutcome struct {
kind txOutcomeKind
err error
addr string
execErr error
unreadReplies bool
}
// txRedirect records the first queue-stage redirect (MOVED/ASK/TRYAGAIN) seen
// while reading +QUEUED replies. Redis dirties and aborts the transaction on
// any such reply, so the EXEC reply will be EXECABORT and the client must
// follow the recorded redirect with the whole transaction.
type txRedirect struct {
moved bool
ask bool
tryAgain bool
addr string
err error
}
// errTxDirtyConn forces releaseConn to discard a connection that may still have
// unread transaction replies on it (an early exit before consuming all N+2).
var errTxDirtyConn = errors.New("redis: connection has unread transaction replies")
func (c *ClusterClient) processTxPipeline(ctx context.Context, cmds []Cmder) (retErr error) {
var operationStart time.Time
pipelineOpDurationCallback := otel.GetPipelineOperationDurationCallback()
if pipelineOpDurationCallback != nil {
operationStart = time.Now()
}
totalAttempts := 0
var lastErr error
defer func() {
if pipelineOpDurationCallback == nil {
return
}
finalErr := cmp.Or(retErr, cmdsFirstErr(cmds), lastErr)
pipelineOpDurationCallback(ctx, time.Since(operationStart), "MULTI", len(cmds), totalAttempts, finalErr, nil, 0)
}()
// Trim multi .. exec.
cmds = cmds[1 : len(cmds)-1]
if len(cmds) == 0 {
return nil
}
state, err := c.state.Get(ctx)
if err != nil {
setCmdsErr(cmds, err)
return err
}
keyedCmdsBySlot := c.slottedKeyedCommands(ctx, cmds)
slot := -1
switch len(keyedCmdsBySlot) {
case 0:
slot = hashtag.RandomSlot()
case 1:
for sl := range keyedCmdsBySlot {
slot = sl
}
default:
// TxPipeline does not support cross slot transaction.
setCmdsErr(cmds, ErrCrossSlot)
return ErrCrossSlot
}
node, err := state.slotMasterNode(slot)
if err != nil {
setCmdsErr(cmds, err)
return err
}
asking := false
// MOVED/ASK are routing changes, not transient failures: follow them immediately.
redirected := false
for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ {
totalAttempts++
if attempt > 0 && !redirected {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
setCmdsErr(cmds, err)
return err
}
}
outcome := c.processTxPipelineNode(ctx, node, cmds, asking)
lastErr = outcome.err
redirected = false
switch outcome.kind {
case txSuccess:
return cmdsFirstErr(cmds)
case txRetryMoved:
// Route directly to the authoritative addr from the MOVED; the
// cached slot state may be stale until LazyReload lands.
redirected = true
asking = false
c.state.LazyReload()
if node, err = c.nodes.GetOrCreate(outcome.addr); err != nil {
setCmdsErr(cmds, err)
return err
}
case txRetryAsk:
redirected = true
asking = true
if node, err = c.nodes.GetOrCreate(outcome.addr); err != nil {
setCmdsErr(cmds, err)
return err
}
case txRetryTryAgain, txRetryConn:
// Same node, fresh connection: TRYAGAIN comes from the migrating
// source (still the owner), and a conn failure only needs a new
// connection. Preserve a prior ASKING flag: if we followed an ASK
// to the importing target, the retry must still send ASKING (the
// slot is still importing). ASKING is harmless if the migration
// has since completed, since the flag is only consulted for
// importing slots.
case txFatal:
// Mark every queued-but-never-executed command with the abort
// error; the command that triggered EXECABORT already has its
// own error and keeps it, so callers can tell what went wrong.
abortErr := cmp.Or(outcome.execErr, outcome.err)
for _, cmd := range cmds {
if cmd.Err() == nil {
cmd.SetErr(abortErr)
}
}
return lastErr
}
}
if lastErr != nil {
setCmdsErr(cmds, lastErr)
}
return cmdsFirstErr(cmds)
}
// slottedKeyedCommands returns a map of slot to commands taking into account
// only commands that have keys.
func (c *ClusterClient) slottedKeyedCommands(_ context.Context, cmds []Cmder) map[int][]Cmder {
cmdsSlots := map[int][]Cmder{}
// Peek once outside the loop, one RLock for the whole batch instead of
// two per command (one for the keyless check, one inside cmdSlot).
cachedInfo := c.cmdsInfoCache.Peek()
prefferedRandomSlot := -1
for _, cmd := range cmds {
var info *CommandInfo
if cachedInfo != nil {
info = cachedInfo[cmd.Name()]
}
pos := cmdFirstKeyPosWithInfo(cmd, info)
if pos == 0 {
continue
}
slot := c.cmdSlotWithPos(cmd, pos, prefferedRandomSlot)
if prefferedRandomSlot == -1 {
prefferedRandomSlot = slot
}
cmdsSlots[slot] = append(cmdsSlots[slot], cmd)
}
return cmdsSlots
}
func (c *ClusterClient) processTxPipelineNode(
ctx context.Context, node *clusterNode, cmds []Cmder, asking bool,
) *txOutcome {
wire := wrapMultiExec(ctx, cmds)
if asking {
// ASKING must precede MULTI so the flag stays set for the whole tx.
wire = append([]Cmder{NewCmd(ctx, "asking")}, wire...)
}
var outcome *txOutcome
// executed guards against a node-level hook short-circuiting (returning
// without calling next) — same treatment as processPipelineNode.
executed := false
chainErr := node.Client.withProcessPipelineHook(ctx, wire, func(ctx context.Context, wire []Cmder) error {
executed = true
// Acquire through the node's dedicated pipeline pool when configured
// (same routing as processPipelineNode); withPipelineConn falls back
// to the main pool otherwise. The inner fn's return value drives the
// connection release exactly like the explicit releaseConn did:
// redis errors keep the conn poolable, unread replies poison it.
entered := false
err := node.Client.withPipelineConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
entered = true
outcome = c.processTxPipelineNodeConn(ctx, node, cn, wire, cmds, asking)
connErr := outcome.err
if isRedisError(outcome.err) {
connErr = nil
}
if outcome.unreadReplies {
connErr = errTxDirtyConn
}
return connErr
})
if !entered && err != nil {
// Connection acquisition failed — fn never ran.
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
outcome = &txOutcome{kind: txRetryConn, err: err}
} else {
outcome = &txOutcome{kind: txFatal, err: err}
}
}
return err
})
if !executed && chainErr != nil {
// A node-level hook aborted with an error: surface its verdict. A hook
// that returned nil short-circuited successfully (it served the batch),
// which is legal for plain pipelines too, so it is not turned into a
// fatal outcome (review finding by codex on #3942).
outcome = &txOutcome{kind: txFatal, err: chainErr}
}
if outcome == nil {
outcome = &txOutcome{kind: txFatal, err: fmt.Errorf("redis: tx pipeline produced no outcome")}
}
return outcome
}
func (c *ClusterClient) processTxPipelineNodeConn(
ctx context.Context, node *clusterNode, cn *pool.Conn, wire []Cmder, cmds []Cmder, asking bool,
) *txOutcome {
// HIMPORT bookkeeping: pending discards and PREPAREs for registered
// fieldsets the transaction references get written ahead of the wire
// batch (before ASKING/MULTI; the session state is visible at EXEC).
injected := node.Client.himportInjectedCmds(ctx, cn, cmds)
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmds(wr, wire)
}); err != nil {
// Write failure: re-route the whole tx on a fresh connection.
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
return &txOutcome{kind: txRetryConn, err: err}
}
return &txOutcome{kind: txFatal, err: err}
}
var outcome *txOutcome
readErr := cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
if err := node.Client.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
// Transport error with the tx replies unread; the batch was
// written and may have committed — fatal, discard the conn.
outcome = c.txReadFatal(err)
return nil
}
outcome = c.readTxPipelineReplies(ctx, node, cn, rd, cmds, asking)
if outcome != nil && outcome.kind == txSuccess {
node.Client.himportAfterBatch(cn, injected, cmds)
}
return nil
})
if readErr != nil {
// Reader-level failure (deadline setup, nil conn) around the read loop.
// The batch was already written, so the server may have committed;
// surface the error as fatal and discard the suspect connection rather
// than re-executing the transaction.
return c.txReadFatal(readErr)
}
return outcome
}
// readTxPipelineReplies reads the replies of one MULTI..EXEC unit and
// classifies the outcome. The reply count always matches the number of sent
// commands, so success/redirect paths leave the connection clean; only an early
// MULTI read failure can leave unread replies.
func (c *ClusterClient) readTxPipelineReplies(
ctx context.Context, node *clusterNode, cn *pool.Conn, rd *proto.Reader, cmds []Cmder, asking bool,
) *txOutcome {
scratch := NewStatusCmd(ctx)
readStatus := func() error {
c.txProcessPush(ctx, node, cn, rd)
return scratch.readReply(rd)
}
// Optional top-level ASKING reply (+OK, or a retryable error such as -LOADING).
if asking {
if err := readStatus(); err != nil {
return c.txPreQueueErrorOutcome(err, cmds)
}
}
// MULTI reply (+OK, or an error such as -LOADING during failover).
if err := readStatus(); err != nil {
return c.txPreQueueErrorOutcome(err, cmds)
}
// Queue replies: +QUEUED, or a redirect / command error that dirties the tx.
var firstRedirect *txRedirect
var firstFatal error
for _, cmd := range cmds {
err := readStatus()
if err == nil {
continue // +QUEUED
}
if !isRedisError(err) {
return c.txReadFatal(err) // IO error
}
if moved, ask, addr := isMovedError(err); moved || ask {
if firstRedirect == nil {
firstRedirect = &txRedirect{moved: moved, ask: ask, addr: addr, err: err}
}
continue
}
if proto.IsTryAgainError(err) {
if firstRedirect == nil {
firstRedirect = &txRedirect{tryAgain: true, err: err}
}
continue
}
// Non-redirect command error (e.g. wrong arity) dirties the tx.
cmd.SetErr(err)
if firstFatal == nil {
firstFatal = err
}
}
// EXEC reply. ReadLine parses error lines into typed errors, so a non-nil
// err means EXEC returned an error rather than the result array.
c.txProcessPush(ctx, node, cn, rd)
line, err := rd.ReadLine()
if err != nil {
if !isRedisError(err) {
return c.txReadFatal(err) // IO error
}
return c.classifyExecError(err, firstRedirect, firstFatal)
}
if line[0] != proto.RespArray {
err := fmt.Errorf("redis: unexpected EXEC reply %q", line)
setCmdsErr(cmds, err)
// A non-array aggregate reply may carry an unread payload.
return &txOutcome{kind: txFatal, err: err, unreadReplies: true}
}
// Success: read the N command results.
if err := node.Client.pipelineReadCmds(ctx, cn, rd, cmds); err != nil && !isRedisError(err) {
return c.txReadFatal(err) // IO error mid-results
}
return &txOutcome{kind: txSuccess}
}
func (c *ClusterClient) txProcessPush(ctx context.Context, node *clusterNode, cn *pool.Conn, rd *proto.Reader) {
if err := node.Client.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
}
// txReadFatal classifies a read-phase IO error. The MULTI..EXEC batch was
// already written, so the server may have committed the transaction; retrying
// would re-execute it, double-applying non-idempotent commands (INCR/APPEND,
// which are not NoRetry). Surface the error as fatal and discard the
// connection, since replies may still be unread on the wire.
func (c *ClusterClient) txReadFatal(err error) *txOutcome {
return &txOutcome{kind: txFatal, err: err, unreadReplies: true}
}
// txPreQueueErrorOutcome classifies a setup-phase reply error: the top-level
// ASKING reply or the MULTI reply. The transaction body never executes (EXEC
// returns -EXECABORT), so retryable errors such as -LOADING are safe to retry
// on a fresh connection. A failed setup reply still leaves the remaining
// replies on the wire -- the server replies to each following command and to
// EXEC regardless -- so the connection is always discarded.
func (c *ClusterClient) txPreQueueErrorOutcome(err error, cmds []Cmder) *txOutcome {
if !isRedisError(err) {
return c.txReadFatal(err)
}
if shouldRetry(err, true) && !cmdsContainNoRetry(cmds) {
return &txOutcome{kind: txRetryConn, err: err, unreadReplies: true}
}
return &txOutcome{kind: txFatal, err: err, unreadReplies: true}
}
// classifyExecError turns an EXEC reply error into a retry/fatal outcome.
func (c *ClusterClient) classifyExecError(execErr error, firstRedirect *txRedirect, firstFatal error) *txOutcome {
if moved, ask, addr := isMovedError(execErr); moved || ask {
// Narrow race: the slot moved after every command was queued.
if ask {
return &txOutcome{kind: txRetryAsk, err: execErr, addr: addr}
}
return &txOutcome{kind: txRetryMoved, err: execErr, addr: addr}
}
if proto.IsTryAgainError(execErr) {
return &txOutcome{kind: txRetryTryAgain, err: execErr}
}
if proto.IsClusterDownError(execErr) {
// Cluster degraded: back off and retry. Replies were fully consumed.
return &txOutcome{kind: txRetryConn, err: execErr}
}
if proto.IsExecAbortError(execErr) {
if firstFatal != nil {
return &txOutcome{kind: txFatal, err: firstFatal, execErr: execErr}
}
if firstRedirect != nil {
switch {
case firstRedirect.moved:
return &txOutcome{kind: txRetryMoved, err: firstRedirect.err, addr: firstRedirect.addr}
case firstRedirect.ask:
return &txOutcome{kind: txRetryAsk, err: firstRedirect.err, addr: firstRedirect.addr}
case firstRedirect.tryAgain:
return &txOutcome{kind: txRetryTryAgain, err: firstRedirect.err}
}
}
return &txOutcome{kind: txFatal, err: execErr, execErr: execErr}
}
return &txOutcome{kind: txFatal, err: execErr}
}
func (c *ClusterClient) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error {
if len(keys) == 0 {
return errNoWatchKeys
}
slot := hashtag.Slot(keys[0])
for _, key := range keys[1:] {
if hashtag.Slot(key) != slot {
return errWatchCrosslot
}
}
node, err := c.slotMasterNode(ctx, slot)
if err != nil {
return err
}
for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ {
if attempt > 0 {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
return err
}
}
// Track callback errors separately to avoid retrying user failures through cluster retry classification.
var fnErr error
err = node.Client.Watch(ctx, func(tx *Tx) error {
fnErr = fn(tx)
return fnErr
}, keys...)
if err == nil {
break
}
if fnErr != nil {
return fnErr
}
moved, ask, addr := isMovedError(err)
if moved || ask {
node, err = c.nodes.GetOrCreate(addr)
if err != nil {
return err
}
continue
}
if isReadOnly := isReadOnlyError(err); isReadOnly || err == pool.ErrClosed {
if isReadOnly {
c.state.LazyReload()
}
node, err = c.slotMasterNode(ctx, slot)
if err != nil {
return err
}
continue
}
if shouldRetry(err, true) {
continue
}
return err
}
return err
}
// maintenance notifications won't work here for now
func (c *ClusterClient) pubSub() *PubSub {
var node *clusterNode
pubsub := &PubSub{
opt: c.opt.clientOptions(),
newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) {
if node != nil {
panic("node != nil")
}
var err error
if len(channels) > 0 {
slot := hashtag.Slot(channels[0])
// newConn in PubSub is only used for subscription connections, so it is safe to
// assume that a slave node can always be used when client options specify ReadOnly.
if c.opt.ReadOnly {
state, err := c.state.Get(ctx)
if err != nil {
return nil, err
}
node, err = c.slotReadOnlyNode(state, slot)
if err != nil {
return nil, err
}
} else {
node, err = c.slotMasterNode(ctx, slot)
if err != nil {
return nil, err
}
}
} else {
node, err = c.nodes.Random()
if err != nil {
return nil, err
}
}
cn, err := node.Client.pubSubPool.NewConn(ctx, node.Client.opt.Network, node.Client.opt.Addr, channels)
if err != nil {
node = nil
return nil, err
}
// will return nil if already initialized
err = node.Client.initConn(ctx, cn)
if err != nil {
_ = cn.Close()
node = nil
return nil, err
}
node.Client.pubSubPool.TrackConn(cn)
return cn, nil
},
closeConn: func(cn *pool.Conn) error {
// Untrack connection from PubSubPool
node.Client.pubSubPool.UntrackConn(cn)
err := cn.Close()
node = nil
return err
},
}
pubsub.init()
return pubsub
}
// Subscribe subscribes the client to the specified channels.
// Channels can be omitted to create empty subscription.
func (c *ClusterClient) Subscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.Subscribe(ctx, channels...)
}
return pubsub
}
// PSubscribe subscribes the client to the given patterns.
// Patterns can be omitted to create empty subscription.
func (c *ClusterClient) PSubscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.PSubscribe(ctx, channels...)
}
return pubsub
}
// SSubscribe Subscribes the client to the specified shard channels.
func (c *ClusterClient) SSubscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.SSubscribe(ctx, channels...)
}
return pubsub
}
func (c *ClusterClient) retryBackoff(attempt int) time.Duration {
return internal.RetryBackoff(attempt, c.opt.MinRetryBackoff, c.opt.MaxRetryBackoff)
}
func (c *ClusterClient) cmdsInfo(ctx context.Context) (map[string]*CommandInfo, error) {
// Try 3 random nodes.
const nodeLimit = 3
addrs, err := c.nodes.Addrs()
if err != nil {
return nil, err
}
var firstErr error
perm := rand.Perm(len(addrs))
if len(perm) > nodeLimit {
perm = perm[:nodeLimit]
}
for _, idx := range perm {
addr := addrs[idx]
node, err := c.nodes.GetOrCreate(addr)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
info, err := node.Client.Command(ctx).Result()
if err == nil {
return info, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
panic("not reached")
}
return nil, firstErr
}
// cmdInfo will fetch and cache the command policies after the first execution
func (c *ClusterClient) cmdInfo(ctx context.Context, name string) *CommandInfo {
// Use a separate context that won't be canceled to ensure command info lookup
// doesn't fail due to original context cancellation
cmdInfoCtx := c.context(ctx)
if c.opt.ContextTimeoutEnabled && ctx != nil {
// If context timeout is enabled, still use a reasonable timeout
var cancel context.CancelFunc
cmdInfoCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
}
cmdsInfo, err := c.cmdsInfoCache.Get(cmdInfoCtx)
if err != nil {
internal.Logger.Printf(cmdInfoCtx, "getting command info: %s", err)
return nil
}
info := cmdsInfo[name]
if info == nil {
internal.Logger.Printf(cmdInfoCtx, "info for cmd=%s not found", name)
}
return info
}
// cmdInfoPeek returns the cached CommandInfo for the named command without
// triggering a round-trip to Redis. It returns nil when the cache is cold.
func (c *ClusterClient) cmdInfoPeek(name string) *CommandInfo {
if cmds := c.cmdsInfoCache.Peek(); cmds != nil {
return cmds[name]
}
return nil
}
func (c *ClusterClient) cmdSlot(cmd Cmder, prefferedSlot int) int {
// Serve/populate the per-command slot cache only on the natural-slot path
// (prefferedSlot == -1). A forced prefferedSlot (retry re-routing) must not be
// cached or served from cache. The cache lets the autopipeline shard router
// and the pipeline-flush router (mapCmdsByNode) share one slot computation
// instead of each recomputing it.
if prefferedSlot == -1 {
if slot, ok := cmd.cachedSlot(); ok {
return slot
}
}
info := c.cmdInfoPeek(cmd.Name())
slot := c.cmdSlotWithPos(cmd, cmdFirstKeyPosWithInfo(cmd, info), prefferedSlot)
if prefferedSlot == -1 && slot >= 0 {
cmd.setCachedSlot(slot)
}
return slot
}
// cmdSlotWithPos computes the cluster slot for cmd given a pre-resolved first key
// position. Separating pos resolution from slot computation lets callers that
// already know pos avoid a redundant Peek() call.
func (c *ClusterClient) cmdSlotWithPos(cmd Cmder, pos int, prefferedSlot int) int {
args := cmd.Args()
if args[0] == "cluster" && (args[1] == "getkeysinslot" || args[1] == "countkeysinslot") {
return args[2].(int)
}
return cmdSlot(cmd, pos, prefferedSlot)
}
func cmdSlot(cmd Cmder, pos int, prefferedRandomSlot int) int {
if pos == 0 {
if prefferedRandomSlot != -1 {
return prefferedRandomSlot
}
// Return -1 for keyless commands to signal that ShardPicker should be used
return -1
}
firstKey := cmd.stringArg(pos)
return hashtag.Slot(firstKey)
}
func (c *ClusterClient) cmdNode(
ctx context.Context,
cmdName string,
slot int,
) (*clusterNode, error) {
state, err := c.state.Get(ctx)
if err != nil {
return nil, err
}
if c.opt.ReadOnly {
cmdInfo := c.cmdInfo(ctx, cmdName)
if cmdInfo != nil && cmdInfo.ReadOnly {
return c.slotReadOnlyNode(state, slot)
}
}
return state.slotMasterNode(slot)
}
func (c *ClusterClient) cmdNodeWithShardPicker(
ctx context.Context,
cmdName string,
slot int,
shardPicker routing.ShardPicker,
) (*clusterNode, error) {
state, err := c.state.Get(ctx)
if err != nil {
return nil, err
}
// For keyless commands (slot == -1), use ShardPicker to select a shard
// This respects the user's configured ShardPicker policy
if slot == -1 {
if len(state.Masters) == 0 {
return nil, errClusterNoNodes
}
idx := shardPicker.Next(len(state.Masters))
return state.Masters[idx], nil
}
if c.opt.ReadOnly {
cmdInfo := c.cmdInfo(ctx, cmdName)
if cmdInfo != nil && cmdInfo.ReadOnly {
return c.slotReadOnlyNode(state, slot)
}
}
return state.slotMasterNode(slot)
}
func (c *ClusterClient) slotReadOnlyNode(state *clusterState, slot int) (*clusterNode, error) {
if c.opt.RouteByLatency {
if c.opt.RouteByLatencyTolerance > 0 {
return state.slotNodeWithinLatency(slot, c.opt.RouteByLatencyTolerance)
}
return state.slotClosestNode(slot)
}
if c.opt.RouteRandomly {
return state.slotRandomNode(slot)
}
if c.opt.ShardPicker != nil {
return state.slotShardPickerSlaveNode(slot, c.opt.ShardPicker)
}
return state.slotSlaveNode(slot)
}
func (c *ClusterClient) slotMasterNode(ctx context.Context, slot int) (*clusterNode, error) {
state, err := c.state.Get(ctx)
if err != nil {
return nil, err
}
return state.slotMasterNode(slot)
}
// SlaveForKey gets a client for a replica node to run any command on it.
// This is especially useful if we want to run a particular lua script which has
// only read only commands on the replica.
// This is because other redis commands generally have a flag that points that
// they are read only and automatically run on the replica nodes
// if ClusterOptions.ReadOnly flag is set to true.
func (c *ClusterClient) SlaveForKey(ctx context.Context, key string) (*Client, error) {
state, err := c.state.Get(ctx)
if err != nil {
return nil, err
}
slot := hashtag.Slot(key)
node, err := c.slotReadOnlyNode(state, slot)
if err != nil {
return nil, err
}
return node.Client, err
}
// MasterForKey return a client to the master node for a particular key.
func (c *ClusterClient) MasterForKey(ctx context.Context, key string) (*Client, error) {
slot := hashtag.Slot(key)
node, err := c.slotMasterNode(ctx, slot)
if err != nil {
return nil, err
}
return node.Client, nil
}
func (c *ClusterClient) context(ctx context.Context) context.Context {
if c.opt.ContextTimeoutEnabled {
return ctx
}
return context.Background()
}
func (c *ClusterClient) GetResolver() *commandInfoResolver {
return c.cmdInfoResolver
}
func (c *ClusterClient) SetCommandInfoResolver(cmdInfoResolver *commandInfoResolver) {
c.cmdInfoResolver = cmdInfoResolver
}
// extractCommandInfo retrieves the routing policy for a command
func (c *ClusterClient) extractCommandInfo(ctx context.Context, cmd Cmder) *routing.CommandPolicy {
if cmdInfo := c.cmdInfo(ctx, cmd.Name()); cmdInfo != nil && cmdInfo.CommandPolicy != nil {
return cmdInfo.CommandPolicy
}
return nil
}
// NewDynamicResolver returns a CommandInfoResolver
// that uses the underlying cmdInfo cache to resolve the policies
func (c *ClusterClient) NewDynamicResolver() *commandInfoResolver {
return &commandInfoResolver{
resolveFunc: c.extractCommandInfo,
}
}
func appendIfNotExist[T comparable](vals []T, newVal T) []T {
if slices.Contains(vals, newVal) {
return vals
}
return append(vals, newVal)
}
//------------------------------------------------------------------------------
type cmdsMap struct {
mu sync.Mutex
m map[*clusterNode][]Cmder
}
func newCmdsMap() *cmdsMap {
return &cmdsMap{
m: make(map[*clusterNode][]Cmder),
}
}
func (m *cmdsMap) Add(node *clusterNode, cmds ...Cmder) {
m.mu.Lock()
m.m[node] = append(m.m[node], cmds...)
m.mu.Unlock()
}
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 atomic.Int64
err := c.ForEachMaster(ctx, func(ctx context.Context, master *Client) error {
n, err := master.DBSize(ctx).Result()
if err != nil {
return err
}
size.Add(n)
return nil
})
if err != nil {
cmd.SetErr(err)
} else {
cmd.val = size.Load()
}
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
}
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 := make([]*clusterNode, 0, len(state.Masters)+len(state.Slaves))
nodes = append(nodes, state.Masters...)
nodes = append(nodes, 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 := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name()))
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, firstKeyPos)
}
// executeMultiSlot executes commands across multiple slots concurrently
func (c *ClusterClient) executeMultiSlot(ctx context.Context, cmd Cmder, slotMap map[int][]string, keyOrder []string, policy *routing.CommandPolicy, firstKeyPos int) 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, firstKeyPos)
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.
// firstKeyPos is passed in from the caller (computed once in executeMultiShard)
// so this function never independently re-peeks the cache — avoids the
// cold --> warm inconsistency the reviewer flagged.
func (c *ClusterClient) createSlotSpecificCommand(ctx context.Context, originalCmd Cmder, keys []string, firstKeyPos int) Cmder {
originalArgs := originalCmd.Args()
// 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 CmdTypeXAutoClaimWithDeleted:
return NewXAutoClaimWithDeletedCmd(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 := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name()))
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
}
// Index into masters+slaves without materializing a combined slice.
// append(state.Masters, state.Slaves...) writes into the shared snapshot's
// spare capacity and races other routers, so pick directly.
idx := c.opt.ShardPicker.Next(len(state.Masters) + len(state.Slaves))
if idx < len(state.Masters) {
return state.Masters[idx]
}
return state.Slaves[idx-len(state.Masters)]
}
// hasKeys checks if a command operates on keys
func (c *ClusterClient) hasKeys(cmd Cmder) bool {
firstKeyPos := cmdFirstKeyPosWithInfo(cmd, c.cmdInfoPeek(cmd.Name()))
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 CmdTypeXAutoClaimWithDeleted:
if c, ok := cmd.(*XAutoClaimWithDeletedCmd); ok {
if v, ok := value.(CmdTypeXAutoClaimWithDeletedValue); ok {
c.SetVal(v.messages, v.start, v.deletedIDs)
}
}
case CmdTypeXAutoClaimJustID:
if c, ok := cmd.(*XAutoClaimJustIDCmd); ok {
if v, ok := value.(CmdTypeXAutoClaimJustIDValue); ok {
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
}
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()
}
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
}
package redis
import (
"context"
"fmt"
"github.com/redis/go-redis/v9/internal/proto"
)
type ProbabilisticCmdable interface {
BFAdd(ctx context.Context, key string, element interface{}) *BoolCmd
BFCard(ctx context.Context, key string) *IntCmd
BFExists(ctx context.Context, key string, element interface{}) *BoolCmd
BFInfo(ctx context.Context, key string) *BFInfoCmd
BFInfoArg(ctx context.Context, key, option string) *BFInfoCmd
BFInfoCapacity(ctx context.Context, key string) *BFInfoCmd
BFInfoSize(ctx context.Context, key string) *BFInfoCmd
BFInfoFilters(ctx context.Context, key string) *BFInfoCmd
BFInfoItems(ctx context.Context, key string) *BFInfoCmd
BFInfoExpansion(ctx context.Context, key string) *BFInfoCmd
BFInsert(ctx context.Context, key string, options *BFInsertOptions, elements ...interface{}) *BoolSliceCmd
BFMAdd(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd
BFMExists(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd
BFReserve(ctx context.Context, key string, errorRate float64, capacity int64) *StatusCmd
BFReserveExpansion(ctx context.Context, key string, errorRate float64, capacity, expansion int64) *StatusCmd
BFReserveNonScaling(ctx context.Context, key string, errorRate float64, capacity int64) *StatusCmd
BFReserveWithArgs(ctx context.Context, key string, options *BFReserveOptions) *StatusCmd
BFScanDump(ctx context.Context, key string, iterator int64) *ScanDumpCmd
BFLoadChunk(ctx context.Context, key string, iterator int64, data interface{}) *StatusCmd
CFAdd(ctx context.Context, key string, element interface{}) *BoolCmd
CFAddNX(ctx context.Context, key string, element interface{}) *BoolCmd
CFCount(ctx context.Context, key string, element interface{}) *IntCmd
CFDel(ctx context.Context, key string, element interface{}) *BoolCmd
CFExists(ctx context.Context, key string, element interface{}) *BoolCmd
CFInfo(ctx context.Context, key string) *CFInfoCmd
CFInsert(ctx context.Context, key string, options *CFInsertOptions, elements ...interface{}) *BoolSliceCmd
CFInsertNX(ctx context.Context, key string, options *CFInsertOptions, elements ...interface{}) *IntSliceCmd
CFMExists(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd
CFReserve(ctx context.Context, key string, capacity int64) *StatusCmd
CFReserveWithArgs(ctx context.Context, key string, options *CFReserveOptions) *StatusCmd
CFReserveExpansion(ctx context.Context, key string, capacity int64, expansion int64) *StatusCmd
CFReserveBucketSize(ctx context.Context, key string, capacity int64, bucketsize int64) *StatusCmd
CFReserveMaxIterations(ctx context.Context, key string, capacity int64, maxiterations int64) *StatusCmd
CFScanDump(ctx context.Context, key string, iterator int64) *ScanDumpCmd
CFLoadChunk(ctx context.Context, key string, iterator int64, data interface{}) *StatusCmd
CMSIncrBy(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd
CMSInfo(ctx context.Context, key string) *CMSInfoCmd
CMSInitByDim(ctx context.Context, key string, width, height int64) *StatusCmd
CMSInitByProb(ctx context.Context, key string, errorRate, probability float64) *StatusCmd
CMSMerge(ctx context.Context, destKey string, sourceKeys ...string) *StatusCmd
CMSMergeWithWeight(ctx context.Context, destKey string, sourceKeys map[string]int64) *StatusCmd
CMSQuery(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd
TopKAdd(ctx context.Context, key string, elements ...interface{}) *StringSliceCmd
TopKCount(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd
TopKIncrBy(ctx context.Context, key string, elements ...interface{}) *StringSliceCmd
TopKInfo(ctx context.Context, key string) *TopKInfoCmd
TopKList(ctx context.Context, key string) *StringSliceCmd
TopKListWithCount(ctx context.Context, key string) *MapStringIntCmd
TopKQuery(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd
TopKReserve(ctx context.Context, key string, k int64) *StatusCmd
TopKReserveWithOptions(ctx context.Context, key string, k int64, width, depth int64, decay float64) *StatusCmd
TDigestAdd(ctx context.Context, key string, elements ...float64) *StatusCmd
TDigestByRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd
TDigestByRevRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd
TDigestCDF(ctx context.Context, key string, elements ...float64) *FloatSliceCmd
TDigestCreate(ctx context.Context, key string) *StatusCmd
TDigestCreateWithCompression(ctx context.Context, key string, compression int64) *StatusCmd
TDigestInfo(ctx context.Context, key string) *TDigestInfoCmd
TDigestMax(ctx context.Context, key string) *FloatCmd
TDigestMin(ctx context.Context, key string) *FloatCmd
TDigestMerge(ctx context.Context, destKey string, options *TDigestMergeOptions, sourceKeys ...string) *StatusCmd
TDigestQuantile(ctx context.Context, key string, elements ...float64) *FloatSliceCmd
TDigestRank(ctx context.Context, key string, values ...float64) *IntSliceCmd
TDigestReset(ctx context.Context, key string) *StatusCmd
TDigestRevRank(ctx context.Context, key string, values ...float64) *IntSliceCmd
TDigestTrimmedMean(ctx context.Context, key string, lowCutQuantile, highCutQuantile float64) *FloatCmd
}
type BFInsertOptions struct {
Capacity int64
Error float64
Expansion int64
NonScaling bool
NoCreate bool
}
type BFReserveOptions struct {
Capacity int64
Error float64
Expansion int64
NonScaling bool
}
type CFReserveOptions struct {
Capacity int64
BucketSize int64
MaxIterations int64
Expansion int64
}
type CFInsertOptions struct {
Capacity int64
NoCreate bool
}
// -------------------------------------------
// Bloom filter commands
//-------------------------------------------
// BFReserve creates an empty Bloom filter with a single sub-filter
// for the initial specified capacity and with an upper bound error_rate.
// For more information - https://redis.io/commands/bf.reserve/
func (c cmdable) BFReserve(ctx context.Context, key string, errorRate float64, capacity int64) *StatusCmd {
args := []interface{}{"BF.RESERVE", key, errorRate, capacity}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFReserveExpansion creates an empty Bloom filter with a single sub-filter
// for the initial specified capacity and with an upper bound error_rate.
// This function also allows for specifying an expansion rate for the filter.
// For more information - https://redis.io/commands/bf.reserve/
func (c cmdable) BFReserveExpansion(ctx context.Context, key string, errorRate float64, capacity, expansion int64) *StatusCmd {
args := []interface{}{"BF.RESERVE", key, errorRate, capacity, "EXPANSION", expansion}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFReserveNonScaling creates an empty Bloom filter with a single sub-filter
// for the initial specified capacity and with an upper bound error_rate.
// This function also allows for specifying that the filter should not scale.
// For more information - https://redis.io/commands/bf.reserve/
func (c cmdable) BFReserveNonScaling(ctx context.Context, key string, errorRate float64, capacity int64) *StatusCmd {
args := []interface{}{"BF.RESERVE", key, errorRate, capacity, "NONSCALING"}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFReserveWithArgs creates an empty Bloom filter with a single sub-filter
// for the initial specified capacity and with an upper bound error_rate.
// This function also allows for specifying additional options such as expansion rate and non-scaling behavior.
// For more information - https://redis.io/commands/bf.reserve/
func (c cmdable) BFReserveWithArgs(ctx context.Context, key string, options *BFReserveOptions) *StatusCmd {
args := []interface{}{"BF.RESERVE", key}
if options != nil {
args = append(args, options.Error, options.Capacity)
if options.Expansion != 0 {
args = append(args, "EXPANSION", options.Expansion)
}
if options.NonScaling {
args = append(args, "NONSCALING")
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFAdd adds an item to a Bloom filter.
// For more information - https://redis.io/commands/bf.add/
func (c cmdable) BFAdd(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"BF.ADD", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFCard returns the cardinality of a Bloom filter -
// number of items that were added to a Bloom filter and detected as unique
// (items that caused at least one bit to be set in at least one sub-filter).
// For more information - https://redis.io/commands/bf.card/
func (c cmdable) BFCard(ctx context.Context, key string) *IntCmd {
args := []interface{}{"BF.CARD", key}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFExists determines whether a given item was added to a Bloom filter.
// For more information - https://redis.io/commands/bf.exists/
func (c cmdable) BFExists(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"BF.EXISTS", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFLoadChunk restores a Bloom filter previously saved using BF.SCANDUMP.
// For more information - https://redis.io/commands/bf.loadchunk/
func (c cmdable) BFLoadChunk(ctx context.Context, key string, iterator int64, data interface{}) *StatusCmd {
args := []interface{}{"BF.LOADCHUNK", key, iterator, data}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Begins an incremental save of the Bloom filter.
// This command is useful for large Bloom filters that cannot fit into the DUMP and RESTORE model.
// For more information - https://redis.io/commands/bf.scandump/
func (c cmdable) BFScanDump(ctx context.Context, key string, iterator int64) *ScanDumpCmd {
args := []interface{}{"BF.SCANDUMP", key, iterator}
cmd := newScanDumpCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type ScanDump struct {
Iter int64
Data string
}
type ScanDumpCmd struct {
baseCmd
val ScanDump
}
func newScanDumpCmd(ctx context.Context, args ...interface{}) *ScanDumpCmd {
return &ScanDumpCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeScanDump,
},
}
}
func (cmd *ScanDumpCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *ScanDumpCmd) SetVal(val ScanDump) {
cmd.val = val
}
func (cmd *ScanDumpCmd) Result() (ScanDump, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *ScanDumpCmd) Val() ScanDump {
cmd.await()
return cmd.val
}
func (cmd *ScanDumpCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = ScanDump{}
for i := 0; i < n; i++ {
iter, err := rd.ReadInt()
if err != nil {
return err
}
data, err := rd.ReadString()
if err != nil {
return err
}
cmd.val.Data = data
cmd.val.Iter = iter
}
return nil
}
func (cmd *ScanDumpCmd) Clone() Cmder {
return &ScanDumpCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // ScanDump is a simple struct, can be copied directly
}
}
// Returns information about a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfo(ctx context.Context, key string) *BFInfoCmd {
args := []interface{}{"BF.INFO", key}
cmd := NewBFInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type BFInfo struct {
Capacity int64
Size int64
Filters int64
ItemsInserted int64
ExpansionRate int64
}
type BFInfoCmd struct {
baseCmd
val BFInfo
}
func NewBFInfoCmd(ctx context.Context, args ...interface{}) *BFInfoCmd {
return &BFInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeBFInfo,
},
}
}
func (cmd *BFInfoCmd) SetVal(val BFInfo) {
cmd.val = val
}
func (cmd *BFInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *BFInfoCmd) Val() BFInfo {
cmd.await()
return cmd.val
}
func (cmd *BFInfoCmd) Result() (BFInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *BFInfoCmd) readReply(rd *proto.Reader) (err error) {
result := BFInfo{}
// Create a mapping from key names to pointers of struct fields
respMapping := map[string]*int64{
"Capacity": &result.Capacity,
"CAPACITY": &result.Capacity,
"Size": &result.Size,
"SIZE": &result.Size,
"Number of filters": &result.Filters,
"FILTERS": &result.Filters,
"Number of items inserted": &result.ItemsInserted,
"ITEMS": &result.ItemsInserted,
"Expansion rate": &result.ExpansionRate,
"EXPANSION": &result.ExpansionRate,
}
// Helper function to read and assign a value based on the key.
// Unknown keys are drained and skipped when skipUnknown is set so that
// fields added by newer servers don't break the parser.
readAndAssignValue := func(key string, skipUnknown bool) error {
fieldPtr, exists := respMapping[key]
if !exists {
if skipUnknown {
return rd.DiscardNext()
}
return fmt.Errorf("redis: BLOOM.INFO unexpected key %s", key)
}
// Read the integer and assign to the field via pointer dereferencing
val, err := rd.ReadInt()
if err != nil {
return err
}
*fieldPtr = val
return nil
}
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
if len(cmd.args) > 2 && readType == proto.RespArray {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
if key, ok := cmd.args[2].(string); ok && n == 1 {
if err := readAndAssignValue(key, false); err != nil {
return err
}
} else {
return fmt.Errorf("redis: BLOOM.INFO invalid argument key type")
}
} else {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
for i := 0; i < n; i++ {
key, err := rd.ReadString()
if err != nil {
return err
}
if err := readAndAssignValue(key, true); err != nil {
return err
}
}
}
cmd.val = result
return nil
}
func (cmd *BFInfoCmd) Clone() Cmder {
return &BFInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // BFInfo is a simple struct, can be copied directly
}
}
// BFInfoCapacity returns information about the capacity of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoCapacity(ctx context.Context, key string) *BFInfoCmd {
return c.BFInfoArg(ctx, key, "CAPACITY")
}
// BFInfoSize returns information about the size of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoSize(ctx context.Context, key string) *BFInfoCmd {
return c.BFInfoArg(ctx, key, "SIZE")
}
// BFInfoFilters returns information about the filters of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoFilters(ctx context.Context, key string) *BFInfoCmd {
return c.BFInfoArg(ctx, key, "FILTERS")
}
// BFInfoItems returns information about the items of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoItems(ctx context.Context, key string) *BFInfoCmd {
return c.BFInfoArg(ctx, key, "ITEMS")
}
// BFInfoExpansion returns information about the expansion rate of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoExpansion(ctx context.Context, key string) *BFInfoCmd {
return c.BFInfoArg(ctx, key, "EXPANSION")
}
// BFInfoArg returns information about a specific option of a Bloom filter.
// For more information - https://redis.io/commands/bf.info/
func (c cmdable) BFInfoArg(ctx context.Context, key, option string) *BFInfoCmd {
args := []interface{}{"BF.INFO", key, option}
cmd := NewBFInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFInsert inserts elements into a Bloom filter.
// This function also allows for specifying additional options such as:
// capacity, error rate, expansion rate, and non-scaling behavior.
// For more information - https://redis.io/commands/bf.insert/
func (c cmdable) BFInsert(ctx context.Context, key string, options *BFInsertOptions, elements ...interface{}) *BoolSliceCmd {
args := []interface{}{"BF.INSERT", key}
if options != nil {
if options.Capacity != 0 {
args = append(args, "CAPACITY", options.Capacity)
}
if options.Error != 0 {
args = append(args, "ERROR", options.Error)
}
if options.Expansion != 0 {
args = append(args, "EXPANSION", options.Expansion)
}
if options.NoCreate {
args = append(args, "NOCREATE")
}
if options.NonScaling {
args = append(args, "NONSCALING")
}
}
args = append(args, "ITEMS")
args = append(args, elements...)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFMAdd adds multiple elements to a Bloom filter.
// Returns an array of booleans indicating whether each element was added to the filter or not.
// For more information - https://redis.io/commands/bf.madd/
func (c cmdable) BFMAdd(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd {
args := []interface{}{"BF.MADD", key}
args = append(args, elements...)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// BFMExists check if multiple elements exist in a Bloom filter.
// Returns an array of booleans indicating whether each element exists in the filter or not.
// For more information - https://redis.io/commands/bf.mexists/
func (c cmdable) BFMExists(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd {
args := []interface{}{"BF.MEXISTS", key}
args = append(args, elements...)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// -------------------------------------------
// Cuckoo filter commands
//-------------------------------------------
// CFReserve creates an empty Cuckoo filter with the specified capacity.
// For more information - https://redis.io/commands/cf.reserve/
func (c cmdable) CFReserve(ctx context.Context, key string, capacity int64) *StatusCmd {
args := []interface{}{"CF.RESERVE", key, capacity}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFReserveExpansion creates an empty Cuckoo filter with the specified capacity and expansion rate.
// For more information - https://redis.io/commands/cf.reserve/
func (c cmdable) CFReserveExpansion(ctx context.Context, key string, capacity int64, expansion int64) *StatusCmd {
args := []interface{}{"CF.RESERVE", key, capacity, "EXPANSION", expansion}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFReserveBucketSize creates an empty Cuckoo filter with the specified capacity and bucket size.
// For more information - https://redis.io/commands/cf.reserve/
func (c cmdable) CFReserveBucketSize(ctx context.Context, key string, capacity int64, bucketsize int64) *StatusCmd {
args := []interface{}{"CF.RESERVE", key, capacity, "BUCKETSIZE", bucketsize}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFReserveMaxIterations creates an empty Cuckoo filter with the specified capacity and maximum number of iterations.
// For more information - https://redis.io/commands/cf.reserve/
func (c cmdable) CFReserveMaxIterations(ctx context.Context, key string, capacity int64, maxiterations int64) *StatusCmd {
args := []interface{}{"CF.RESERVE", key, capacity, "MAXITERATIONS", maxiterations}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFReserveWithArgs creates an empty Cuckoo filter with the specified options.
// This function allows for specifying additional options such as bucket size and maximum number of iterations.
// For more information - https://redis.io/commands/cf.reserve/
func (c cmdable) CFReserveWithArgs(ctx context.Context, key string, options *CFReserveOptions) *StatusCmd {
args := []interface{}{"CF.RESERVE", key, options.Capacity}
if options.BucketSize != 0 {
args = append(args, "BUCKETSIZE", options.BucketSize)
}
if options.MaxIterations != 0 {
args = append(args, "MAXITERATIONS", options.MaxIterations)
}
if options.Expansion != 0 {
args = append(args, "EXPANSION", options.Expansion)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFAdd adds an element to a Cuckoo filter.
// Returns true if the element was added to the filter or false if it already exists in the filter.
// For more information - https://redis.io/commands/cf.add/
func (c cmdable) CFAdd(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"CF.ADD", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFAddNX adds an element to a Cuckoo filter only if it does not already exist in the filter.
// Returns true if the element was added to the filter or false if it already exists in the filter.
// For more information - https://redis.io/commands/cf.addnx/
func (c cmdable) CFAddNX(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"CF.ADDNX", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFCount returns an estimate of the number of times an element may be in a Cuckoo Filter.
// For more information - https://redis.io/commands/cf.count/
func (c cmdable) CFCount(ctx context.Context, key string, element interface{}) *IntCmd {
args := []interface{}{"CF.COUNT", key, element}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFDel deletes an item once from the cuckoo filter.
// For more information - https://redis.io/commands/cf.del/
func (c cmdable) CFDel(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"CF.DEL", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFExists determines whether an item may exist in the Cuckoo Filter or not.
// For more information - https://redis.io/commands/cf.exists/
func (c cmdable) CFExists(ctx context.Context, key string, element interface{}) *BoolCmd {
args := []interface{}{"CF.EXISTS", key, element}
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFLoadChunk restores a filter previously saved using SCANDUMP.
// For more information - https://redis.io/commands/cf.loadchunk/
func (c cmdable) CFLoadChunk(ctx context.Context, key string, iterator int64, data interface{}) *StatusCmd {
args := []interface{}{"CF.LOADCHUNK", key, iterator, data}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFScanDump begins an incremental save of the cuckoo filter.
// For more information - https://redis.io/commands/cf.scandump/
func (c cmdable) CFScanDump(ctx context.Context, key string, iterator int64) *ScanDumpCmd {
args := []interface{}{"CF.SCANDUMP", key, iterator}
cmd := newScanDumpCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type CFInfo struct {
Size int64
NumBuckets int64
NumFilters int64
NumItemsInserted int64
NumItemsDeleted int64
BucketSize int64
ExpansionRate int64
MaxIteration int64
}
type CFInfoCmd struct {
baseCmd
val CFInfo
}
func NewCFInfoCmd(ctx context.Context, args ...interface{}) *CFInfoCmd {
return &CFInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeCFInfo,
},
}
}
func (cmd *CFInfoCmd) SetVal(val CFInfo) {
cmd.val = val
}
func (cmd *CFInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *CFInfoCmd) Val() CFInfo {
cmd.await()
return cmd.val
}
func (cmd *CFInfoCmd) Result() (CFInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *CFInfoCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
var result CFInfo
for f := 0; f < n; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "Size":
result.Size, err = rd.ReadInt()
case "Number of buckets":
result.NumBuckets, err = rd.ReadInt()
case "Number of filters":
result.NumFilters, err = rd.ReadInt()
case "Number of items inserted":
result.NumItemsInserted, err = rd.ReadInt()
case "Number of items deleted":
result.NumItemsDeleted, err = rd.ReadInt()
case "Bucket size":
result.BucketSize, err = rd.ReadInt()
case "Expansion rate":
result.ExpansionRate, err = rd.ReadInt()
case "Max iterations":
result.MaxIteration, err = rd.ReadInt()
default:
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
return err
}
}
cmd.val = result
return nil
}
func (cmd *CFInfoCmd) Clone() Cmder {
return &CFInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // CFInfo is a simple struct, can be copied directly
}
}
// CFInfo returns information about a Cuckoo filter.
// For more information - https://redis.io/commands/cf.info/
func (c cmdable) CFInfo(ctx context.Context, key string) *CFInfoCmd {
args := []interface{}{"CF.INFO", key}
cmd := NewCFInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFInsert inserts elements into a Cuckoo filter.
// This function also allows for specifying additional options such as capacity, error rate, expansion rate, and non-scaling behavior.
// Returns an array of booleans indicating whether each element was added to the filter or not.
// For more information - https://redis.io/commands/cf.insert/
func (c cmdable) CFInsert(ctx context.Context, key string, options *CFInsertOptions, elements ...interface{}) *BoolSliceCmd {
args := []interface{}{"CF.INSERT", key}
args = c.getCfInsertWithArgs(args, options, elements...)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CFInsertNX inserts elements into a Cuckoo filter only if they do not already exist in the filter.
// This function also allows for specifying additional options such as:
// capacity, error rate, expansion rate, and non-scaling behavior.
// Returns an array of integers indicating whether each element was added to the filter or not.
// For more information - https://redis.io/commands/cf.insertnx/
func (c cmdable) CFInsertNX(ctx context.Context, key string, options *CFInsertOptions, elements ...interface{}) *IntSliceCmd {
args := []interface{}{"CF.INSERTNX", key}
args = c.getCfInsertWithArgs(args, options, elements...)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) getCfInsertWithArgs(args []interface{}, options *CFInsertOptions, elements ...interface{}) []interface{} {
if options != nil {
if options.Capacity != 0 {
args = append(args, "CAPACITY", options.Capacity)
}
if options.NoCreate {
args = append(args, "NOCREATE")
}
}
args = append(args, "ITEMS")
args = append(args, elements...)
return args
}
// CFMExists check if multiple elements exist in a Cuckoo filter.
// Returns an array of booleans indicating whether each element exists in the filter or not.
// For more information - https://redis.io/commands/cf.mexists/
func (c cmdable) CFMExists(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd {
args := []interface{}{"CF.MEXISTS", key}
args = append(args, elements...)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// -------------------------------------------
// CMS commands
//-------------------------------------------
// CMSIncrBy increments the count of one or more items in a Count-Min Sketch filter.
// Returns an array of integers representing the updated count of each item.
// For more information - https://redis.io/commands/cms.incrby/
func (c cmdable) CMSIncrBy(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd {
args := make([]interface{}, 2, 2+len(elements))
args[0] = "CMS.INCRBY"
args[1] = key
args = appendArgs(args, elements)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type CMSInfo struct {
Width int64
Depth int64
Count int64
// CellSize is the size in bytes of each counter (1, 2, 4 or 8).
// Reported since Redis 8.12, alongside the CELL_SIZE option of
// CMS.INITBYDIM / CMS.INITBYPROB; zero on older servers.
CellSize int64
}
type CMSInfoCmd struct {
baseCmd
val CMSInfo
}
func NewCMSInfoCmd(ctx context.Context, args ...interface{}) *CMSInfoCmd {
return &CMSInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeCMSInfo,
},
}
}
func (cmd *CMSInfoCmd) SetVal(val CMSInfo) {
cmd.val = val
}
func (cmd *CMSInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *CMSInfoCmd) Val() CMSInfo {
cmd.await()
return cmd.val
}
func (cmd *CMSInfoCmd) Result() (CMSInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *CMSInfoCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
var result CMSInfo
for f := 0; f < n; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "width":
result.Width, err = rd.ReadInt()
case "depth":
result.Depth, err = rd.ReadInt()
case "count":
result.Count, err = rd.ReadInt()
case "cell_size":
result.CellSize, err = rd.ReadInt()
default:
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
return err
}
}
cmd.val = result
return nil
}
func (cmd *CMSInfoCmd) Clone() Cmder {
return &CMSInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // CMSInfo is a simple struct, can be copied directly
}
}
// CMSInfo returns information about a Count-Min Sketch filter.
// For more information - https://redis.io/commands/cms.info/
func (c cmdable) CMSInfo(ctx context.Context, key string) *CMSInfoCmd {
args := []interface{}{"CMS.INFO", key}
cmd := NewCMSInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CMSInitByDim creates an empty Count-Min Sketch filter with the specified dimensions.
// For more information - https://redis.io/commands/cms.initbydim/
func (c cmdable) CMSInitByDim(ctx context.Context, key string, width, depth int64) *StatusCmd {
args := []interface{}{"CMS.INITBYDIM", key, width, depth}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CMSInitByProb creates an empty Count-Min Sketch filter with the specified error rate and probability.
// For more information - https://redis.io/commands/cms.initbyprob/
func (c cmdable) CMSInitByProb(ctx context.Context, key string, errorRate, probability float64) *StatusCmd {
args := []interface{}{"CMS.INITBYPROB", key, errorRate, probability}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CMSMerge merges multiple Count-Min Sketch filters into a single filter.
// The destination filter must not exist and will be created with the dimensions of the first source filter.
// The number of items in each source filter must be equal.
// Returns OK on success or an error if the filters could not be merged.
// For more information - https://redis.io/commands/cms.merge/
func (c cmdable) CMSMerge(ctx context.Context, destKey string, sourceKeys ...string) *StatusCmd {
args := []interface{}{"CMS.MERGE", destKey, len(sourceKeys)}
for _, s := range sourceKeys {
args = append(args, s)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CMSMergeWithWeight merges multiple Count-Min Sketch filters into a single filter with weights for each source filter.
// The destination filter must not exist and will be created with the dimensions of the first source filter.
// The number of items in each source filter must be equal.
// Returns OK on success or an error if the filters could not be merged.
// For more information - https://redis.io/commands/cms.merge/
func (c cmdable) CMSMergeWithWeight(ctx context.Context, destKey string, sourceKeys map[string]int64) *StatusCmd {
args := make([]interface{}, 0, 4+(len(sourceKeys)*2+1))
args = append(args, "CMS.MERGE", destKey, len(sourceKeys))
if len(sourceKeys) > 0 {
sk := make([]interface{}, len(sourceKeys))
sw := make([]interface{}, len(sourceKeys))
i := 0
for k, w := range sourceKeys {
sk[i] = k
sw[i] = w
i++
}
args = append(args, sk...)
args = append(args, "WEIGHTS")
args = append(args, sw...)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// CMSQuery returns count for item(s).
// For more information - https://redis.io/commands/cms.query/
func (c cmdable) CMSQuery(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd {
args := []interface{}{"CMS.QUERY", key}
args = append(args, elements...)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// -------------------------------------------
// TopK commands
//--------------------------------------------
// TopKAdd adds one or more elements to a Top-K filter.
// Returns an array of strings representing the items that were removed from the filter, if any.
// For more information - https://redis.io/commands/topk.add/
func (c cmdable) TopKAdd(ctx context.Context, key string, elements ...interface{}) *StringSliceCmd {
args := make([]interface{}, 2, 2+len(elements))
args[0] = "TOPK.ADD"
args[1] = key
args = appendArgs(args, elements)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKReserve creates an empty Top-K filter with the specified number of top items to keep.
// For more information - https://redis.io/commands/topk.reserve/
func (c cmdable) TopKReserve(ctx context.Context, key string, k int64) *StatusCmd {
args := []interface{}{"TOPK.RESERVE", key, k}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKReserveWithOptions creates an empty Top-K filter with the specified number of top items to keep and additional options.
// This function allows for specifying additional options such as width, depth and decay.
// For more information - https://redis.io/commands/topk.reserve/
func (c cmdable) TopKReserveWithOptions(ctx context.Context, key string, k int64, width, depth int64, decay float64) *StatusCmd {
args := []interface{}{"TOPK.RESERVE", key, k, width, depth, decay}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type TopKInfo struct {
K int64
Width int64
Depth int64
Decay float64
}
type TopKInfoCmd struct {
baseCmd
val TopKInfo
}
func NewTopKInfoCmd(ctx context.Context, args ...interface{}) *TopKInfoCmd {
return &TopKInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTopKInfo,
},
}
}
func (cmd *TopKInfoCmd) SetVal(val TopKInfo) {
cmd.val = val
}
func (cmd *TopKInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TopKInfoCmd) Val() TopKInfo {
cmd.await()
return cmd.val
}
func (cmd *TopKInfoCmd) Result() (TopKInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TopKInfoCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
var result TopKInfo
for f := 0; f < n; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "k":
result.K, err = rd.ReadInt()
case "width":
result.Width, err = rd.ReadInt()
case "depth":
result.Depth, err = rd.ReadInt()
case "decay":
result.Decay, err = rd.ReadFloat()
default:
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
return err
}
}
cmd.val = result
return nil
}
func (cmd *TopKInfoCmd) Clone() Cmder {
return &TopKInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // TopKInfo is a simple struct, can be copied directly
}
}
// TopKInfo returns information about a Top-K filter.
// For more information - https://redis.io/commands/topk.info/
func (c cmdable) TopKInfo(ctx context.Context, key string) *TopKInfoCmd {
args := []interface{}{"TOPK.INFO", key}
cmd := NewTopKInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKQuery check if multiple elements exist in a Top-K filter.
// Returns an array of booleans indicating whether each element exists in the filter or not.
// For more information - https://redis.io/commands/topk.query/
func (c cmdable) TopKQuery(ctx context.Context, key string, elements ...interface{}) *BoolSliceCmd {
args := make([]interface{}, 2, 2+len(elements))
args[0] = "TOPK.QUERY"
args[1] = key
args = appendArgs(args, elements)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKCount returns an estimate of the number of times an item may be in a Top-K filter.
// For more information - https://redis.io/commands/topk.count/
func (c cmdable) TopKCount(ctx context.Context, key string, elements ...interface{}) *IntSliceCmd {
args := make([]interface{}, 2, 2+len(elements))
args[0] = "TOPK.COUNT"
args[1] = key
args = appendArgs(args, elements)
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKIncrBy increases the count of one or more items in a Top-K filter.
// For more information - https://redis.io/commands/topk.incrby/
func (c cmdable) TopKIncrBy(ctx context.Context, key string, elements ...interface{}) *StringSliceCmd {
args := make([]interface{}, 2, 2+len(elements))
args[0] = "TOPK.INCRBY"
args[1] = key
args = appendArgs(args, elements)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKList returns all items in Top-K list.
// For more information - https://redis.io/commands/topk.list/
func (c cmdable) TopKList(ctx context.Context, key string) *StringSliceCmd {
args := []interface{}{"TOPK.LIST", key}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TopKListWithCount returns all items in Top-K list with their respective count.
// For more information - https://redis.io/commands/topk.list/
func (c cmdable) TopKListWithCount(ctx context.Context, key string) *MapStringIntCmd {
args := []interface{}{"TOPK.LIST", key, "WITHCOUNT"}
cmd := NewMapStringIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// -------------------------------------------
// t-digest commands
// --------------------------------------------
// TDigestAdd adds one or more elements to a t-Digest data structure.
// Returns OK on success or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.add/
func (c cmdable) TDigestAdd(ctx context.Context, key string, elements ...float64) *StatusCmd {
args := make([]interface{}, 2+len(elements))
args[0] = "TDIGEST.ADD"
args[1] = key
for i, v := range elements {
args[2+i] = v
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestByRank returns an array of values from a t-Digest data structure based on their rank.
// The rank of an element is its position in the sorted list of all elements in the t-Digest.
// Returns an array of floats representing the values at the specified ranks or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.byrank/
func (c cmdable) TDigestByRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd {
args := make([]interface{}, 2+len(rank))
args[0] = "TDIGEST.BYRANK"
args[1] = key
for i, r := range rank {
args[2+i] = r
}
cmd := NewFloatSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestByRevRank returns an array of values from a t-Digest data structure based on their reverse rank.
// The reverse rank of an element is its position in the sorted list of all elements in the t-Digest when sorted in descending order.
// Returns an array of floats representing the values at the specified ranks or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.byrevrank/
func (c cmdable) TDigestByRevRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd {
args := make([]interface{}, 2+len(rank))
args[0] = "TDIGEST.BYREVRANK"
args[1] = key
for i, r := range rank {
args[2+i] = r
}
cmd := NewFloatSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestCDF returns an array of cumulative distribution function (CDF) values for one or more elements in a t-Digest data structure.
// The CDF value for an element is the fraction of all elements in the t-Digest that are less than or equal to it.
// Returns an array of floats representing the CDF values for each element or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.cdf/
func (c cmdable) TDigestCDF(ctx context.Context, key string, elements ...float64) *FloatSliceCmd {
args := make([]interface{}, 2+len(elements))
args[0] = "TDIGEST.CDF"
args[1] = key
for i, v := range elements {
args[2+i] = v
}
cmd := NewFloatSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestCreate creates an empty t-Digest data structure with default parameters.
// Returns OK on success or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.create/
func (c cmdable) TDigestCreate(ctx context.Context, key string) *StatusCmd {
args := []interface{}{"TDIGEST.CREATE", key}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestCreateWithCompression creates an empty t-Digest data structure with a specified compression parameter.
// The compression parameter controls the accuracy and memory usage of the t-Digest.
// Returns OK on success or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.create/
func (c cmdable) TDigestCreateWithCompression(ctx context.Context, key string, compression int64) *StatusCmd {
args := []interface{}{"TDIGEST.CREATE", key, "COMPRESSION", compression}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type TDigestInfo struct {
Compression int64
Capacity int64
MergedNodes int64
UnmergedNodes int64
MergedWeight int64
UnmergedWeight int64
Observations int64
TotalCompressions int64
MemoryUsage int64
}
type TDigestInfoCmd struct {
baseCmd
val TDigestInfo
}
func NewTDigestInfoCmd(ctx context.Context, args ...interface{}) *TDigestInfoCmd {
return &TDigestInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTDigestInfo,
},
}
}
func (cmd *TDigestInfoCmd) SetVal(val TDigestInfo) {
cmd.val = val
}
func (cmd *TDigestInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TDigestInfoCmd) Val() TDigestInfo {
cmd.await()
return cmd.val
}
func (cmd *TDigestInfoCmd) Result() (TDigestInfo, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TDigestInfoCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
var key string
var result TDigestInfo
for f := 0; f < n; f++ {
key, err = rd.ReadString()
if err != nil {
return err
}
switch key {
case "Compression":
result.Compression, err = rd.ReadInt()
case "Capacity":
result.Capacity, err = rd.ReadInt()
case "Merged nodes":
result.MergedNodes, err = rd.ReadInt()
case "Unmerged nodes":
result.UnmergedNodes, err = rd.ReadInt()
case "Merged weight":
result.MergedWeight, err = rd.ReadInt()
case "Unmerged weight":
result.UnmergedWeight, err = rd.ReadInt()
case "Observations":
result.Observations, err = rd.ReadInt()
case "Total compressions":
result.TotalCompressions, err = rd.ReadInt()
case "Memory usage":
result.MemoryUsage, err = rd.ReadInt()
default:
// skip unknown fields so newer servers don't break the parser
err = rd.DiscardNext()
}
if err != nil {
return err
}
}
cmd.val = result
return nil
}
func (cmd *TDigestInfoCmd) Clone() Cmder {
return &TDigestInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val, // TDigestInfo is a simple struct, can be copied directly
}
}
// TDigestInfo returns information about a t-Digest data structure.
// For more information - https://redis.io/commands/tdigest.info/
func (c cmdable) TDigestInfo(ctx context.Context, key string) *TDigestInfoCmd {
args := []interface{}{"TDIGEST.INFO", key}
cmd := NewTDigestInfoCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestMax returns the maximum value from a t-Digest data structure.
// For more information - https://redis.io/commands/tdigest.max/
func (c cmdable) TDigestMax(ctx context.Context, key string) *FloatCmd {
args := []interface{}{"TDIGEST.MAX", key}
cmd := NewFloatCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type TDigestMergeOptions struct {
Compression int64
Override bool
}
// TDigestMerge merges multiple t-Digest data structures into a single t-Digest.
// This function also allows for specifying additional options such as compression and override behavior.
// Returns OK on success or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.merge/
func (c cmdable) TDigestMerge(ctx context.Context, destKey string, options *TDigestMergeOptions, sourceKeys ...string) *StatusCmd {
args := []interface{}{"TDIGEST.MERGE", destKey, len(sourceKeys)}
for _, sourceKey := range sourceKeys {
args = append(args, sourceKey)
}
if options != nil {
if options.Compression != 0 {
args = append(args, "COMPRESSION", options.Compression)
}
if options.Override {
args = append(args, "OVERRIDE")
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestMin returns the minimum value from a t-Digest data structure.
// For more information - https://redis.io/commands/tdigest.min/
func (c cmdable) TDigestMin(ctx context.Context, key string) *FloatCmd {
args := []interface{}{"TDIGEST.MIN", key}
cmd := NewFloatCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestQuantile returns an array of quantile values for one or more elements in a t-Digest data structure.
// The quantile value for an element is the fraction of all elements in the t-Digest that are less than or equal to it.
// Returns an array of floats representing the quantile values for each element or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.quantile/
func (c cmdable) TDigestQuantile(ctx context.Context, key string, elements ...float64) *FloatSliceCmd {
args := make([]interface{}, 2+len(elements))
args[0] = "TDIGEST.QUANTILE"
args[1] = key
for i, v := range elements {
args[2+i] = v
}
cmd := NewFloatSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestRank returns an array of rank values for one or more elements in a t-Digest data structure.
// The rank of an element is its position in the sorted list of all elements in the t-Digest.
// Returns an array of integers representing the rank values for each element or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.rank/
func (c cmdable) TDigestRank(ctx context.Context, key string, values ...float64) *IntSliceCmd {
args := make([]interface{}, 2+len(values))
args[0] = "TDIGEST.RANK"
args[1] = key
for i, v := range values {
args[i+2] = v
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestReset resets a t-Digest data structure to its initial state.
// Returns OK on success or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.reset/
func (c cmdable) TDigestReset(ctx context.Context, key string) *StatusCmd {
args := []interface{}{"TDIGEST.RESET", key}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestRevRank returns an array of reverse rank values for one or more elements in a t-Digest data structure.
// The reverse rank of an element is its position in the sorted list of all elements in the t-Digest when sorted in descending order.
// Returns an array of integers representing the reverse rank values for each element or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.revrank/
func (c cmdable) TDigestRevRank(ctx context.Context, key string, values ...float64) *IntSliceCmd {
args := make([]interface{}, 2+len(values))
args[0] = "TDIGEST.REVRANK"
args[1] = key
for i, v := range values {
args[2+i] = v
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TDigestTrimmedMean returns the trimmed mean value from a t-Digest data structure.
// The trimmed mean is calculated by removing a specified fraction of the highest and lowest values from the t-Digest and then calculating the mean of the remaining values.
// Returns a float representing the trimmed mean value or an error if the operation could not be completed.
// For more information - https://redis.io/commands/tdigest.trimmed_mean/
func (c cmdable) TDigestTrimmedMean(ctx context.Context, key string, lowCutQuantile, highCutQuantile float64) *FloatCmd {
args := []interface{}{"TDIGEST.TRIMMED_MEAN", key, lowCutQuantile, highCutQuantile}
cmd := NewFloatCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
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
}
// Include c.schannels so reconnect-time routing of an SSubscribe-only
// PubSub picks the slot owner (channels[0] in ClusterClient.pubSub()'s
// newConn closure) instead of a random node.
// See https://github.com/redis/go-redis/issues/3806.
// c.patterns is intentionally NOT included: patterns are not slot-
// addressable, and adding them would force PSubscribe-only PubSubs to
// pin to a single node based on pattern-string hash, regressing the
// existing random-node behaviour.
channels := slices.Collect(maps.Keys(c.channels))
channels = append(channels, slices.Collect(maps.Keys(c.schannels))...)
channels = append(channels, newChannels...)
cn, err := c.newConn(ctx, c.opt.Addr, channels)
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"))
return
}
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
}
}
// WithChannelPingTimeout specifies the timeout for the health-check ping.
//
// The default is 5 seconds.
func WithChannelPingTimeout(d time.Duration) ChannelOption {
return func(c *channel) {
c.pingTimeout = d
}
}
// WithChannelReconnectTimeout specifies the timeout for reconnecting after
// a failed health-check ping.
//
// The default is 10 seconds.
func WithChannelReconnectTimeout(d time.Duration) ChannelOption {
return func(c *channel) {
c.reconnectTimeout = d
}
}
type channel struct {
pubSub *PubSub
msgCh chan *Message
allCh chan interface{}
ping chan struct{}
chanSize int
chanSendTimeout time.Duration
checkInterval time.Duration
pingTimeout time.Duration
reconnectTimeout time.Duration
}
func newChannel(pubSub *PubSub, opts ...ChannelOption) *channel {
c := &channel{
pubSub: pubSub,
chanSize: 100,
chanSendTimeout: time.Minute,
checkInterval: 3 * time.Second,
pingTimeout: 5 * time.Second,
reconnectTimeout: 10 * time.Second,
}
for _, opt := range opts {
opt(c)
}
if c.checkInterval > 0 {
c.initHealthCheck()
}
return c
}
func (c *channel) initHealthCheck() {
c.ping = make(chan struct{}, 1)
go func() {
timer := time.NewTimer(time.Minute)
timer.Stop()
for {
timer.Reset(c.checkInterval)
select {
case <-c.ping:
select {
case <-timer.C:
default:
}
case <-timer.C:
ctx, cancel := context.WithTimeout(context.Background(), c.pingTimeout)
pingErr := c.pubSub.Ping(ctx)
cancel()
if pingErr != nil {
c.pubSub.mu.Lock()
reconnectCtx, reconnectCancel := context.WithTimeout(context.Background(), c.reconnectTimeout)
c.pubSub.reconnect(reconnectCtx, pingErr)
reconnectCancel()
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)
}
}
}()
}
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). Gated on the result
// being readable WITHOUT blocking: on the deferred autopipeline face the
// call above only enqueues, so reading the outcome here would await the
// batch and turn a fire-and-forget publish into a blocking call — i.e.
// enabling telemetry would change the async call shape (review finding by
// codex on #3942). The metric is therefore skipped for a submission that
// has not executed yet; recording it from the execution path instead is a
// follow-up in the OTel wiring, not something the command wrapper can do.
if otel.Enabled() && cmd.resultReady() && cmd.rawErr() == 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). See Publish for why
// this is gated on the result being readable without blocking.
if otel.Enabled() && cmd.resultReady() && cmd.rawErr() == 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
}
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
}
package push
import (
"context"
"errors"
"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
}
type timeoutError interface {
Timeout() bool
}
func isTimeoutError(err error) bool {
var timeoutErr timeoutError
return errors.As(err, &timeoutErr) && timeoutErr.Timeout()
}
// 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 {
return p.processPendingNotifications(ctx, handlerCtx, rd, false)
}
// ProcessPendingNotificationsBuffered processes one pending push notification
// and then continues only through frames already buffered by that read. It is
// used by callers that have already established socket readiness and must not
// wait for another frame after draining the current batch.
func (p *Processor) ProcessPendingNotificationsBuffered(
ctx context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader,
) error {
return p.processPendingNotifications(ctx, handlerCtx, rd, true)
}
func (p *Processor) processPendingNotifications(
ctx context.Context,
handlerCtx NotificationHandlerContext,
rd *proto.Reader,
bufferedContinuation bool,
) error {
if rd == nil {
return nil
}
processed := false
for !bufferedContinuation || !processed || rd.Buffered() > 0 {
// Check if there's data available to read
var replyType byte
if bufferedContinuation {
for {
b, err := rd.Peek(1)
if err != nil {
if isTimeoutError(err) {
return nil
}
return err
}
replyType = b[0]
if replyType != proto.RespAttr {
break
}
// Unlike Peek, DiscardNext consumes bytes. Any error here is
// fatal because the reader may be left mid-frame.
if err := rd.DiscardNext(); err != nil {
return err
}
}
} else {
var err error
replyType, err = rd.PeekReplyType()
if err != nil {
// No more data available or error reading.
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 {
// A buffered probe stops cleanly when no full frame is available yet.
if bufferedContinuation && isTimeoutError(err) {
return nil
}
// The frame is a CONFIRMED push (peeked above) but its name cannot be
// peeked — too long, or a non-string / malformed name. Fall through to
// ReadReply to CONSUME it. Do NOT break: that leaves the push at the
// buffer head for the caller's reply read to eat as the command value
// (cache poison / one-frame reply shift). A genuine mid-frame read error
// surfaces at ReadReply below (fatal → the reply-expected drain retires
// the conn); a non-string name is gracefully ignored (the type assertion
// below fails, so no handler runs).
} else if willHandleNotificationInClient(notificationName) {
break
}
// Surface a ReadReply error (unlike the boundary peek errors above,
// which consumed nothing): it happens mid-frame after bytes are
// consumed, so the conn is desynced and the CSC drainer must remove it.
// Normal reply-read callers log-and-ignore this and let their own read fail.
reply, err := rd.ReadReply()
if err != nil {
internal.Logger.Printf(ctx, "push: error reading push notification: %v", err)
return err
}
processed = true
// 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 {
// Name too long to peek: still consume the frame below so it isn't
// misread as a reply.
if !errors.Is(err, proto.ErrPushNotificationNameTooLong) {
break
}
} else 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
}
}
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
}
package redis
import (
"github.com/redis/go-redis/v9/push"
)
// NewPushNotificationProcessor creates a new push notification processor
// This processor maintains a registry of handlers and processes push notifications
// It is used for RESP3 connections where push notifications are available
func NewPushNotificationProcessor() push.NotificationProcessor {
return push.NewProcessor()
}
// NewVoidPushNotificationProcessor creates a new void push notification processor
// This processor does not maintain any handlers and always returns nil for all operations
// It is used for RESP2 connections where push notifications are not available
// It can also be used to disable push notifications for RESP3 connections, where
// it will discard all push notifications without processing them
func NewVoidPushNotificationProcessor() push.NotificationProcessor {
return push.NewVoidProcessor()
}
package redis
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"weak"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/auth/streaming"
"github.com/redis/go-redis/v9/internal/hscan"
"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/maintnotifications"
"github.com/redis/go-redis/v9/push"
)
// Scanner internal/hscan.Scanner exposed interface.
type Scanner = hscan.Scanner
// Nil reply returned by Redis when key does not exist.
const Nil = proto.Nil
// String representations of special float values.
// Values are lowercase for consistency with Redis RESP2 protocol responses.
const (
NaN = internal.NaN // Not a Number
Inf = internal.Inf // Positive infinity
NInf = internal.NInf // Negative infinity
)
// SetLogger set custom log
// Use with VoidLogger to disable logging.
// If logger is nil, the call is ignored and the existing logger is kept.
func SetLogger(logger internal.Logging) {
if logger == nil {
return
}
internal.Logger.Store(logger)
}
// SetLogLevel sets the log level for the library.
func SetLogLevel(logLevel internal.LogLevelT) {
internal.LogLevel.Store(logLevel)
}
//------------------------------------------------------------------------------
type Hook interface {
DialHook(next DialHook) DialHook
ProcessHook(next ProcessHook) ProcessHook
ProcessPipelineHook(next ProcessPipelineHook) ProcessPipelineHook
}
type (
DialHook func(ctx context.Context, network, addr string) (net.Conn, error)
ProcessHook func(ctx context.Context, cmd Cmder) error
ProcessPipelineHook func(ctx context.Context, cmds []Cmder) error
)
type hooksMixin struct {
// hooksMu serializes writers (AddHook); readers never take it.
hooksMu *sync.Mutex
// state holds the immutable hook snapshot. Readers Load it lock-free;
// writers publish a replacement copy-on-write under hooksMu.
state *atomic.Pointer[hooksState]
}
// hooksState is an immutable snapshot of the hook configuration. Once stored
// in hooksMixin.state it is never mutated; AddHook builds a fresh copy.
type hooksState struct {
slice []Hook
initial hooks
current hooks
}
// rebuild recomputes current from initial + slice. It mutates the receiver, so
// it must only run on a state that has not yet been published.
func (s *hooksState) rebuild() {
s.initial.setDefaults()
s.current.dial = s.initial.dial
s.current.process = s.initial.process
s.current.pipeline = s.initial.pipeline
s.current.txPipeline = s.initial.txPipeline
for i := len(s.slice) - 1; i >= 0; i-- {
if wrapped := s.slice[i].DialHook(s.current.dial); wrapped != nil {
s.current.dial = wrapped
}
if wrapped := s.slice[i].ProcessHook(s.current.process); wrapped != nil {
s.current.process = wrapped
}
if wrapped := s.slice[i].ProcessPipelineHook(s.current.pipeline); wrapped != nil {
s.current.pipeline = wrapped
}
if wrapped := s.slice[i].ProcessPipelineHook(s.current.txPipeline); wrapped != nil {
s.current.txPipeline = wrapped
}
}
}
func (hs *hooksMixin) initHooks(hooks hooks) {
var slice []Hook
if hs.state != nil {
if old := hs.state.Load(); old != nil {
slice = old.slice
}
}
hs.hooksMu = new(sync.Mutex)
hs.state = new(atomic.Pointer[hooksState])
state := &hooksState{slice: slice, initial: hooks}
state.rebuild()
hs.state.Store(state)
}
type hooks struct {
dial DialHook
process ProcessHook
pipeline ProcessPipelineHook
txPipeline ProcessPipelineHook
}
func (h *hooks) setDefaults() {
if h.dial == nil {
h.dial = func(ctx context.Context, network, addr string) (net.Conn, error) { return nil, nil }
}
if h.process == nil {
h.process = func(ctx context.Context, cmd Cmder) error { return nil }
}
if h.pipeline == nil {
h.pipeline = func(ctx context.Context, cmds []Cmder) error { return nil }
}
if h.txPipeline == nil {
h.txPipeline = func(ctx context.Context, cmds []Cmder) error { return nil }
}
}
// AddHook is to add a hook to the queue.
// Hook is a function executed during network connection, command execution, and pipeline,
// it is a first-in-first-out stack queue (FIFO).
// You need to execute the next hook in each hook, unless you want to terminate the execution of the command.
// For example, you added hook-1, hook-2:
//
// client.AddHook(hook-1, hook-2)
//
// hook-1:
//
// func (Hook1) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
// return func(ctx context.Context, cmd Cmder) error {
// print("hook-1 start")
// next(ctx, cmd)
// print("hook-1 end")
// return nil
// }
// }
//
// hook-2:
//
// func (Hook2) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
// return func(ctx context.Context, cmd redis.Cmder) error {
// print("hook-2 start")
// next(ctx, cmd)
// print("hook-2 end")
// return nil
// }
// }
//
// The execution sequence is:
//
// hook-1 start -> hook-2 start -> exec redis cmd -> hook-2 end -> hook-1 end
//
// Please note: "next(ctx, cmd)" is very important, it will call the next hook,
// if "next(ctx, cmd)" is not executed, the redis command will not be executed.
//
// Contract. A hook runs inside the client's command machinery and must be
// well-behaved, or behavior is undefined:
// - Call next (as above) unless deliberately terminating the command.
// - Do not call Close or other client control methods from a hook — the hook
// runs on the goroutine Close waits for, so this deadlocks.
// - Do not panic. A panicking hook can crash the process or leave an operation
// unsettled; wrap fallible work and return an error instead.
// - Do not mutate client or connection state. A hook may observe and wrap
// errors (call cmd.SetErr) but must not reconfigure the client mid-flight.
func (hs *hooksMixin) AddHook(hook Hook) {
hs.hooksMu.Lock()
defer hs.hooksMu.Unlock()
old := hs.state.Load()
state := &hooksState{
slice: make([]Hook, len(old.slice)+1),
initial: old.initial,
}
copy(state.slice, old.slice)
state.slice[len(old.slice)] = hook
state.rebuild()
hs.state.Store(state)
}
func (hs *hooksMixin) clone() hooksMixin {
old := hs.state.Load()
l := len(old.slice)
state := &hooksState{
slice: old.slice[:l:l],
initial: old.initial,
current: old.current,
}
clone := hooksMixin{
hooksMu: new(sync.Mutex),
state: new(atomic.Pointer[hooksState]),
}
clone.state.Store(state)
return clone
}
func (hs *hooksMixin) withProcessHook(ctx context.Context, cmd Cmder, hook ProcessHook) error {
slice := hs.state.Load().slice
for i := len(slice) - 1; i >= 0; i-- {
if wrapped := slice[i].ProcessHook(hook); wrapped != nil {
hook = wrapped
}
}
return hook(ctx, cmd)
}
func (hs *hooksMixin) withProcessPipelineHook(
ctx context.Context, cmds []Cmder, hook ProcessPipelineHook,
) error {
slice := hs.state.Load().slice
for i := len(slice) - 1; i >= 0; i-- {
if wrapped := slice[i].ProcessPipelineHook(hook); wrapped != nil {
hook = wrapped
}
}
return hook(ctx, cmds)
}
func (hs *hooksMixin) dialHook(ctx context.Context, network, addr string) (net.Conn, error) {
return hs.state.Load().current.dial(ctx, network, addr)
}
// hookCount reports how many user hooks are installed. The autopipeliner
// arms its await() self-deadlock guard only when hooks exist, keeping the
// guard a single atomic load on hook-free clients.
func (hs *hooksMixin) hookCount() int {
return len(hs.state.Load().slice)
}
func (hs *hooksMixin) processHook(ctx context.Context, cmd Cmder) error {
return hs.state.Load().current.process(ctx, cmd)
}
func (hs *hooksMixin) processPipelineHook(ctx context.Context, cmds []Cmder) error {
return hs.state.Load().current.pipeline(ctx, cmds)
}
func (hs *hooksMixin) processTxPipelineHook(ctx context.Context, cmds []Cmder) error {
return hs.state.Load().current.txPipeline(ctx, cmds)
}
//------------------------------------------------------------------------------
// Stable identifiers for baseClient.onClose hooks. Each component that
// registers a close callback owns a dedicated id here so the set of known
// hooks is discoverable in one place and id collisions are caught at
// compile time. New ids should be added as additional constants.
const (
// onCloseHookIDSentinelFailover identifies the close callback installed
// by NewFailoverClient to tear down sentinel failover background work.
onCloseHookIDSentinelFailover = "sentinel-failover"
// onCloseHookIDAutoPipeline / onCloseHookIDAsyncAutoPipeline identify the
// close callbacks that cancel a cached autopipeliner's engine context when
// the SHARED pools close through any sharer (e.g. a WithTimeout clone falling
// through to baseClient.Close). The wrapper's own Close cancels its aps
// directly, but a clone shares only the pools + onCloseHooks, so without this
// the original wrapper's flusher/full-duplex goroutines would park on ap.ctx
// forever. Two ids because one Client caches at most a blocking and an async
// instance.
onCloseHookIDAutoPipeline = "autopipeline"
onCloseHookIDAsyncAutoPipeline = "autopipeline-async"
)
// onCloseHooks is a small registry of named close callbacks attached to a
// baseClient. Each callback is identified by a stable string id; registering
// the same id twice replaces the previous callback rather than chaining onto
// it. This guarantees the registry stays bounded regardless of how often a
// hook is (re)registered and avoids the unbounded closure chain that
// motivated issue #3772.
//
// Hooks are invoked in registration order. All hooks run regardless of
// individual errors; the first non-nil error is returned.
//
// A zero-value onCloseHooks is ready to use. It is safe for concurrent use.
// Clones of a baseClient share the same *onCloseHooks so registrations and
// close semantics are preserved across WithTimeout / WithContext / etc.
type onCloseHooks struct {
mu sync.Mutex
order []string
hooks map[string]func() error
// ran is set once run has taken its snapshot: the owner is closing (or
// closed), so a callback registered from here on would never be invoked.
// register reports that instead of silently accepting the registration.
ran bool
}
// register adds or replaces the callback associated with id. Re-registering
// an existing id overwrites the previous callback in place; new ids are
// appended to the invocation order.
//
// It returns false, and registers nothing, once run has already taken its
// snapshot: the owner is closing, so the callback could never fire. A caller
// that registers lazily against a possibly-closing owner (the cluster FD
// router's per-node evict hook) must treat false as "already closed" and do
// the callback's work itself, or it is left holding state the close will
// never clean up (cursor bugbot on #4002).
func (h *onCloseHooks) register(id string, fn func() error) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.ran {
return false
}
if h.hooks == nil {
h.hooks = make(map[string]func() error)
}
if _, exists := h.hooks[id]; !exists {
h.order = append(h.order, id)
}
h.hooks[id] = fn
return true
}
// unregister removes the callback associated with id, if any. Used by
// AutoPipeliner.Close to detach its per-engine close hook (see
// registerAutoPipelineCloseHook).
func (h *onCloseHooks) unregister(id string) {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.hooks[id]; !exists {
return
}
delete(h.hooks, id)
for i, x := range h.order {
if x == id {
h.order = append(h.order[:i], h.order[i+1:]...)
break
}
}
}
// run invokes all registered callbacks in REVERSE registration order (LIFO) and
// returns the first non-nil error encountered. All callbacks are executed even if an
// earlier one returns an error.
//
// LIFO is a dependency-teardown order: a hook registered LATER is a consumer of state
// that earlier registrations provide, so it must run FIRST. Concretely, the Sentinel
// failover teardown is registered at client construction (first), while an
// autopipeliner drain hook is registered lazily on first use (later). The drain needs
// the failover client alive to resolve the master address / dial a replacement
// connection for accepted-but-unsent work; running the Sentinel teardown first would
// set failover.closed, make MasterAddr return pool.ErrClosed, and fail replayable
// commands even though Redis and the pools are still up (a sibling pool-sharing clone
// triggering Close was enough). Draining consumers before tearing down discovery keeps
// those commands serviceable.
func (h *onCloseHooks) run() error {
if h == nil {
return nil
}
h.mu.Lock()
// From here on a late register would never be invoked; make it say so.
h.ran = true
fns := make([]func() error, 0, len(h.order))
for i := len(h.order) - 1; i >= 0; i-- {
if fn := h.hooks[h.order[i]]; fn != nil {
fns = append(fns, fn)
}
}
h.mu.Unlock()
var firstErr error
for _, fn := range fns {
if err := fn(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// pipelinePoolRef bundles the dedicated pipeline pool with the pool name its
// connections carry (pool.Conn.PoolName()), so poolForConn can route a
// connection back to the pool that owns it — e.g. streaming-credentials
// re-auth must close/account a failed pipeline connection against the
// pipeline pool, not connPool. Bundling keeps the pair consistent: it is set
// once in baseClient.pipelinePool before the client is visible, never mutated.
type pipelinePoolRef struct {
pool *pool.ConnPool
name string
}
type baseClient struct {
// apClosed flips when the shared pools begin closing; every wrapper and
// every clone SHARING those pools refuses to build a new autopipeliner
// from then on. A pointer: withTimeout/clone copy it, so the flag is one
// per pool-set, not one per wrapper. See baseClient.Close.
apClosed *atomic.Bool
opt *Options
optLock sync.RWMutex
connPool pool.Pooler
pubSubPool *pool.PubSubPool
// pipelinePool is the dedicated connection pool for pipelining
// operations (Pipeline, TxPipeline and autopipeline batches), created
// unconditionally at NewClient/NewFailoverClient — like pubSubPool — with
// pipeline-appropriate options (see pipelinePoolOptions): larger buffers,
// no pre-dialing, a small connection cap. It is pure burst capacity: an
// unused pipeline pool holds zero connections. PipelinePoolSize < 0 opts
// out; nil means pipelines use connPool (opt-out, and the internal
// Conn/Tx/Sentinel wrappers which never create one). The field is set
// before the client is visible to any goroutine and never mutated after,
// so plain reads are safe; WithTimeout clones copy the pointer and share
// the pool.
pipelinePool *pipelinePoolRef
hooksMixin
// onClose holds named callbacks invoked when the client is closed.
// Registering a new callback never removes previously registered ones;
// only re-registering the same id replaces the existing callback. This
// lets composing components (e.g. sentinel failover) add close logic
// safely without fear of overwriting each other and without building
// unbounded closure chains on repeated registration.
onClose *onCloseHooks
// Push notification processing
pushProcessor push.NotificationProcessor
// Maintenance notifications manager
maintNotificationsManager *maintnotifications.Manager
maintNotificationsManagerLock sync.RWMutex
// streamingCredentialsManager is used to manage streaming credentials
streamingCredentialsManager *streaming.Manager
// himport is the client-side registry of HIMPORT fieldsets, used to
// lazily replay HIMPORT PREPARE onto pooled connections (see himport.go).
// Shared by clones and by Conn instances derived from the same pool.
himport *himportRegistry
// csc is the shared client-side cache; nil when CSC is disabled.
csc Cache
// cscKeyPrefix namespaces a shared cache by DB and fixed authentication
// identity. It is computed once during attachment and copied with the cache.
cscKeyPrefix string
// Refresh-on-invalidate + reader-miss coalescing (nil unless enabled).
// cscRefreshQueue IS copied by clone() (a clone signals demand on the owner's
// queue, see clone()); cscRefreshHandle is owner-only (it drives the goroutine
// that owns/stops the queue) and stays nil in a clone, so a clone never stops it.
cscRefreshQueue *cscRefreshQueue
cscRefreshHandle *cscRevalidateHandle
// cscClientWeak points (weakly) back at the canonical *Client wrapper, so
// the CSC push-handler adapter can close through Client.Close — which also
// stops the cached autopipeliners — instead of baseClient.Close. WEAK on
// purpose: a strong back-reference would make the wrapper reachable from
// the drainer goroutine and the drop-without-Close cleanup
// (cscRegisterCleanups) could never fire.
cscClientWeak weak.Pointer[Client]
// cscMissCoalescer is atomic: a miss (processCached) races Close, which
// Swaps it to nil — readers Load once (a double-load could call fetch on a
// nil receiver) and exactly one closer wins the Swap.
cscMissCoalescer atomic.Pointer[cscMissCoalescer]
// allowClientTracking exempts a client from the CLIENT TRACKING guard (see
// process and generalProcessPipeline). Set only on initConn's internal conn
// wrapper, whose init pipeline legitimately issues CLIENT TRACKING ON;
// never set on user-visible clients.
allowClientTracking bool
// The following are OWNER-ONLY and NOT copied by clone(): derived clients
// (Conn/WithTimeout) share the cache but must not stop the owner's
// goroutines or flush its cache on their own Close.
// cscOwnsCache is true only when this client constructed its LocalCache (not
// an injected/shared one); it gates the defensive flush on drainer stop.
cscOwnsCache bool
// cscDrainHandle is the background drainer handle (nil when none). Held
// on the client, not a global registry, so an un-Closed client stays
// GC-collectible and a runtime.AddCleanup net can stop the goroutine. Its
// presence also identifies the owner (the only client that runs the drainer
// and thus the one that deregisters cscPoolHook).
cscDrainHandle *cscDrainHandle
// cscPoolHook is the evict-on-remove pool hook (nil when CSC is off). Unlike
// the owner-only fields above it IS copied by clone(): a clone reads it in
// processCached to attribute fetches to the shared hook. Only the owner (the
// one with cscDrainHandle) deregisters it when the drainer exits.
cscPoolHook pool.PoolHook
// cscActive is allocated only after CSC attaches successfully and becomes
// false once the drainer stops (owner Close, GC cleanup, or damping). It is
// shared with derived clients so they initialize borrowed pool connections
// with tracking only while the parent's CSC is actually operational.
cscActive *atomic.Bool
}
func (c *baseClient) clone() *baseClient {
c.maintNotificationsManagerLock.RLock()
maintNotificationsManager := c.maintNotificationsManager
c.maintNotificationsManagerLock.RUnlock()
clone := &baseClient{
apClosed: c.apClosed,
opt: c.opt,
connPool: c.connPool,
// Pointer copy on purpose: the clone shares the parent's already-created
// pipeline pool over the one shared pool set, rather than building its own.
pipelinePool: c.pipelinePool,
pubSubPool: c.pubSubPool,
onClose: c.onClose,
pushProcessor: c.pushProcessor,
maintNotificationsManager: maintNotificationsManager,
streamingCredentialsManager: c.streamingCredentialsManager,
himport: c.himport,
csc: c.csc,
// cscPoolHook and cscActive travel with the cache (read in processCached);
// the owner-only fields — cscDrainHandle, cscOwnsCache — do not, so a clone's
// Close never tears down the owner's resources.
cscPoolHook: c.cscPoolHook,
cscActive: c.cscActive,
cscKeyPrefix: c.cscKeyPrefix,
// cscRefreshQueue is SHARED (pointer copy), like cscPoolHook: processCached
// calls signalDemand on it so a miss for a key still in the refresher's
// window flushes that window early. Without the share a clone's field is
// nil and signalDemand no-ops, so the clone's miss waits the full window
// (#3965 F4). Lifecycle stays owner-only: signalDemand is a nil-safe,
// non-blocking buffered send that does no I/O, and teardown keys off the
// owner-only cscRefreshHandle/cscDrainHandle (both nil in a clone), so a
// clone only SIGNALS the queue and never stops it — even a clone outliving
// the owner's Close just sends into a buffered channel whose worker has
// exited, which is harmless.
cscRefreshQueue: c.cscRefreshQueue,
}
// The miss coalescer travels with the cache (an atomic.Pointer cannot appear
// in the literal above); a clone shares the OWNER's coalescer, whose
// lifecycle stays owner-only — a clone's Close does not stop it.
clone.cscMissCoalescer.Store(c.cscMissCoalescer.Load())
return clone
}
// cloneOpt clones c.opt while holding optLock to prevent races with initConn
// which writes to MaintNotificationsConfig.Mode under the same lock.
func (c *baseClient) cloneOpt() *Options {
c.optLock.RLock()
clone := c.opt.clone()
c.optLock.RUnlock()
return clone
}
func (c *baseClient) withTimeout(timeout time.Duration) *baseClient {
opt := c.cloneOpt()
opt.ReadTimeout = timeout
opt.WriteTimeout = timeout
clone := c.clone()
clone.opt = opt
// Do not route the clone's misses through the OWNER's coalescer when the
// timeouts diverge: the shared engine's writer/reader/acquire budgets and
// backstops all read the owner's options, so a coalesced miss would honor
// the owner's deadlines, not the clone's — defeating WithTimeout's whole
// point. The clone still serves cache hits and still caches its uncached
// fetches (the pre-coalescer CSC path); only miss COALESCING is bypassed.
if timeout != c.opt.ReadTimeout || timeout != c.opt.WriteTimeout {
clone.cscMissCoalescer.Store(nil)
}
// cscRefreshQueue is NOT dropped here even when timeouts diverge: unlike the
// coalescer it does no I/O on this client's behalf — signalDemand is a
// non-blocking buffered send — so there is no deadline to inherit, and the
// refresh work itself runs on the owner's connection under the owner's options
// regardless of who nudged it.
return clone
}
func (c *baseClient) String() string {
return fmt.Sprintf("Redis<%s db:%d>", c.getAddr(), c.opt.DB)
}
func (c *baseClient) getConn(ctx context.Context) (*pool.Conn, error) {
cn, _, err := c.getConnLimited(ctx)
return cn, err
}
// getConnLimited is getConn with the Limiter admission split from the dial: the
// `limited` return reports that Limiter.Allow REJECTED the operation (before any
// dial), so a caller can tell an explicit admission denial apart from a
// dial/transport failure. getConn wraps it for the common case, leaving every
// other caller unchanged. Allow is reported (ReportResult) only for dial results,
// never for admission denials — matching the original getConn.
func (c *baseClient) getConnLimited(ctx context.Context) (cn *pool.Conn, limited bool, err error) {
if c.opt.Limiter != nil {
if err := c.opt.Limiter.Allow(); err != nil {
return nil, true, err
}
}
cn, err = c._getConn(ctx)
if err != nil {
if c.opt.Limiter != nil {
c.opt.Limiter.ReportResult(err)
}
return nil, false, err
}
return cn, false, nil
}
func (c *baseClient) _getConn(ctx context.Context) (*pool.Conn, error) {
cn, err := c.connPool.Get(ctx)
if err != nil {
return nil, err
}
if err := c.initPooledConn(ctx, c.connPool, cn); err != nil {
return nil, err
}
return cn, nil
}
// initPooledConn brings a conn freshly obtained from p to a usable state: it
// runs the connection handshake if needed, records the connection-create-time
// metric, and re-acquires the conn after initConn parks it IDLE. On failure
// the conn is Removed from p (never leaked) and the error is unwrapped to the
// caller-visible cause. Shared by the main-pool path (_getConn) and the
// dedicated pipeline-pool path (withPipelineConn) so the two cannot drift.
func (c *baseClient) initPooledConn(ctx context.Context, p pool.Pooler, cn *pool.Conn) error {
if cn.IsInited() {
return nil
}
if err := c.initConn(ctx, cn); err != nil {
p.Remove(ctx, cn, err)
if unwrapped := errors.Unwrap(err); unwrapped != nil {
return unwrapped
}
return err
}
if dialStartNs := cn.GetDialStartNs(); dialStartNs > 0 {
if cb := pool.GetMetricConnectionCreateTimeCallback(); cb != nil {
duration := time.Duration(time.Now().UnixNano() - dialStartNs)
cb(ctx, duration, cn)
}
}
// initConn will transition to IDLE state, so we need to acquire it
// before returning it to the user.
if !cn.TryAcquire() {
err := fmt.Errorf("redis: connection is not usable")
// Remove rather than abandon: an unacquirable conn left outside the
// pool's accounting would leak its slot.
p.Remove(ctx, cn, err)
return err
}
return nil
}
// loadPipelinePool returns the pipeline-pool ref, or nil when pipelines use
// the main pool (PipelinePoolSize < 0, or an internal wrapper client).
func (c *baseClient) loadPipelinePool() *pipelinePoolRef {
return c.pipelinePool
}
// getPipelinePool returns the dedicated pipeline pool as a pool.Pooler, or a
// true nil interface when there is none. Callers must use this rather than
// wrapping loadPipelinePool().pool themselves where a pool.Pooler is expected:
// a typed-nil *pool.ConnPool inside the interface would defeat `!= nil` checks.
func (c *baseClient) getPipelinePool() pool.Pooler {
if ref := c.loadPipelinePool(); ref != nil {
return ref.pool
}
return nil
}
// isPipelinePoolConn reports whether cn was dialed by the dedicated pipeline
// pool, identified by the pool name its connections carry.
func (c *baseClient) isPipelinePoolConn(cn *pool.Conn) bool {
ref := c.loadPipelinePool()
return ref != nil && cn.PoolName() == ref.name
}
// poolForConn returns the pool that owns cn — the dedicated pipeline pool when
// cn was dialed there, otherwise the main pool. Re-auth close/accounting must
// target the owning pool so a failed pipeline connection is removed from the
// pipeline pool's books, not the main pool's.
func (c *baseClient) poolForConn(cn *pool.Conn) pool.Pooler {
if ref := c.loadPipelinePool(); ref != nil && cn.PoolName() == ref.name {
return ref.pool
}
return c.connPool
}
// pipelinePoolOptions resolves the Options the dedicated pipeline pool is
// built with. Pure function of the client options, so the resolution rules are
// testable without dialing anything:
//
// - Buffers: the explicit pipeline buffer size when set; otherwise the
// LARGER of the regular buffer size and DefaultPipelineBufferSize.
// Pipelines move whole batches per round trip, so their connections earn
// bigger buffers than regular per-command traffic (measured: throughput
// plateaus around 64 KiB and very large buffers can regress it). The
// RESP3 minimum clamp applies as on the main pool.
// - PoolSize: PipelinePoolSize when set, DefaultPipelinePoolSize otherwise.
// - MinIdleConns: always 0. The pipeline pool is burst capacity — its
// connections dial on demand and there is nothing to keep warm before
// the first pipeline runs. Without this the clone would inherit the
// main pool's MinIdleConns and pre-dial that many pipeline connections
// at creation, silently doubling a client's idle footprint.
func pipelinePoolOptions(opt *Options) *Options {
pipelineOpt := opt.clone()
if opt.PipelineReadBufferSize > 0 {
pipelineOpt.ReadBufferSize = opt.PipelineReadBufferSize
} else if pipelineOpt.ReadBufferSize < DefaultPipelineBufferSize {
pipelineOpt.ReadBufferSize = DefaultPipelineBufferSize
}
// Same clamp Options.init applies to the main pool: RESP3 push parsing
// needs a minimum read buffer, and a tiny pipeline reader would break
// push-notification handling on pipeline conns.
if pipelineOpt.Protocol == 3 && pipelineOpt.ReadBufferSize < proto.MinRESP3ReadBufferSize {
pipelineOpt.ReadBufferSize = proto.MinRESP3ReadBufferSize
}
if opt.PipelineWriteBufferSize > 0 {
pipelineOpt.WriteBufferSize = opt.PipelineWriteBufferSize
} else if pipelineOpt.WriteBufferSize < DefaultPipelineBufferSize {
pipelineOpt.WriteBufferSize = DefaultPipelineBufferSize
}
if opt.PipelinePoolSize > 0 {
pipelineOpt.PoolSize = opt.PipelinePoolSize
} else {
pipelineOpt.PoolSize = DefaultPipelinePoolSize
}
pipelineOpt.MinIdleConns = 0
// Re-resolve MaxConcurrentDials for the pipeline pool. Options.init capped it to
// the MAIN pool size, so a pipeline pool larger than the main pool (e.g.
// PoolSize:1 with the default 10-slot pipeline pool) would otherwise dial one
// connection at a time — an initial burst serializes slow dials and can hit
// PoolTimeout or spill despite idle pipeline slots. If the caller set an EXPLICIT
// dial cap, honor it (bounded by the pipeline pool size); otherwise default to
// the pipeline pool size. Keyed on maxConcurrentDialsSet, NOT equality with
// PoolSize, so an explicit MaxConcurrentDials==PoolSize (e.g. PoolSize:1,
// MaxConcurrentDials:1) is not mistaken for the default and silently widened.
if opt.maxConcurrentDialsSet {
if pipelineOpt.MaxConcurrentDials > pipelineOpt.PoolSize {
pipelineOpt.MaxConcurrentDials = pipelineOpt.PoolSize
}
} else {
pipelineOpt.MaxConcurrentDials = pipelineOpt.PoolSize
}
// Do NOT inherit MaxActiveConns. Inheriting it verbatim would roughly DOUBLE
// the client's socket ceiling (e.g. MaxActiveConns 100 -> up to ~200 across
// the two pools). Reset to 0 so the pipeline pool is bounded only by its own
// PoolSize: the effective ceiling becomes MaxActiveConns + PipelinePoolSize —
// a small, bounded addition rather than a doubling, and the main pool a burst
// spills to still enforces MaxActiveConns. (Consequence: with MaxActiveConns
// 0 the pipeline pool never itself returns ErrPoolExhausted; a burst wider
// than PoolSize spills on ErrPoolTimeout instead — see withPipelineConn.)
pipelineOpt.MaxActiveConns = 0
// Spill, don't queue: withPipelineConn acquires from the pipeline pool with
// TryGet, which never waits out PoolTimeout — a saturated pool returns
// ErrPoolTryFull AT ONCE and the caller spills to the main pool immediately
// (see withPipelineConn, DefaultPipelinePoolTimeout). This value therefore
// has no live effect on that acquisition; it only bounds what PoolTimeout
// ends up stored on the pipeline pool's Options, capped here so a caller
// tuned to a short PoolTimeout is not silently widened to the (main)
// default (tens of seconds), which the clone would otherwise inherit.
pipelineOpt.PoolTimeout = DefaultPipelinePoolTimeout
if opt.PoolTimeout > 0 && opt.PoolTimeout < DefaultPipelinePoolTimeout {
pipelineOpt.PoolTimeout = opt.PoolTimeout
}
return pipelineOpt
}
// buildPipelinePool constructs the dedicated pipeline pool from the client's
// options as resolved by pipelinePoolOptions. Shared by the NewClient and
// NewFailoverClient creation paths so they cannot drift.
func (c *baseClient) buildPipelinePool(poolName string) (*pipelinePoolRef, error) {
p, err := newConnPool(pipelinePoolOptions(c.opt), c.dialHook, poolName)
if err != nil {
return nil, err
}
return &pipelinePoolRef{pool: p, name: poolName}, nil
}
func (c *baseClient) reAuthConnection() func(poolCn *pool.Conn, credentials auth.Credentials) error {
return func(poolCn *pool.Conn, credentials auth.Credentials) error {
var err error
username, password := credentials.BasicAuth()
// Use background context - timeout is handled by ReadTimeout in WithReader/WithWriter
ctx := context.Background()
connPool := pool.NewSingleConnPool(c.poolForConn(poolCn), poolCn)
// Pass hooks so that reauth commands are recorded/traced; share the
// HIMPORT registry for the same reason as in initConn.
cn := newConn(c.opt, connPool, &c.hooksMixin, c.himport)
if username != "" {
err = cn.AuthACL(ctx, username, password).Err()
} else {
err = cn.Auth(ctx, password).Err()
}
return err
}
}
func (c *baseClient) onAuthenticationErr() func(poolCn *pool.Conn, err error) {
return func(poolCn *pool.Conn, err error) {
if err != nil {
if isBadConn(err, false, c.opt.Addr) {
// Close the connection to force a reconnection.
// Re-auth happens on connections that were idle in the pool (the pool hook
// waits for IDLE state before transitioning to UNUSABLE for re-auth).
// From metrics perspective, the connection was never "used" by a client.
// Note: Using context.Background() as this callback doesn't have access to caller's context.
err := c.poolForConn(poolCn).CloseConn(context.Background(), poolCn, pool.CloseReasonAuthError, pool.MetricStateIdle)
if err != nil {
internal.Logger.Printf(context.Background(), "redis: failed to close connection: %v", err)
// try to close the network connection directly
// so that no resource is leaked
err := poolCn.Close()
if err != nil {
internal.Logger.Printf(context.Background(), "redis: failed to close network connection: %v", err)
}
}
}
internal.Logger.Printf(context.Background(), "redis: re-authentication failed: %v", err)
}
}
}
// resolveCredentials returns the username/password to authenticate with, using
// the non-streaming credential sources in precedence order:
// CredentialsProviderContext, then CredentialsProvider, then the static
// Username/Password fields. The StreamingCredentialsProvider path is handled
// separately by initConn (it requires per-connection listener wiring) and is
// intentionally not covered here. Returns empty strings when no credentials
// are configured.
func (opt *Options) resolveCredentials(ctx context.Context) (username, password string, err error) {
switch {
case opt.CredentialsProviderContext != nil:
return opt.CredentialsProviderContext(ctx)
case opt.CredentialsProvider != nil:
username, password = opt.CredentialsProvider()
case opt.Username != "" || opt.Password != "":
username, password = opt.Username, opt.Password
}
return username, password, nil
}
func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error {
// This function is called in two scenarios:
// 1. First-time init: Connection is in CREATED state (from pool.Get())
// - We need to transition CREATED → INITIALIZING and do the initialization
// - If another goroutine is already initializing, we WAIT for it to finish
// 2. Re-initialization: Connection is in INITIALIZING state (from SetNetConnAndInitConn())
// - We're already in INITIALIZING, so just proceed with initialization
currentState := cn.GetStateMachine().GetState()
// Fast path: Check if already initialized (IDLE or IN_USE)
if currentState == pool.StateIdle || currentState == pool.StateInUse {
return nil
}
// If in CREATED state, try to transition to INITIALIZING
if currentState == pool.StateCreated {
finalState, err := cn.GetStateMachine().TryTransition([]pool.ConnState{pool.StateCreated}, pool.StateInitializing)
if err != nil {
// Another goroutine is initializing or connection is in unexpected state
// Check what state we're in now
if finalState == pool.StateIdle || finalState == pool.StateInUse {
// Already initialized by another goroutine
return nil
}
if finalState == pool.StateInitializing {
// Another goroutine is initializing - WAIT for it to complete
// Use a context with timeout = min(remaining command timeout, DialTimeout)
// This prevents waiting too long while respecting the caller's deadline
var waitCtx context.Context
var cancel context.CancelFunc
dialTimeout := c.opt.DialTimeout
if cmdDeadline, hasCmdDeadline := ctx.Deadline(); hasCmdDeadline {
// Calculate remaining time until command deadline
remainingTime := time.Until(cmdDeadline)
// Use the minimum of remaining time and DialTimeout
if remainingTime < dialTimeout {
// Command deadline is sooner, use it
waitCtx = ctx
} else {
// DialTimeout is shorter, cap the wait at DialTimeout
waitCtx, cancel = context.WithTimeout(ctx, dialTimeout)
}
} else {
// No command deadline, use DialTimeout to prevent waiting indefinitely
waitCtx, cancel = context.WithTimeout(ctx, dialTimeout)
}
if cancel != nil {
defer cancel()
}
finalState, err := cn.GetStateMachine().AwaitAndTransition(
waitCtx,
[]pool.ConnState{pool.StateIdle, pool.StateInUse},
pool.StateIdle, // Target is IDLE (but we're already there, so this is a no-op)
)
if err != nil {
return err
}
// Verify we're now initialized
if finalState == pool.StateIdle || finalState == pool.StateInUse {
return nil
}
// Unexpected state after waiting
return fmt.Errorf("connection in unexpected state after initialization: %s", finalState)
}
// Unexpected state (CLOSED, UNUSABLE, etc.)
return err
}
}
// At this point, we're in INITIALIZING state and we own the initialization
// If we fail, we must transition to CLOSED
var initErr error
connPool := pool.NewSingleConnPool(c.connPool, cn)
// The handshake Conn (handed to OnConnect) must share the client's
// HIMPORT registry: a private registry restarts versions at 1, so an
// OnConnect prepare would mark the pooled connection with a version
// number that collides with the client registry's and silently skips
// the replay of a different fieldset definition.
conn := newConn(c.opt, connPool, &c.hooksMixin, c.himport)
// The internal wrapper does not serve cached reads, but it needs the
// successful-attachment signal both to issue CLIENT TRACKING during init
// and to guard the user-visible OnConnect callback below.
conn.baseClient.cscActive = c.cscActive
// This internal conn's init pipeline issues CLIENT TRACKING ON itself;
// exempt it from the guard that blocks user-issued CLIENT TRACKING. Setting
// the field after newConn is safe: initHooks bound the pipeline hook as a
// method value on the addressable baseClient, so the guard reads the
// updated field.
conn.baseClient.allowClientTracking = true
username, password := "", ""
if c.opt.StreamingCredentialsProvider != nil {
credListener, initErr := c.streamingCredentialsManager.Listener(
cn,
c.reAuthConnection(),
c.onAuthenticationErr(),
)
if initErr != nil {
cn.GetStateMachine().Transition(pool.StateClosed)
return fmt.Errorf("failed to create credentials listener: %w", initErr)
}
credentials, unsubscribeFromCredentialsProvider, initErr := c.opt.StreamingCredentialsProvider.
Subscribe(credListener)
if initErr != nil {
cn.GetStateMachine().Transition(pool.StateClosed)
return fmt.Errorf("failed to subscribe to streaming credentials: %w", initErr)
}
// Per-connection unsubscribe is attached to the connection itself so it
// runs when this specific connection is closed. Do not register it on
// c.onClose: initConn runs for every (re)initialized connection, and
// attaching per-connection state to the shared baseClient registry would
// either leak entries (one per connection id, never trimmed) or — with
// the pre-fix wrappedOnClose approach — build an unbounded closure chain
// retaining every prior connection's unsubscribe (see issue #3772).
//
// Note: pool.Conn.SetOnClose OVERWRITES any prior callback (see the
// doc on that method). That is safe here because the streaming
// credentials Manager deduplicates listeners by connection id, so a
// second initConn on the same cn re-Subscribes the SAME listener and
// the returned unsubscribe is equivalent to the one already installed.
// Any future code path that could hand out a distinct unsubscribe on
// re-initialization must first invoke the existing one to avoid
// orphaning the old subscription on the credentials provider.
cn.SetOnClose(unsubscribeFromCredentialsProvider)
username, password = credentials.BasicAuth()
} else {
username, password, initErr = c.opt.resolveCredentials(ctx)
if initErr != nil {
cn.GetStateMachine().Transition(pool.StateClosed)
return fmt.Errorf("failed to resolve credentials: %w", initErr)
}
}
// for redis-server versions that do not support the HELLO command,
// RESP2 will continue to be used.
// helloOK tracks whether HELLO succeeded. If it did not, the connection
// falls back to RESP2 regardless of c.opt.Protocol, and features that
// require RESP3 (e.g. maintenance notifications) must be skipped.
helloOK := false
// For redis-server versions that do not support HELLO, RESP2 continues to
// be used. Remember that negotiated fallback: configured Protocol remains 3,
// but CSC must not serve without RESP3 invalidations.
helloFallbackToRESP2 := false
if initErr = conn.Hello(ctx, c.opt.Protocol, username, password, c.opt.ClientName).Err(); initErr == nil {
// Authentication successful with HELLO command
helloOK = true
} else if !isRedisError(initErr) {
// When the server responds with the RESP protocol and the result is not a normal
// execution result of the HELLO command, we consider it to be an indication that
// the server does not support the HELLO command.
// The server may be a redis-server that does not support the HELLO command,
// or it could be DragonflyDB or a third-party redis-proxy. They all respond
// with different error string results for unsupported commands, making it
// difficult to rely on error strings to determine all results.
cn.GetStateMachine().Transition(pool.StateClosed)
return initErr
} else {
helloFallbackToRESP2 = c.opt.Protocol == 3
if password != "" {
// Try legacy AUTH command if HELLO failed.
if username != "" {
initErr = conn.AuthACL(ctx, username, password).Err()
} else {
initErr = conn.Auth(ctx, password).Err()
}
if initErr != nil {
cn.GetStateMachine().Transition(pool.StateClosed)
return fmt.Errorf("failed to authenticate: %w", initErr)
}
}
}
if helloFallbackToRESP2 {
c.disableCSCServing(ctx, "HELLO 3 was rejected and the connection negotiated RESP2")
}
// trackingEnabled reports whether THIS pool connection must issue
// CLIENT TRACKING ON during init. True when CSC (SharedTracking) is enabled:
// the shared cache is fed by per-connection tracking + the background
// drainer. Once CSC serving stops (owner Close, GC cleanup, or drainer
// damping), new and re-inited conns skip tracking — nothing consumes the
// pushes into the cache anymore.
// Pipeline-pool connections are excluded from CLIENT TRACKING: pipelined
// commands never consult or populate the client-side cache (only the
// single-command cached path on main-pool connections does), so tracking
// reads made on pipeline connections would only grow the server's tracking
// table and produce invalidation pushes for keys the cache does not hold.
trackingEnabled := !helloFallbackToRESP2 && !cn.IsPubSub() && c.cscTrackingRequested() &&
!c.isPipelinePoolConn(cn)
if trackingEnabled && c.cscConnInitGen(cn.GetID()) == 0 {
// First initialization establishes generation 1. Reinitialization
// already bumped and evicted through onCscReinit before replacing the
// socket, so it must not bump a second time here.
c.cscEvictOwnedEntries(cn.GetID())
}
var trackingCmd *StatusCmd
initCmds, initErr := conn.Pipelined(ctx, func(pipe Pipeliner) error {
if c.opt.DB > 0 {
pipe.Select(ctx, c.opt.DB)
}
if c.opt.readOnly {
pipe.ReadOnly(ctx)
}
if c.opt.ClientName != "" {
pipe.ClientSetName(ctx, c.opt.ClientName)
}
if trackingEnabled {
// Must run before any cacheable command is issued on this conn.
trackingCmd = pipe.ClientTrackingOn(ctx, nil)
}
return nil
})
// The exemption is init-only. OnConnect is user code and must go through
// the same CSC connection-state guard as every other public command path.
conn.baseClient.allowClientTracking = false
trackingRejected := trackingCmd != nil && isRedisError(trackingCmd.Err())
for _, cmd := range initCmds {
if cmd != trackingCmd && cmd.Err() != nil {
trackingRejected = false
break
}
}
if trackingRejected {
// A server-side rejection means tracking is unavailable, but the
// connection and the preceding init commands are still usable. Disable
// CSC globally and continue without caching. Transport and protocol
// failures still take the normal connection-failure path below.
c.disableCSCServing(ctx, fmt.Sprintf("CLIENT TRACKING ON was rejected: %v", trackingCmd.Err()))
c.cscForgetConn(cn.GetID())
trackingEnabled = false
initErr = nil
}
if initErr != nil {
if trackingEnabled {
// cscEvictOwnedEntries above bumped this conn's init generation; a
// failed init never serves, and the pubsub path has no OnRemove
// hook (and the close hook below is not yet installed), so drop
// the entry here to keep the map bounded to live conns.
c.cscForgetConn(cn.GetID())
}
cn.GetStateMachine().Transition(pool.StateClosed)
return fmt.Errorf("failed to initialize connection options: %w", initErr)
}
if trackingEnabled {
// Evict this conn's entries on any close (incl. the ConnMaxLifetime/idle
// path that bypasses the OnRemove hook), since the server drops its
// tracking table on close.
c.cscInstallConnCloseHook(cn)
// A handoff replaces the socket before initConn runs. Bump and evict at
// the pre-swap boundary so fulfillCached cannot publish an old-socket
// reply during that gap.
c.cscInstallConnReinitHook(cn)
}
// Enable maintnotifications if maintnotifications are configured
c.optLock.RLock()
maintNotifEnabled := c.opt.MaintNotificationsConfig != nil && c.opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled
protocol := c.opt.Protocol
var endpointType maintnotifications.EndpointType
var maintNotifMode maintnotifications.Mode
if maintNotifEnabled {
endpointType = c.opt.MaintNotificationsConfig.EndpointType
maintNotifMode = c.opt.MaintNotificationsConfig.Mode
}
c.optLock.RUnlock()
// Maintenance notifications require RESP3 push frames. If HELLO failed
// and the connection fell back to RESP2, there is no point in sending
// CLIENT MAINT_NOTIFICATIONS: the server either rejects it (making the
// error misleading) or accepts it silently, leaving the client unable
// to receive any notifications. Decide based on the actual negotiated
// protocol rather than the requested one.
if maintNotifEnabled && protocol == 3 && !helloOK {
if maintNotifMode == maintnotifications.ModeEnabled {
// Explicitly requested - fail fast with a clear reason.
cn.GetStateMachine().Transition(pool.StateClosed)
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorCallback(ctx, "HANDSHAKE_FAILED", cn, "HANDSHAKE_FAILED", true, 0)
}
return fmt.Errorf("failed to enable maintnotifications: server does not support RESP3 (HELLO command failed)")
}
// auto/other modes: silently disable maintnotifications for this client.
c.optLock.Lock()
c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeDisabled
c.optLock.Unlock()
if err := c.disableMaintNotificationsUpgrades(); err != nil {
internal.Logger.Printf(ctx, "failed to disable maintnotifications in auto mode: %v", err)
}
maintNotifEnabled = false
}
var maintNotifHandshakeErr error
if maintNotifEnabled && protocol == 3 {
// Hold the manager read lock across the handshake and tracking so a
// concurrent downgrade cannot remove pool-level listeners before a
// successfully enabled connection is tracked for retirement.
c.maintNotificationsManagerLock.RLock()
manager := c.maintNotificationsManager
maintNotifHandshakeErr = conn.ClientMaintNotifications(
ctx,
true,
endpointType.String(),
).Err()
// A successful handshake enables maintnotifications for this connection,
// but must not promote ModeAuto to ModeEnabled. ModeEnabled is the
// explicit fail-closed policy; ModeAuto must remain able to downgrade if a
// later reconnect/failover reaches an endpoint that rejects the command.
if maintNotifHandshakeErr == nil && manager != nil {
manager.TrackMaintNotificationsConn(cn)
}
c.maintNotificationsManagerLock.RUnlock()
if maintNotifHandshakeErr != nil {
if !isRedisError(maintNotifHandshakeErr) {
// if not redis error, fail the connection
cn.GetStateMachine().Transition(pool.StateClosed)
return maintNotifHandshakeErr
}
c.optLock.Lock()
// handshake failed - check and modify config atomically
switch c.opt.MaintNotificationsConfig.Mode {
case maintnotifications.ModeEnabled:
// enabled mode, fail the connection
c.optLock.Unlock()
cn.GetStateMachine().Transition(pool.StateClosed)
// Record handshake failure metric
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorCallback(ctx, "HANDSHAKE_FAILED", cn, "HANDSHAKE_FAILED", true, 0)
}
return fmt.Errorf("failed to enable maintnotifications: %w", maintNotifHandshakeErr)
default: // will handle auto and any other
// Disabling logging here as it's too noisy.
// TODO: Enable when we have a better logging solution for log levels
// internal.Logger.Printf(ctx, "auto mode fallback: maintnotifications disabled due to handshake error: %v", maintNotifHandshakeErr)
c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeDisabled
c.optLock.Unlock()
// auto mode, disable maintnotifications and continue
if initErr := c.disableMaintNotificationsUpgrades(); initErr != nil {
// Log error but continue - auto mode should be resilient
internal.Logger.Printf(ctx, "failed to disable maintnotifications in auto mode: %v", initErr)
}
}
}
}
if !c.opt.DisableIdentity && !c.opt.DisableIndentity {
libName := ""
libVer := Version()
if c.opt.IdentitySuffix != "" {
libName = c.opt.IdentitySuffix
}
p := conn.Pipeline()
p.ClientSetInfo(ctx, WithLibraryName(libName))
p.ClientSetInfo(ctx, WithLibraryVersion(libVer))
// Handle network errors (e.g. timeouts) in CLIENT SETINFO to avoid
// out of order responses later on.
if _, initErr = p.Exec(ctx); initErr != nil && !isRedisError(initErr) {
cn.GetStateMachine().Transition(pool.StateClosed)
return initErr
}
}
// Set the connection initialization function for potential reconnections
// This must be set before transitioning to IDLE so that handoff/reauth can use it
cn.SetInitConnFunc(c.createInitConnFunc())
// Initialization succeeded - transition to IDLE state
// This marks the connection as initialized and ready for use
// NOTE: The connection is still owned by the calling goroutine at this point
// and won't be available to other goroutines until it's Put() back into the pool
cn.GetStateMachine().Transition(pool.StateIdle)
// Call OnConnect hook if configured
// The connection is in IDLE state but still owned by this goroutine
// If OnConnect needs to send commands, it can use the connection safely
if c.opt.OnConnect != nil {
if initErr = c.opt.OnConnect(ctx, conn); initErr != nil {
// OnConnect failed - transition to closed
cn.GetStateMachine().Transition(pool.StateClosed)
return initErr
}
}
return nil
}
// errConnUnusable marks a connection whose reply stream is desynchronized (a
// partial push-frame drain, or a panic mid-serialization) but whose error is not a
// transport bad-conn error. releaseConnToPool removes such a conn instead of
// returning it to the pool. Wrap the real cause with %w so callers still see it.
var errConnUnusable = errors.New("redis: connection unusable (reply stream desynchronized)")
func (c *baseClient) releaseConn(ctx context.Context, cn *pool.Conn, err error) {
if c.opt.Limiter != nil {
c.opt.Limiter.ReportResult(err)
}
c.releaseConnToPool(ctx, c.connPool, cn, err)
}
// releaseConnToPool returns a conn to p after a command or pipeline ran on
// it: bad conns are Removed, pending push notifications are drained (a
// mid-frame drain failure also Removes — the reply stream may be
// desynchronized), and a client-side-cache post-read probe is requested when
// tracking is on. Limiter accounting stays with the callers, whose shapes
// differ. Shared by releaseConn and withPipelineConn so the two cannot drift.
func (c *baseClient) releaseConnToPool(ctx context.Context, p pool.Pooler, cn *pool.Conn, err error) {
// errConnUnusable is wrapped around errors that leave the connection's reply
// stream desynchronized even though they are not transport (bad-conn) errors:
// a push-notification drain that consumed part of a RESP3 frame, or a panic
// while serializing a command's args mid-write. Such a conn MUST be removed —
// reusing it would decode leftover bytes as the next caller's replies.
if isBadConn(err, false, c.opt.Addr) || errors.Is(err, errConnUnusable) {
p.Remove(ctx, cn, err)
return
}
// process any pending push notifications before returning the connection to the pool
if err := c.processPushNotifications(ctx, cn); err != nil {
internal.Logger.Printf(ctx, "push: error processing pending notifications before releasing connection: %v", err)
// Any drain error may leave the reply stream desynchronized: a mid-frame
// read failure, or a custom PushNotificationProcessor that consumed part of a
// frame and returned a non-transport error. Remove the conn rather than
// relying on isBadConn to recognize the cause — reusing it would decode
// leftover bytes as the next caller's reply. The built-in processor returns
// nil on a nothing-consumed drain, so its normal path still Puts the conn.
p.Remove(ctx, cn, err)
return
}
if c.cscTrackingRequested() {
// A TLS-like wrapper can retain decrypted bytes after the command
// reply even when its raw socket is empty. Ask the background
// drainer for one bounded post-read probe before relying on raw
// socket peeks again.
cn.MarkCscReadPending()
}
p.Put(ctx, cn)
}
func (c *baseClient) withConn(
ctx context.Context, fn func(context.Context, *pool.Conn) error,
) error {
cn, err := c.getConn(ctx)
if err != nil {
return err
}
var fnErr error
defer func() {
// A panic inside fn (e.g. a user BinaryMarshaler panicking while writeCmd
// serializes args) can leave a partial write on the wire, desyncing the conn.
// Mark it unusable so releaseConn REMOVES it instead of returning a poisoned
// conn to the pool, then re-panic to preserve the caller's panic propagation.
if r := recover(); r != nil {
fnErr = fmt.Errorf("%w: panic: %v", errConnUnusable, r)
c.releaseConn(ctx, cn, fnErr)
panic(r)
}
c.releaseConn(ctx, cn, fnErr)
}()
fnErr = fn(ctx, cn)
return fnErr
}
// withPipelineConn executes fn with a connection from the pipeline pool when
// one is configured (PipelineReadBufferSize/PipelineWriteBufferSize set),
// otherwise it falls back to the regular pool via withConn.
// withPipelineConn is withConn/releaseConn for the DEDICATED pipeline pool.
// Conn preparation and release go through the shared pool-parameterized
// helpers (initPooledConn, releaseConnToPool) — the paths used to mirror each
// other by hand and drifted three times (a Limiter-ordering divergence, a
// missed drain-error removal, a missed client-side-cache probe), so only the
// Limiter shape is allowed to live here.
func (c *baseClient) withPipelineConn(
ctx context.Context, fn func(context.Context, *pool.Conn) error,
) (retErr error) {
// Use pipeline pool if available, otherwise fall back to regular pool.
// Read the ref once so every use below sees the same pool (it is set once at
// construction and never mutated, so a plain read is safe).
ref := c.loadPipelinePool()
if ref == nil {
return c.withConn(ctx, fn)
}
pipelinePool := ref.pool
// Honor the Limiter on the dedicated pipeline-pool path too, mirroring
// getConn/releaseConn: Allow() before acquiring and ReportResult() on every
// exit (including the early init/re-acquire failures below). Without this,
// enabling the pipeline pool would silently bypass throttling and failure
// reporting for callers that set a Limiter.
if c.opt.Limiter != nil {
if err := c.opt.Limiter.Allow(); err != nil {
return err
}
}
// One deferred exit for both concerns, because their ORDER is part of the
// contract: releaseConn reports the result BEFORE the connection becomes
// available again, so a limiter or circuit breaker observes the failure
// before it can admit the next operation. Two separate defers would run
// LIFO and release first, letting another pipelined operation through
// against a breaker that has not seen the failure yet (review finding by
// codex on #3942). cn is nil on the acquire/init failure paths, which still
// must report.
var cn *pool.Conn
// connPool records which pool cn was acquired from — the pipeline pool
// normally, or the main pool on a spill — so the single deferred
// ReportResult+release below runs against the right pool, in report-before-
// release order, without re-entering the Limiter a second time.
var connPool pool.Pooler = pipelinePool
var fnErr error
defer func() {
// A panic inside fn (a user encoder panicking mid-write) can desync the conn;
// mark it unusable so it is REMOVED, report the failure to the Limiter, and
// re-panic after releasing so the caller's panic propagation is preserved.
// Recover once, then re-panic at the very end (report-before-release order is
// part of the contract — see below — so the release must run first).
var pv any
panicked := false
if r := recover(); r != nil {
panicked = true
pv = r
fnErr = fmt.Errorf("%w: panic: %v", errConnUnusable, r)
retErr = fnErr
}
if c.opt.Limiter != nil {
c.opt.Limiter.ReportResult(retErr)
}
if cn != nil {
c.releaseConnToPool(ctx, connPool, cn, fnErr)
}
if panicked {
panic(pv)
}
}()
// Acquire+init from the pipeline pool; SPILL to the main pool when the
// pipeline pool cannot serve this pipeline. Acquire the main pool DIRECTLY
// (not via withConn, which would call Limiter.Allow()/ReportResult() a second
// time on top of the outer pair above — the #3959 double-count, which could
// also spuriously reject the spill); the outer Allow accounts this op and the
// deferred ReportResult reports it once.
//
// Spill on EVERY acquisition failure EXCEPT a hard stop — a closed pool
// (pool.ErrClosed) or a cancelled/expired acquire ctx (context.Canceled/
// DeadlineExceeded) — because there the main pool would fail the same way. This
// is a deny-list, not an allow-list: TryGet also DIALS when the pipeline pool
// has no idle conn, and a transient dial failure there is neither ErrPoolTryFull
// nor ErrPoolExhausted, so an allow-list would surface it and fail the pipeline
// even though the main pool has an idle conn or could dial cleanly. Saturation
// (ErrPoolTryFull/ErrPoolExhausted), a dial error, and a pipeline-conn init
// failure all spill; the main pool may hand back an idle conn and avoid it.
// Spilled pipelines run with the regular buffer sizes (a throughput detail).
//
// TryGet, not Get: on a saturated pipeline pool TryGet returns ErrPoolTryFull
// AT ONCE (no PoolTimeout wait, and it is not counted as a pool timeout), so the
// pipeline spills to the main pool immediately instead of stalling up to
// DefaultPipelinePoolTimeout and recording a spurious Stats.Timeouts.
spill := false
cn, retErr = pipelinePool.TryGet(ctx)
if retErr != nil {
cn = nil
if errors.Is(retErr, pool.ErrClosed) ||
errors.Is(retErr, context.Canceled) ||
errors.Is(retErr, context.DeadlineExceeded) {
// Hard stop: the main pool cannot do better (closed pool, or the caller
// cancelled/expired the acquire ctx). Surface it rather than spill.
return retErr
}
spill = true
} else if err := c.initPooledConn(ctx, pipelinePool, cn); err != nil {
cn = nil // initPooledConn already removed it from the pipeline pool
retErr = err
spill = true
}
if spill {
cn, retErr = c.connPool.Get(ctx)
if retErr != nil {
cn = nil
return retErr
}
connPool = c.connPool
if err := c.initPooledConn(ctx, c.connPool, cn); err != nil {
cn = nil // initPooledConn already removed it from the main pool
retErr = err
return retErr
}
}
fnErr = fn(ctx, cn)
retErr = fnErr
return retErr
}
func (c *baseClient) dial(ctx context.Context, network, addr string) (net.Conn, error) {
return c.opt.Dialer(ctx, network, addr)
}
// cscTrackingRequested reports whether initConn must issue CLIENT TRACKING ON.
// cscActive is allocated only after attachment succeeds and is shared with
// derived clients: a conn initialized by Conn/Tx may later return to the
// parent's pool, but a configured cache whose attachment failed must not turn
// tracking on.
func (c *baseClient) cscTrackingRequested() bool {
if c.opt.Protocol != 3 || c.cscActive == nil || !c.cscActive.Load() {
return false
}
return c.opt.DB == 0
}
// autopipelineCSCActive reports whether client-side caching can serve this
// client; the autopipeliner captures it at construction to gate cacheable-solo
// routing through the cache-honoring Process path.
func (c *baseClient) autopipelineCSCActive() bool {
return c.csc != nil && c.cscActive != nil && c.cscActive.Load()
}
func (c *baseClient) process(ctx context.Context, cmd Cmder) error {
return c.processStartingAt(ctx, cmd, 0, time.Time{})
}
// processStartingAt runs cmd like process() but starts the retry loop at
// startAttempt. The full-duplex divert (retryOnNormalConn) passes 1 for a command
// that already spent its initial attempt on the FD socket and came back with a
// retryable reply, so the retry budget (MaxRetries) is not exceeded by one; it
// passes 0 for a redirect, where the FD attempt did not execute the command.
//
// start is the operation start time for the OTel duration metric. The FD divert
// passes req.writtenAt so the reported duration spans the initial FD write to
// final completion, matching the inline FD path and the attempt count (which
// already includes the FD attempt). A zero start defaults to now, so the normal
// path measures from here.
func (c *baseClient) processStartingAt(ctx context.Context, cmd Cmder, startAttempt int, start time.Time) error {
opDurationCallback := otel.GetOperationDurationCallback()
if opDurationCallback == nil {
return c.processCommand(ctx, cmd, nil, startAttempt)
}
if start.IsZero() {
start = time.Now()
}
var state processState
err := c.processCommand(ctx, cmd, &state, startAttempt)
opDurationCallback(ctx, time.Since(start), cmd, state.attempts, err, state.lastConn, c.opt.DB)
return err
}
type processState struct {
attempts int
lastConn *pool.Conn
}
func (c *baseClient) processCommand(ctx context.Context, cmd Cmder, state *processState, startAttempt int) error {
// Reject commands that would make one pooled connection diverge from CSC's
// tracking or database assumptions. Pipelines mirror this guard below.
if err := c.cscCommandError(cmd); err != nil {
return err
}
if c.csc != nil && isCacheable(cmd) {
// A cacheable command can still reach the cached path on the full-duplex
// divert (retryOnNormalConn). The command spent its first attempt on the FD
// socket. So startAttempt must go into processCached. On a cache miss
// processCached runs the MaxRetries loop. If startAttempt is lost, the
// diverted command runs one attempt more than MaxRetries+1.
return c.processCached(ctx, cmd, state, startAttempt)
}
return c.processWithRetry(ctx, cmd, nil, state, startAttempt)
}
// processWithRetry runs cmd through the retry loop. capture (optional) is
// filled by the successful attempt's reply read for the CSC fetch path (see
// cscFetchCapture).
func (c *baseClient) processWithRetry(
ctx context.Context, cmd Cmder, capture *cscFetchCapture, state *processState, startAttempt int,
) error {
var lastConn *pool.Conn
if state != nil {
// Keep a connection an earlier stage already attributed to this command
// (processCached: the coalesced fetch's session conn) when this loop never
// reaches one, e.g. the re-run fails to acquire a pooled connection. The
// duration metric then names the last server that saw the command rather
// than none.
lastConn = state.lastConn
}
var lastErr error
maxRetries := c.opt.MaxRetries
himportRetried := false
// startAttempt > 0 accounts for attempts already spent elsewhere: the
// full-duplex divert passes 1 when a command already used its initial attempt
// on the FD socket (a retryable reply), so the total (FD attempt + this loop)
// does not exceed MaxRetries+1. Clamp so a caller can never disable execution:
// startAttempt <= maxRetries guarantees the loop runs at least once.
if startAttempt < 0 {
startAttempt = 0
}
if startAttempt > maxRetries {
startAttempt = maxRetries
}
// Seed with startAttempt so state.attempts (reported to the OTel duration
// callback) counts attempts already spent before this loop — e.g. the FD socket
// attempt on a diverted retryable command — not just this loop's iterations.
totalAttempts := startAttempt
for attempt := startAttempt; attempt <= maxRetries; attempt++ {
totalAttempts++
attempt := attempt
retry, forced, cn, err := c._process(ctx, cmd, attempt, capture)
if cn != nil {
lastConn = cn
}
if state != nil {
state.attempts = totalAttempts
state.lastConn = lastConn
}
// A "no such fieldset" reply for a registered fieldset means the
// connection lost its server session state (e.g. RESET, concurrent
// discard). The stale prepared flag was invalidated inside _process
// while the connection was still held; grant a single extra attempt
// so the retry re-prepares lazily on whichever connection it lands.
if err != nil && !retry && !himportRetried && !cmd.NoRetry() &&
c.himportShouldRetrySet(cmd, err) {
himportRetried = true
if attempt == maxRetries {
maxRetries++
}
lastErr = err
continue
}
// Don't retry if the command explicitly disables retries (e.g. RawWriteToCmd,
// which writes directly to an io.Writer and cannot undo partial writes) — UNLESS
// the failure was a pre-write desync (forced): the command never reached the
// wire, so replaying it is its first execution, which NoRetry does not forbid
// (NoRetry guards against a SECOND execution of a possibly-written command).
if err == nil || !retry || (cmd.NoRetry() && !forced) {
if err != nil {
recordCommandError(ctx, err, lastConn, totalAttempts-1)
}
return err
}
lastErr = err
}
// Record error metric for exhausted retries
recordCommandError(ctx, lastErr, lastConn, totalAttempts-1)
return lastErr
}
// recordCommandError emits the native error metric for a command's terminal
// failure: err classified, the connection that served the last attempt (nil when
// none did), and the retries spent (attempts beyond the first). Every exit that
// ends a command with an error shares it: the two in processWithRetry and the
// exhausted-budget return in processCached.
func recordCommandError(ctx context.Context, err error, cn *pool.Conn, retries int) {
errorCallback := pool.GetMetricErrorCallback()
if errorCallback == nil {
return
}
errorType, statusCode, isInternal := classifyCommandError(err)
errorCallback(ctx, errorType, cn, statusCode, isInternal, retries)
}
// classifyCommandError classifies an error for metrics reporting.
// Returns: errorType, statusCode, isInternal
// - errorType: A string describing the error type (e.g., "TIMEOUT", "NETWORK", "ERR")
// - statusCode: The Redis error prefix or error category
// - isInternal: true for network/timeout errors, false for Redis server errors
func classifyCommandError(err error) (errorType, statusCode string, isInternal bool) {
if err == nil {
return "", "", false
}
errStr := err.Error()
// Check for timeout errors
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return "TIMEOUT", "TIMEOUT", true
}
// Check for network errors
if _, ok := err.(net.Error); ok {
return "NETWORK", "NETWORK", true
}
// Check for context errors
if errors.Is(err, context.Canceled) {
return "CONTEXT_CANCELED", "CONTEXT_CANCELED", true
}
if errors.Is(err, context.DeadlineExceeded) {
return "CONTEXT_TIMEOUT", "CONTEXT_TIMEOUT", true
}
// Check for Redis errors
// Examples: "ERR ...", "WRONGTYPE ...", "CLUSTERDOWN ..."
if len(errStr) > 0 {
// Find the first space to extract the prefix
spaceIdx := 0
for i, c := range errStr {
if c == ' ' {
spaceIdx = i
break
}
}
if spaceIdx == 0 {
spaceIdx = len(errStr)
}
prefix := errStr[:spaceIdx]
isUppercase := true
for _, c := range prefix {
if c < 'A' || c > 'Z' {
isUppercase = false
break
}
}
if isUppercase && len(prefix) > 0 {
return prefix, prefix, false
}
}
return "UNKNOWN", "UNKNOWN", true
}
// _process runs one attempt. It returns retry (the error is retryable), forced (the
// error is a PRE-WRITE desync: the conn was closed before the command reached the
// wire, so replay is its FIRST execution and safe even for a NoRetry command), the
// conn used, and the error.
func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int, capture *cscFetchCapture) (retry, forced bool, cn *pool.Conn, err error) {
if attempt > 0 {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
return false, false, nil, err
}
}
var usedConn *pool.Conn
var retryTimeout atomic.Uint32
// forceRetry marks the returned error as retryable regardless of its type, AND
// (surfaced as the _process "forced" return) lets the retry bypass a NoRetry
// command's gate. Set only when we have already CLOSED the connection before the
// command reached the wire (a pre-command push-drain desync), so re-running on a
// fresh conn is its FIRST execution — safe even for an error shouldRetry would
// reject (a custom push-processor sentinel) and even for a NoRetry command (whose
// gate guards a SECOND execution, not a first).
var forceRetry atomic.Bool
if err := c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
usedConn = cn
// Process any pending push notifications before executing the command. A
// non-nil error means the drain may have stopped mid-frame (a fragmented
// push straddling its hard read cap), leaving the reader desynced. Do NOT
// ignore it: reading this command's reply on the conn would then return
// shifted bytes — a wrong result, or a wrong-key cache on the CSC capture
// path. Close the conn so releaseConn removes it, mark the error retryable,
// and let processWithRetry re-run on a fresh conn. Benign cases return nil
// (peekAndProcessPushNotifications gates on MaybeHasData; the built-in
// processor propagates only genuine mid-frame errors and a custom processor
// is peeked first), so this fires only on a real desync.
if err := c.processPushNotifications(ctx, cn); err != nil {
internal.Logger.Printf(ctx, "push: pre-command drain failed, retiring conn: %v", err)
_ = cn.Close()
// Force a retry: the conn is closed so re-running on a fresh conn is safe,
// and the drain error may be a custom-processor sentinel that shouldRetry
// would reject (retryTimeout only affects timeout errors). The command was
// not written yet, so replay is clean.
forceRetry.Store(true)
return err
}
// HIMPORT bookkeeping: pending discards for this session and the
// PREPARE for an HIMPORT SET's registered fieldset are written in
// the same round trip, right before the command.
var injected []Cmder
if _, ok := cmd.(himportCmder); ok {
injected = c.himportInjectedCmds(ctx, cn, []Cmder{cmd})
}
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmd(wr, cmd)
}); err != nil {
retryTimeout.Store(1)
return err
}
readReplyFunc := cmd.readReply
// When the caller requested raw-reply capture (client-side cache),
// read the reply as raw RESP bytes and re-parse them through the
// command's normal reply handler. This reuses proto.Reader rather
// than duplicating parsing logic in a bespoke cache serializer.
if capture != nil {
origRead := readReplyFunc
readReplyFunc = func(rd *proto.Reader) error {
raw, err := rd.ReadRawReply()
if err != nil {
return err
}
capture.raw = raw
return origRead(proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1))
}
}
readErr := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), 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 {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
if len(injected) > 0 {
if err := c.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
return err
}
// A push notification can arrive between the injected
// replies and the command reply; drain again so the
// reply read below does not consume it as the command's.
if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
}
err := readReplyFunc(rd)
// Assert the command type before touching the error: the
// errors.As chain inside himportNoSuchFieldset allocates, and
// this is the per-command hot path.
if set, ok := cmd.(*HImportSetCmd); ok && himportNoSuchFieldset(err) {
// A failed injected PREPARE is the root cause of the
// command's "no such fieldset" reply (drained above).
for _, ic := range injected {
if prep, ok := ic.(*HImportPrepareCmd); ok &&
prep.fieldsetName == set.fieldsetName && prep.Err() != nil {
err = prep.Err()
break
}
}
// The session lost a registered fieldset the flags claim is
// prepared — and the same event (failover, cross-region
// switch, reset storm) may have wiped other sessions whose
// flags also still look current. Bump the fieldset version
// so every connection re-prepares before its next use,
// wherever the retry granted by process() lands.
if himportNoSuchFieldset(err) {
if fs, registered := c.himport.lookup(set.fieldsetName); registered {
c.himport.refreshVersion(set.fieldsetName, fs.version)
}
}
}
return err
})
// redis.Nil is a complete, valid negative reply. For a CSC fetch, retain
// its connection attribution before returning Nil to the caller so the
// raw reply can be cached and invalidated like any other read result.
if readErr != nil && (capture == nil || readErr != Nil) {
if cmd.readTimeout() == nil {
retryTimeout.Store(1)
} else {
retryTimeout.Store(0)
}
return readErr
}
if capture != nil {
// Attribute while the conn is still held: once it is released, a
// queued handoff may swap the socket and bump the generation, and
// this capture is what fulfillCached compares against.
capture.connID = cn.GetID()
capture.initGen = c.cscConnInitGen(capture.connID)
}
if hc, ok := cmd.(himportCmder); ok {
c.himportAfterCmd(cn, hc)
}
return readErr
}); err != nil {
retry := forceRetry.Load() || shouldRetry(err, retryTimeout.Load() == 1)
return retry, forceRetry.Load(), usedConn, err
}
return false, false, usedConn, nil
}
func (c *baseClient) retryBackoff(attempt int) time.Duration {
return internal.RetryBackoff(attempt, c.opt.MinRetryBackoff, c.opt.MaxRetryBackoff)
}
func (c *baseClient) cmdTimeout(cmd Cmder) time.Duration {
if timeout := cmd.readTimeout(); timeout != nil {
t := *timeout
if t == 0 {
return 0
}
return t + 10*time.Second
}
return c.opt.ReadTimeout
}
// context returns the context for the current connection.
// If the context timeout is enabled, it returns the original context.
// Otherwise, it returns a new background context.
func (c *baseClient) context(ctx context.Context) context.Context {
if c.opt.ContextTimeoutEnabled {
return ctx
}
return context.Background()
}
// createInitConnFunc creates a connection initialization function that can be used for reconnections.
func (c *baseClient) createInitConnFunc() func(context.Context, *pool.Conn) error {
return func(ctx context.Context, cn *pool.Conn) error {
return c.initConn(ctx, cn)
}
}
// enableMaintNotificationsUpgrades initializes the maintnotifications upgrade manager and pool hook.
// This function is called during client initialization.
// will register push notification handlers for all maintenance upgrade events.
// will start background workers for handoff processing in the pool hook.
func (c *baseClient) enableMaintNotificationsUpgrades() error {
// Create client adapter
clientAdapterInstance := newClientAdapter(c)
// Create maintnotifications manager directly
manager, err := maintnotifications.NewManager(clientAdapterInstance, c.connPool, c.opt.MaintNotificationsConfig)
if err != nil {
return err
}
// Set the manager reference and initialize pool hook
c.maintNotificationsManagerLock.Lock()
c.maintNotificationsManager = manager
c.maintNotificationsManagerLock.Unlock()
// Initialize pool hook (safe to call without lock since manager is now set)
manager.InitPoolHook(c.dialHook)
// If a dedicated pipeline connection pool is in use, attach an independent
// maintnotifications hook to it as well. Otherwise autopipelined/pipelined
// commands run on pipeline-pool connections that never receive MOVING/
// MIGRATING handoff handling.
if pp := c.getPipelinePool(); pp != nil {
manager.InitPoolHookForPool(pp, c.dialHook)
}
return nil
}
func (c *baseClient) disableMaintNotificationsUpgrades() error {
c.maintNotificationsManagerLock.Lock()
defer c.maintNotificationsManagerLock.Unlock()
// Close the maintnotifications manager
if c.maintNotificationsManager != nil {
// Closing the manager will also shutdown the pool hook
// and remove it from the pool
if err := c.maintNotificationsManager.Close(); err != nil {
return err
}
c.maintNotificationsManager = nil
}
return nil
}
// Close closes the client, releasing any open resources.
//
// It is rare to Close a Client, as the Client is meant to be
// long-lived and shared between many goroutines.
func (c *baseClient) Close() error {
// The pools this baseClient owns are shared with every WithTimeout/
// WithReadTimeout clone. Once ANY sharer closes them, no wrapper may
// build a fresh autopipeliner against them — its flushers would run
// against closed pools forever. The atomic is checked by the
// AutoPipeline getters of every wrapper sharing this base.
if c.apClosed != nil {
c.apClosed.Store(true)
}
if h := c.cscDrainHandle; h != nil {
h.closeOnce.Do(func() {
h.closeErr = c.closeResources()
})
return h.closeErr
}
return c.closeResources()
}
func (c *baseClient) closeResources() error {
var firstErr error
// CSC teardown (no-op when CSC is not active): stop the background
// invalidation drainer before the pool it walks is torn down.
c.stopBackgroundDrainer()
// Close maintnotifications manager first
if err := c.disableMaintNotificationsUpgrades(); err != nil {
firstErr = err
}
if err := c.onClose.run(); err != nil && firstErr == nil {
firstErr = err
}
// Unregister pools from OTel before closing them
otel.UnregisterPools(c.connPool, c.pubSubPool, c.getPipelinePool())
if c.connPool != nil {
if err := c.connPool.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if pp := c.getPipelinePool(); pp != nil {
if err := pp.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if c.pubSubPool != nil {
if err := c.pubSubPool.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (c *baseClient) getAddr() string {
return c.opt.Addr
}
func (c *baseClient) processPipeline(ctx context.Context, cmds []Cmder) error {
if err := c.generalProcessPipeline(ctx, cmds, c.pipelineProcessCmds, "PIPELINE", c.opt.MaxRetries); err != nil {
return err
}
return cmdsFirstErr(cmds)
}
// processPipelineRetries runs a pipeline with an explicit retry bound instead of
// the client's configured MaxRetries. The autopipeliner's shutdown flush passes
// maxRetries==0 to give an already-attempted carried command exactly one final
// execution, so replaying it on Close cannot exceed its per-command retry budget.
func (c *baseClient) processPipelineRetries(ctx context.Context, cmds []Cmder, maxRetries int) error {
if err := c.generalProcessPipeline(ctx, cmds, c.pipelineProcessCmds, "PIPELINE", maxRetries); err != nil {
return err
}
return cmdsFirstErr(cmds)
}
func (c *baseClient) processTxPipeline(ctx context.Context, cmds []Cmder) error {
if err := c.generalProcessPipeline(ctx, cmds, c.txPipelineProcessCmds, "MULTI", c.opt.MaxRetries); err != nil {
return err
}
return cmdsFirstErr(cmds)
}
type pipelineProcessor func(context.Context, *pool.Conn, []Cmder) (bool, error)
// pipelineErrShouldStamp reports whether a pipeline-level error must be stamped
// onto every command (setCmdsErr). A per-command Redis reply error (LOADING,
// WRONGTYPE, ...) is left alone so each command keeps its own reply - EXCEPT when
// the error is errConnUnusable, which marks a desynchronized reply stream and may
// WRAP a redis.Error (a custom PushNotificationProcessor that returns a redis.Error
// on a failed push drain). isRedisError unwraps to that inner redis.Error and would
// otherwise treat the whole batch as a normal reply, leaving every command with
// Err()==nil while Exec returns an error - so the errConnUnusable marker takes
// precedence and the transport failure is stamped onto every command.
func pipelineErrShouldStamp(err error) bool {
return errors.Is(err, errConnUnusable) || !isRedisError(err)
}
func (c *baseClient) generalProcessPipeline(
ctx context.Context, cmds []Cmder, p pipelineProcessor, operationName string, maxRetries int,
) error {
// Pipeline commands never pass through process, so apply the same CSC state
// guard here. initConn's internal client is exempt.
for _, cmd := range cmds {
if err := c.cscCommandError(cmd); err != nil {
setCmdsErr(cmds, err)
return err
}
}
// Only call time.Now() if pipeline operation duration callback is set to avoid overhead
var operationStart time.Time
pipelineOpDurationCallback := otel.GetPipelineOperationDurationCallback()
if pipelineOpDurationCallback != nil {
operationStart = time.Now()
}
var lastConn *pool.Conn
totalAttempts := 0
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
totalAttempts++
if attempt > 0 {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
setCmdsErr(cmds, err)
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
pipelineOpDurationCallback(ctx, operationDuration, operationName, len(cmds), totalAttempts, err, lastConn, c.opt.DB)
}
return err
}
}
// Enable retries by default to retry dial errors returned by withConn.
canRetry := true
// Route pipelines through the dedicated pipeline pool when configured;
// withPipelineConn falls back to the regular pool when it is not.
lastErr = c.withPipelineConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
lastConn = cn
// Drain pending push notifications before executing the pipeline. A drain
// error can mean a custom processor consumed part of a RESP3 push frame,
// leaving the reply stream desynchronized — writing the batch now would
// misread every reply (silent cross-command result shift). Fail the batch
// with the error instead of logging on: errConnUnusable makes
// releaseConnToPool remove the desynced conn, and shouldRetry does not
// treat it as retryable, so the caller sees an error rather than shifted
// results. (Failing loud is deliberately preferred over teaching the shared
// retry classifier a new sentinel.)
if err := c.processPushNotifications(ctx, cn); err != nil {
return fmt.Errorf("%w: pipeline push drain: %w", errConnUnusable, err)
}
var err error
canRetry, err = p(ctx, cn, cmds)
return err
})
// Don't retry if any command in the pipeline explicitly disables retries
// (e.g., RawWriteToCmd which writes directly to an io.Writer and cannot
// undo partial writes on retry)
if lastErr == nil || !canRetry || !shouldRetry(lastErr, true) || cmdsContainNoRetry(cmds) {
// The error should be set here only when failing to obtain the conn.
if pipelineErrShouldStamp(lastErr) {
setCmdsErr(cmds, lastErr)
}
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
pipelineOpDurationCallback(ctx, operationDuration, operationName, len(cmds), totalAttempts, lastErr, lastConn, c.opt.DB)
}
if lastErr != nil {
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType, statusCode, isInternal := classifyCommandError(lastErr)
errorCallback(ctx, errorType, lastConn, statusCode, isInternal, totalAttempts-1)
}
}
return lastErr
}
}
// Retries exhausted on a retryable error: the loop fell through without the
// early-exit branch running, so the commands were never populated with the
// failure. Mirror that branch here so callers that observe results only
// per-command — notably AutoPipeline, which discards this function's returned
// error — see the error instead of a nil error and a zero value. Guard on
// !isRedisError so a per-command redis error (e.g. LOADING) keeps its own
// reply rather than being overwritten.
if pipelineErrShouldStamp(lastErr) {
setCmdsErr(cmds, lastErr)
}
if pipelineOpDurationCallback != nil {
operationDuration := time.Since(operationStart)
pipelineOpDurationCallback(ctx, operationDuration, operationName, len(cmds), totalAttempts, lastErr, lastConn, c.opt.DB)
}
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType, statusCode, isInternal := classifyCommandError(lastErr)
errorCallback(ctx, errorType, lastConn, statusCode, isInternal, totalAttempts-1)
}
return lastErr
}
func (c *baseClient) pipelineProcessCmds(
ctx context.Context, cn *pool.Conn, cmds []Cmder,
) (bool, error) {
// Drain pending push notifications before writing the pipeline. A drain error
// may mean the reply stream is desynchronized (a processor consumed part of a
// RESP3 frame), so writing now would misread replies. Fail the batch and remove
// the conn (errConnUnusable) rather than logging on. (generalProcessPipeline
// drains once more before calling this, so this is the belt-and-suspenders gate
// for a direct/again-buffered push.)
if err := c.processPushNotifications(ctx, cn); err != nil {
err = fmt.Errorf("%w: pipeline push drain: %w", errConnUnusable, err)
setCmdsErr(cmds, err)
return false, err
}
// HIMPORT bookkeeping: pending discards for this session and PREPAREs
// for registered fieldsets the batch references get written ahead of
// the batch.
injected := c.himportInjectedCmds(ctx, cn, cmds)
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmds(wr, cmds)
}); err != nil {
setCmdsErr(cmds, err)
return true, err
}
var readErr error
if err := cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
if err := c.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
// Transport error with every batch reply unreadXX: stamp the
// batch like a write failure. The outer retry loop stamps only
// on its exit branch, not when attempts run out, so without
// this a batch that keeps dying here would surface an Exec
// error while every command still reports Err() == nil.
setCmdsErr(cmds, err)
return err
}
// read all replies
readErr = c.pipelineReadCmds(ctx, cn, rd, cmds)
if readErr != nil && !isRedisError(readErr) {
return readErr
}
c.himportAfterBatch(cn, injected, cmds)
return nil
}); err != nil {
return true, err
}
// Registered fieldsets whose SETs came back "no such fieldset" (the
// session was lost between prepare and use) are re-prepared and those
// SETs re-issued once on the same connection; the error must not
// surface for managed fieldsets.
//
// A transport failure here must neither retry nor fail the batch: the
// first round trip was fully consumed and its results delivered, so
// re-executing would double-apply non-idempotent commands and failing
// would stamp a spurious error onto commands that succeeded. The
// re-issue errors stay on the retried SETs; the connection, which may
// hold unread replies, is marked for removal when released.
if err := c.himportRetryFailedSets(ctx, cn, cmds); err != nil {
internal.Logger.Printf(ctx, "himport: pipeline set re-issue failed: %v", err)
cn.MarkCloseOnPut("himport: transport error during set re-issue")
}
// Preserve retryable first-command errors (e.g. LOADING) for the outer
// loop; the re-issue above may have cleared it. rawErr: this runs on the
// execution path; never await here (an async autopipeline command's ready
// channel is closed by this very batch — Err() would self-deadlock).
if readErr != nil {
readErr = cmds[0].rawErr()
}
return readErr != nil, readErr
}
func (c *baseClient) pipelineReadCmds(ctx context.Context, cn *pool.Conn, rd *proto.Reader, cmds []Cmder) error {
for i, cmd := range cmds {
// 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 {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
err := cmd.readReply(rd)
cmd.SetErr(err)
if err != nil && !isRedisError(err) {
setCmdsErr(cmds[i+1:], err)
return err
}
}
// Retry errors like "LOADING redis is loading the dataset in memory".
// rawErr: this runs on the execution path; never await here (an async
// autopipeline command's ready channel is closed by this very batch).
return cmds[0].rawErr()
}
func (c *baseClient) txPipelineProcessCmds(
ctx context.Context, cn *pool.Conn, cmds []Cmder,
) (bool, error) {
// Drain pending push notifications before writing the transaction. A drain error
// may leave the reply stream desynchronized, so fail the batch and remove the
// conn (errConnUnusable) rather than logging on. (generalProcessPipeline drains
// once more before calling this — belt-and-suspenders gate.)
if err := c.processPushNotifications(ctx, cn); err != nil {
err = fmt.Errorf("%w: txpipeline push drain: %w", errConnUnusable, err)
setCmdsErr(cmds, err)
return false, err
}
// HIMPORT bookkeeping: pending discards for this session and PREPAREs
// for registered fieldsets the transaction references get written ahead
// of MULTI; the session state is visible inside the transaction.
injected := c.himportInjectedCmds(ctx, cn, cmds)
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
for _, ic := range injected {
if err := writeCmd(wr, ic); err != nil {
return err
}
}
return writeCmds(wr, cmds)
}); err != nil {
setCmdsErr(cmds, err)
return true, err
}
if err := cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
if err := c.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
// Transport error with every transaction reply unread: stamp
// the batch like a write failure (see pipelineProcessCmds).
setCmdsErr(cmds, err)
return err
}
statusCmd := cmds[0].(*StatusCmd)
// Trim multi and exec.
trimmedCmds := cmds[1 : len(cmds)-1]
if err := c.txPipelineReadQueued(ctx, cn, rd, statusCmd, trimmedCmds); err != nil {
setCmdsErr(cmds, err)
return err
}
// Read replies.
err := c.pipelineReadCmds(ctx, cn, rd, trimmedCmds)
if err == nil || isRedisError(err) {
c.himportAfterBatch(cn, injected, trimmedCmds)
}
return err
}); err != nil {
return false, err
}
return false, nil
}
// txPipelineReadQueued reads queued replies from the Redis server.
// It returns an error if the server returns an error or if the number of replies does not match the number of commands.
func (c *baseClient) txPipelineReadQueued(ctx context.Context, cn *pool.Conn, rd *proto.Reader, statusCmd *StatusCmd, cmds []Cmder) 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 {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
// Parse +OK.
if err := statusCmd.readReply(rd); err != nil {
return err
}
// Parse +QUEUED.
for _, cmd := range cmds {
// 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 {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
if err := statusCmd.readReply(rd); err != nil {
cmd.SetErr(err)
if !isRedisError(err) {
return err
}
}
}
// 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 {
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
}
// Parse number of replies.
line, err := rd.ReadLine()
if err != nil {
if err == Nil {
err = TxFailedErr
}
return err
}
if line[0] != proto.RespArray {
return fmt.Errorf("redis: expected '*', but got line %q", line)
}
return nil
}
//------------------------------------------------------------------------------
// Client is a Redis client representing a pool of zero or more underlying connections.
// It's safe for concurrent use by multiple goroutines.
//
// Client creates and frees connections automatically; it also maintains a free pool
// of idle connections. You can control the pool size with Config.PoolSize option.
type Client struct {
*baseClient
cmdable
// cscLifecycleOwner keeps the canonical Client wrapper (the one whose GC
// cleanup owns the drainer) reachable while a WithTimeout clone can still
// serve from its cache. Nil on the canonical wrapper and on non-CSC clones.
cscLifecycleOwner *Client
autopipelinerMu *sync.Mutex // guards the autopipeliner fields against concurrent first-call creation
autopipeliner *AutoPipeliner // blocking face (Client.AutoPipeline)
asyncAutopipeliner *AutoPipeliner // deferred face (Client.AsyncAutoPipeline)
autopipelinerClosed bool // set by Close: refuse to resurrect a pipeliner on a closed client
}
// NewClient returns a client to the Redis Server specified by Options.
// Passing nil Options will cause a panic.
func NewClient(opt *Options) *Client {
if opt == nil {
panic("redis: NewClient nil options")
}
// clone to not share options with the caller
opt = opt.clone()
opt.init()
// Push notifications are always enabled for RESP3 (cannot be disabled)
c := Client{
baseClient: &baseClient{
apClosed: &atomic.Bool{},
opt: opt,
onClose: &onCloseHooks{},
himport: newHImportRegistry(),
},
}
c.init()
// Close a partially-built client if a construction step below panics before
// NewClient returns. The pools (and their background goroutines) are created
// below, but several later steps can panic — maintnotifications in
// ModeEnabled failing, or a custom push processor rejecting handler
// registration. The panic propagates to the caller (some callers, e.g.
// MultiDB AddDatabase, recover it), yet &c is never returned, so without this
// those pools would leak with no reference left to Close them. closeResources
// is nil-safe for a partially-built client, and the panic still propagates:
// this defer runs during unwind and does not recover.
built := false
defer func() {
if !built {
_ = c.Close()
}
}()
// Initialize push notification processor using shared helper
// Use void processor for RESP2 connections (push notifications not available)
c.pushProcessor = initializePushProcessor(opt)
// set opt push processor for child clients
c.opt.PushNotificationProcessor = c.pushProcessor
// Generate unique pool names for metrics
uniqueID := generateUniqueID()
mainPoolName := opt.Addr + "_" + uniqueID
pubsubPoolName := opt.Addr + "_" + uniqueID + "_pubsub"
// Create connection pools. Assign the fields only AFTER the error check:
// newConnPool/newPubSubPool return a nil *pool on error, and assigning that
// straight to the pool.Pooler field would leave a typed-nil interface that
// closeResources treats as present (its != nil check passes), so the
// panic-cleanup defer above would nil-deref inside ConnPool.Close instead of
// surfacing the original construction error. A local var keeps the field nil
// on failure. The pipeline pool below already follows this pattern.
connPool, err := newConnPool(opt, c.dialHook, mainPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create connection pool: %w", err))
}
c.connPool = connPool
pubSubPool, err := newPubSubPool(opt, c.dialHook, pubsubPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err))
}
c.pubSubPool = pubSubPool
// Create the dedicated pipeline pool unconditionally, like pubSubPool: it
// is pure burst capacity (no pre-dialing, small cap, larger buffers — see
// pipelinePoolOptions), so an unused pipeline pool holds zero connections
// and costs nothing. Pipelines stop competing with regular commands for
// main-pool connections; a burst wider than the pool's cap spills back to
// the main pool (see withPipelineConn). PipelinePoolSize < 0 opts out.
if opt.PipelinePoolSize >= 0 {
ref, err := c.buildPipelinePool(opt.Addr + "_" + uniqueID + "_pipeline")
if err != nil {
panic(fmt.Errorf("redis: failed to create pipeline connection pool: %w", err))
}
c.pipelinePool = ref
}
if opt.StreamingCredentialsProvider != nil {
// Size the re-auth worker semaphore for the COMBINED ceiling of every pool
// the hook is registered on: the same hook drives the main pool AND the
// (possibly larger) dedicated pipeline pool, so a credential rotation must
// be able to re-AUTH connections from both concurrently. Sizing to only the
// main PoolSize would serialize pipeline re-auths behind PoolSize workers,
// leaving pipeline capacity unavailable well past reAuthTimeout.
workers := c.connPool.Size()
if pp := c.getPipelinePool(); pp != nil {
workers += pp.Size()
}
c.streamingCredentialsManager = streaming.NewManagerWithWorkers(c.connPool, c.opt.PoolTimeout, workers)
c.connPool.AddPoolHook(c.streamingCredentialsManager.PoolHook())
if pp := c.getPipelinePool(); pp != nil {
pp.AddPoolHook(c.streamingCredentialsManager.PoolHook())
}
}
// CSC wiring (SharedTracking): shared cache + per-connection CLIENT TRACKING +
// background drainer. attachCSC is the strategy dispatch entry.
if opt.Protocol == 3 {
var cache Cache
if explicit := opt.ClientSideCache; explicit != nil {
cache = explicit
} else if cfg := opt.ClientSideCacheConfig; cfg != nil {
cache = NewLocalCache(*cfg)
// We constructed it, so we own it (may flush on drainer stop).
c.baseClient.cscOwnsCache = true
}
// CSC is only supported with the built-in push processor: it depends on the
// built-in draining kernel-only readability and treating a boundary read timeout
// as benign. Warn (do not fail) when a custom processor is paired with CSC — the
// contract is that behavior is otherwise undefined. See .claude/specs/push.md.
if cache != nil {
if _, builtin := c.pushProcessor.(*push.Processor); !builtin {
internal.Logger.Printf(context.Background(),
"redis: client-side caching enabled with a custom PushNotificationProcessor; "+
"CSC is only supported with the built-in processor, behavior is otherwise undefined "+
"(invalidation freshness and idle-connection health not guaranteed)")
}
// Publish the weak back-reference to the canonical *Client BEFORE attachCSC
// starts the drainer. The push-handler adapter's canonical close reads
// cscClientWeak on the drainer goroutine; setting it afterwards (it used to be
// set only later, in cscRegisterCleanups) raced that read and could miss the
// close for a push that arrived in the window. Weak so a dropped *Client stays
// collectible and its GC cleanup still fires.
c.baseClient.cscClientWeak = weak.Make(&c)
}
c.baseClient.attachCSC(context.Background(), cache)
// Safety net for a client dropped without Close: the goroutines hold
// *baseClient (never *Client), so dropping *Client (returned as &c)
// triggers these cleanups, which stop them. See cscRegisterCleanups.
cscRegisterCleanups(&c)
}
// Initialize maintnotifications first if enabled and protocol is RESP3
if opt.MaintNotificationsConfig != nil && opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled && opt.Protocol == 3 {
err := c.enableMaintNotificationsUpgrades()
if err != nil {
internal.Logger.Printf(context.Background(), "failed to initialize maintnotifications: %v", err)
if opt.MaintNotificationsConfig.Mode == maintnotifications.ModeEnabled {
/*
Design decision: panic here to fail fast if maintnotifications cannot be enabled when explicitly requested.
We choose to panic instead of returning an error to avoid breaking the existing client API, which does not expect
an error from NewClient. This ensures that misconfiguration or critical initialization failures are surfaced
immediately, rather than allowing the client to continue in a partially initialized or inconsistent state.
Clients relying on maintnotifications should be aware that initialization errors will cause a panic, and should
handle this accordingly (e.g., via recover or by validating configuration before calling NewClient).
This approach is only used when MaintNotificationsConfig.Mode is MaintNotificationsEnabled, indicating that maintnotifications
upgrades are required for correct operation. In other modes, initialization failures are logged but do not panic.
*/
panic(fmt.Errorf("failed to enable maintnotifications: %w", err))
}
}
}
// Register pools with OTel recorder if it supports pool registration
// This allows async gauge metrics to pull stats from pools periodically
otel.RegisterPools(c.connPool, c.pubSubPool, c.getPipelinePool(), opt.Addr)
built = true
return &c
}
func (c *Client) init() {
// Fresh per-Client guard and no inherited autopipeliner: a WithTimeout clone
// (clone := *c) must not share the parent's mutex or AutoPipeliner instance.
c.autopipelinerMu = &sync.Mutex{}
c.autopipeliner = nil
c.asyncAutopipeliner = nil
c.cmdable = c.Process
c.initHooks(hooks{
dial: c.baseClient.dial,
process: c.baseClient.process,
pipeline: c.baseClient.processPipeline,
txPipeline: c.baseClient.processTxPipeline,
})
}
// WithTimeout returns a clone sharing the parent's connection pools with the
// given read/write timeout. The clone caches its own autopipeliners, separate
// from the parent's: an AutoPipeline()/AsyncAutoPipeline() created on the clone
// registers a close hook on the shared pool. Closing any pool-sharing wrapper
// (the parent or another clone) drains and stops every registered engine's
// background flusher before the shared pools are torn down, so no flusher
// outlives the pool. That shared-pool drain does not mark the clone's
// autopipeliner closed, so an explicit Close on the clone still fully tears it
// (and its autopipeliners) down; Close is idempotent.
func (c *Client) WithTimeout(timeout time.Duration) *Client {
// Snapshot under the guard: AutoPipeline()/Close() mutate the
// autopipeliner fields concurrently, so a bare struct copy of them is a
// data race (init below discards the copied values either way).
c.autopipelinerMu.Lock()
clone := *c
c.autopipelinerMu.Unlock()
if c.cscLifecycleOwner != nil {
clone.cscLifecycleOwner = c.cscLifecycleOwner
} else if c.baseClient.cscDrainHandle != nil {
clone.cscLifecycleOwner = c
}
clone.baseClient = c.baseClient.withTimeout(timeout)
// Route the clone's CSC push-handler Close through the OWNER wrapper.
// clone() copies neither cscDrainHandle nor cscClientWeak, so without this a
// custom push handler calling Close() on the cscHandlerClient installed for a
// held-conn drain on this clone would fall through to baseClient.Close —
// closing the SHARED pools while the owner's drainer and cached autopipeliners
// keep running against them. cscLifecycleOwner is the canonical wrapper and is
// kept alive by this clone's strong ref, so closeCanonical resolves it and
// calls owner.Close() (the full CSC + autopipeliner teardown).
if clone.cscLifecycleOwner != nil {
clone.baseClient.cscClientWeak = weak.Make(clone.cscLifecycleOwner)
}
clone.init()
return &clone
}
// Close closes the client, stopping both cached autopipeliners (the blocking
// AutoPipeline instance and the async AsyncAutoPipeline instance, if created)
// before releasing the underlying resources, so their background flusher
// goroutines don't outlive the client. AutoPipeliner.Close is idempotent and
// safe to call here even if autopipelining was never used.
// A WithTimeout clone delegates CSC teardown to the canonical wrapper that
// owns the background drainer.
func (c *Client) Close() error {
c.autopipelinerMu.Lock()
ap, async := c.autopipeliner, c.asyncAutopipeliner
c.autopipeliner, c.asyncAutopipeliner = nil, nil
// A later AutoPipeline()/AsyncAutoPipeline() call must not build a fresh
// pipeliner against the closed pools: nothing would ever close it and its
// flusher goroutines would leak. The getters check this flag.
c.autopipelinerClosed = true
c.autopipelinerMu.Unlock()
var firstErr error
for _, p := range []*AutoPipeliner{ap, async} {
if p != nil {
if err := p.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
}
if c.cscLifecycleOwner != nil {
// Delegate through the OWNER's *Client.Close, not its baseClient:
// the owner may hold cached autopipeliners of its own whose flusher
// goroutines must stop with the shared pools, and its
// autopipelinerClosed flag must flip so later owner getters cannot
// resurrect a pipeliner against closed pools. Client.Close is
// idempotent through baseClient.Close, so an owner also closed
// directly is fine.
if err := c.cscLifecycleOwner.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
if err := c.baseClient.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
func (c *Client) Conn() *Conn {
// Share the HIMPORT fieldset registry: the sticky pool borrows
// connections from this client's pool, so fieldsets prepared on them
// stay valid after the connections are returned.
conn := newConn(c.opt, c.baseClient.newStickyConnPool(), &c.hooksMixin, c.himport)
// A sticky client does not serve cache hits, but a new pool connection first
// initialized through it may later be reused by the parent. Share the
// successful-attachment signal so that connection is tracked exactly when
// the parent's CSC is active.
conn.baseClient.cscActive = c.baseClient.cscActive
// No-op today: the strategy needs an idle-conn drainer and a StickyConnPool
// has none, so CSC isn't active on a Conn() (its reads hit the server). Kept
// so a future sticky-pool-capable strategy attaches here.
conn.baseClient.attachCSC(context.Background(), c.csc)
// Carry the parent's shared eviction hook so that if this derived client
// initializes a pool conn, the close hook it installs still evicts from the
// parent cache (its own csc is nil).
conn.baseClient.cscPoolHook = c.baseClient.cscPoolHook
return conn
}
func (c *Client) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
// Options returns read-only *Options that were used to create the client.
// Any alteration of the returned *Options may result in undefined behaviour.
func (c *Client) Options() *Options {
return c.opt
}
// NodeAddress 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
// (e.g., loopback replacement). For standalone clients, this defaults to Addr.
//
// This is useful for matching the source field in maintenance notifications
// (e.g. SMIGRATED).
func (c *Client) NodeAddress() string {
return c.opt.NodeAddress
}
// GetMaintNotificationsManager returns the maintnotifications manager instance for monitoring and control.
// Returns nil if maintnotifications are not enabled.
func (c *Client) GetMaintNotificationsManager() *maintnotifications.Manager {
c.maintNotificationsManagerLock.RLock()
defer c.maintNotificationsManagerLock.RUnlock()
return c.maintNotificationsManager
}
// initializePushProcessor initializes the push notification processor for any client type.
// This is a shared helper to avoid duplication across NewClient, NewFailoverClient, and NewSentinelClient.
func initializePushProcessor(opt *Options) push.NotificationProcessor {
// Always use custom processor if provided
if opt.PushNotificationProcessor != nil {
return opt.PushNotificationProcessor
}
// Push notifications are always enabled for RESP3, disabled for RESP2
if opt.Protocol == 3 {
// Create default processor for RESP3 connections
return NewPushNotificationProcessor()
}
// Create void processor for RESP2 connections (push notifications not available)
return NewVoidPushNotificationProcessor()
}
// RegisterPushNotificationHandler registers a handler for a specific push notification name.
// Returns an error if a handler is already registered for this push notification name.
// If protected is true, the handler cannot be unregistered.
func (c *Client) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error {
return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected)
}
// GetPushNotificationHandler returns the handler for a specific push notification name.
// Returns nil if no handler is registered for the given name.
func (c *Client) GetPushNotificationHandler(pushNotificationName string) push.NotificationHandler {
return c.pushProcessor.GetHandler(pushNotificationName)
}
type PoolStats pool.Stats
// PoolStats returns connection pool stats.
func (c *Client) PoolStats() *PoolStats {
stats := c.connPool.Stats()
stats.PubSubStats = *c.pubSubPool.Stats()
if pp := c.getPipelinePool(); pp != nil {
stats.PipelineStats = pp.Stats()
}
return (*PoolStats)(stats)
}
func (c *Client) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipeline().Pipelined(ctx, fn)
}
func (c *Client) Pipeline() Pipeliner {
pipe := Pipeline{
exec: pipelineExecer(c.processPipelineHook),
}
pipe.init()
return &pipe
}
// AutoPipeline returns the blocking autopipeliner for this client: a drop-in
// replacement for the normal command surface where each command call (ap.Set,
// ap.Get, ...) blocks until executed, exactly like a plain client — but the
// engine batches concurrent callers' commands into pipelines, so throughput is
// far higher (measured locally over loopback: ~1M+ SET/sec vs ~100k; indicative, not a guarantee). Commands keep per-goroutine order.
//
// By default, Options.AutoPipelineOptions is used if set,
// otherwise DefaultBlockingAutoPipelineOptions (a single ordered batch stream,
// which maximizes throughput and minimizes latency for the blocking face — see
// its doc). The instance is cached and shared; the first
// call's config wins and later calls return the same instance until it is closed.
// It must be closed (or close the client) to release its goroutines.
//
// It returns an error if the supplied config is invalid (e.g. MaxConcurrentBatches>1
// without Unordered, or a negative size); on error no instance is cached.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) AutoPipeline() (*AutoPipeliner, error) {
return c.AutoPipelineWithOptions(nil)
}
// AutoPipelineWithOptions is AutoPipeline with explicit options instead of
// Options.AutoPipelineOptions / the default. The instance is cached and shared;
// the first call's config wins.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return getOrCreateAutoPipeliner(c.autopipelinerMu, &c.autopipeliner, &c.autopipelinerClosed, c.baseClient.apClosed, c.baseClient.onClose, onCloseHookIDAutoPipeline, config,
func() *AutoPipelineOptions {
if c.opt.AutoPipelineOptions != nil {
return c.opt.AutoPipelineOptions
}
return DefaultBlockingAutoPipelineOptions()
},
func(cfg *AutoPipelineOptions) (*AutoPipeliner, error) { return newAutoPipeliner(c, cfg, true) })
}
// apCloseHookSeq makes each autopipeliner engine's onClose hook id unique so a
// client and its WithTimeout clone (which share the onClose registry) do not
// collide on a per-slot constant id. The hook is registered once, under the
// getOrCreateAutoPipeliner mutex, on the fresh build; ap.Close unregisters it.
var apCloseHookSeq atomic.Uint64
// AsyncAutoPipeline returns the deferred (async) autopipeliner: command calls
// return immediately and the result accessors (Val/Result/Err) block until the
// command has executed. Submit a window of commands, then read their results, to
// keep each pipeline deep and reach the highest throughput (measured locally over loopback: ~2-3M SET/sec; indicative).
//
// By default, Options.AutoPipelineOptions is used if set,
// otherwise DefaultAutoPipelineOptions (ordered, MaxConcurrentBatches: 1) — a
// single goroutine's deferred commands execute in submit order. Use AsyncAutoPipelineWithOptions
// to override (and, for parallel batches, set Unordered). The instance is
// cached and shared; the first call's config wins. Close it (or the client) to
// release its goroutines.
//
// It returns an error if the supplied config is invalid (e.g. MaxConcurrentBatches>1
// without Unordered, or a negative size); on error no instance is cached.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) AsyncAutoPipeline() (*AutoPipeliner, error) {
return c.AsyncAutoPipelineWithOptions(nil)
}
// AsyncAutoPipelineWithOptions is AsyncAutoPipeline with an explicit config
// instead of Options.AutoPipelineOptions / the default. The instance is cached
// and shared; the first call's config wins.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return getOrCreateAutoPipeliner(c.autopipelinerMu, &c.asyncAutopipeliner, &c.autopipelinerClosed, c.baseClient.apClosed, c.baseClient.onClose, onCloseHookIDAsyncAutoPipeline, config,
func() *AutoPipelineOptions {
if c.opt.AutoPipelineOptions != nil {
return c.opt.AutoPipelineOptions
}
return DefaultAutoPipelineOptions()
},
func(cfg *AutoPipelineOptions) (*AutoPipeliner, error) { return newAutoPipeliner(c, cfg, false) })
}
func (c *Client) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.TxPipeline().Pipelined(ctx, fn)
}
// TxPipeline acts like Pipeline, but wraps queued commands with MULTI/EXEC.
func (c *Client) TxPipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
cmds = wrapMultiExec(ctx, cmds)
return c.processTxPipelineHook(ctx, cmds)
},
}
pipe.init()
return &pipe
}
func (c *Client) pubSub() *PubSub {
pubsub := &PubSub{
opt: c.cloneOpt(),
newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) {
cn, err := c.pubSubPool.NewConn(ctx, c.opt.Network, addr, channels)
if err != nil {
return nil, err
}
// will return nil if already initialized
err = c.initConn(ctx, cn)
if err != nil {
_ = cn.Close()
return nil, err
}
// Track connection in PubSubPool
c.pubSubPool.TrackConn(cn)
return cn, nil
},
closeConn: func(cn *pool.Conn) error {
// Untrack connection from PubSubPool
c.pubSubPool.UntrackConn(cn)
_ = cn.Close()
return nil
},
pushProcessor: c.pushProcessor,
}
pubsub.init()
return pubsub
}
// Subscribe subscribes the client to the specified channels.
// Channels can be omitted to create empty subscription.
// Note that this method does not wait on a response from Redis, so the
// subscription may not be active immediately. To force the connection to wait,
// you may call the Receive() method on the returned *PubSub like so:
//
// sub := client.Subscribe(queryResp)
// iface, err := sub.Receive()
// if err != nil {
// // handle error
// }
//
// // Should be *Subscription, but others are possible if other actions have been
// // taken on sub since it was created.
// switch iface.(type) {
// case *Subscription:
// // subscribe succeeded
// case *Message:
// // received first message
// case *Pong:
// // pong received
// default:
// // handle error
// }
//
// ch := sub.Channel()
func (c *Client) Subscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.Subscribe(ctx, channels...)
}
return pubsub
}
// PSubscribe subscribes the client to the given patterns.
// Patterns can be omitted to create empty subscription.
func (c *Client) PSubscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.PSubscribe(ctx, channels...)
}
return pubsub
}
// SSubscribe Subscribes the client to the specified shard channels.
// Channels can be omitted to create empty subscription.
func (c *Client) SSubscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.SSubscribe(ctx, channels...)
}
return pubsub
}
//------------------------------------------------------------------------------
// Conn represents a single Redis connection rather than a pool of connections.
// Prefer running commands from Client unless there is a specific need
// for a continuous single Redis connection.
type Conn struct {
baseClient
cmdable
statefulCmdable
}
// newConn is a helper func to create a new Conn instance.
// The Conn instance is not thread-safe and should not be shared between goroutines.
// The parentHooks will be cloned, no need to clone before passing it.
// himport is the HIMPORT fieldset registry the Conn participates in — pass
// the owning client's registry (a private one would restart versions at 1
// and collide with the client's version space on the shared pooled
// connections); nil disables HIMPORT tracking.
func newConn(opt *Options, connPool pool.Pooler, parentHooks *hooksMixin, himport *himportRegistry) *Conn {
c := Conn{
baseClient: baseClient{
apClosed: &atomic.Bool{},
opt: opt,
connPool: connPool,
onClose: &onCloseHooks{},
himport: himport,
},
}
if parentHooks != nil {
c.hooksMixin = parentHooks.clone()
}
// Initialize push notification processor using shared helper
// Use void processor for RESP2 connections (push notifications not available)
c.pushProcessor = initializePushProcessor(opt)
c.cmdable = c.Process
c.statefulCmdable = c.Process
c.initHooks(hooks{
dial: c.baseClient.dial,
process: c.baseClient.process,
pipeline: c.baseClient.processPipeline,
txPipeline: c.baseClient.processTxPipeline,
})
return &c
}
func (c *Conn) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
// RegisterPushNotificationHandler registers a handler for a specific push notification name.
// Returns an error if a handler is already registered for this push notification name.
// If protected is true, the handler cannot be unregistered.
func (c *Conn) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error {
return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected)
}
func (c *Conn) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipeline().Pipelined(ctx, fn)
}
func (c *Conn) Pipeline() Pipeliner {
pipe := Pipeline{
exec: c.processPipelineHook,
}
pipe.init()
return &pipe
}
func (c *Conn) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.TxPipeline().Pipelined(ctx, fn)
}
// TxPipeline acts like Pipeline, but wraps queued commands with MULTI/EXEC.
func (c *Conn) TxPipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
cmds = wrapMultiExec(ctx, cmds)
return c.processTxPipelineHook(ctx, cmds)
},
}
pipe.init()
return &pipe
}
// processPushNotifications processes all pending push notifications on a connection
// This ensures that cluster topology changes are handled immediately before the connection is used
// This method should be called by the client before using WithReader for command execution
//
// Performance optimization: Skip the expensive MaybeHasData() syscall if a health check
// was performed recently (within 5 seconds). The health check already verified the connection
// is healthy and checked for unexpected data (push notifications).
func (c *baseClient) processPushNotifications(ctx context.Context, cn *pool.Conn) error {
// Only process push notifications for RESP3 connections with a processor
if c.opt.Protocol != 3 || c.pushProcessor == nil {
return nil
}
// Performance optimization: Skip MaybeHasData() syscall if health check was recent
// If the connection was health-checked within the last 5 seconds, we can skip the
// expensive syscall since the health check already verified no unexpected data.
// This is safe because:
// 0. lastHealthCheckNs is set in pool/conn.go:putConn() after a successful health check
// 1. Health check (connCheck) uses the same syscall (Recvfrom with MSG_PEEK)
// 2. If push notifications arrived, they would have been detected by health check
// 3. 5 seconds is short enough that connection state is still fresh
// 4. Push notifications will be processed by the next WithReader call
// used it is set on getConn, so we should use another timer (lastPutAt?)
lastHealthCheckNs := cn.LastPutAtNs()
if lastHealthCheckNs > 0 {
// Use pool's cached time to avoid expensive time.Now() syscall
nowNs := pool.GetCachedTimeNs()
if nowNs-lastHealthCheckNs < int64(5*time.Second) {
// Recent health check confirmed no unexpected data, skip the syscall
return nil
}
}
return c.peekAndProcessPushNotifications(ctx, cn)
}
// peekAndProcessPushNotifications peeks the socket and processes any pending
// push notifications on cn unconditionally, bypassing the recent-health-check
// shortcut in processPushNotifications. Required on paths that do not follow
// up with a reply read on the same connection (e.g. the CSC cache-hit drain),
// where the shortcut would otherwise suppress invalidations buffered since the
// last health check.
func (c *baseClient) peekAndProcessPushNotifications(ctx context.Context, cn *pool.Conn) error {
if c.opt.Protocol != 3 || c.pushProcessor == nil {
return nil
}
// Also drain when the reader already holds buffered bytes (HasBufferedData),
// not only when the socket is readable (MaybeHasData). A reply and a trailing
// invalidation can arrive in one socket read. The invalidation then stays in
// the reader buffer while the socket is empty. MaybeHasData alone would skip
// it and serve stale data until the next miss or MaxStaleness (#3965).
buffered := cn.HasBufferedData()
if !buffered && !cn.MaybeHasData() {
return nil
}
// Drain even for a custom processor only when the reader already holds buffered
// bytes. NOTE: HasBufferedData is byte-level (rd.Buffered() > 0), NOT proof of a
// COMPLETE frame — a reply plus a partial trailing push in one socket read
// leaves a partial push buffered. Handing that to a custom processor lets it
// block to complete the frame under the hard cap; a mid-frame timeout surfaces
// an error and the caller retires the (now-desynced) connection. That outcome
// is SAFE (retiring a desynced conn is correct), but it does NOT eliminate
// conn churn for custom processors — the earlier "buffered == a complete, safe
// frame" claim was wrong.
//
// The gate is still worth it against the WORSE case: MaybeHasData WITHOUT
// buffered data proves only that the raw socket is readable, which over TLS can
// be a post-handshake control record with zero RESP bytes (conn_check.go
// refuses to unwrap TLS for this reason; maybeHasData does). A custom processor
// handed that empty input times out on an empty read → the FD session reader
// treats the timeout as fatal and closes a HEALTHY connection, failing its
// in-flight misses. The built-in buffered processor accepts the empty read. So
// a custom processor drains only on real buffered data. Tradeoff: a push that
// arrives as bare socket readiness on a custom, idle FD session waits for the
// next reply read, the session recycle, or the MaxStaleness backstop; the
// connection stays open. Mirrors the built-in-only guard on the opaque-
// transport probe in the FD session tick (csc_miss_coalesce_modes.go).
if !buffered {
if _, builtin := c.pushProcessor.(*push.Processor); !builtin {
return nil
}
}
// Readiness is established, so a frame is (probably) present: drain under the
// longer fragmented-frame budget the background drainer uses, NOT the 1ms
// no-data probe cap. A push can arrive in fragments more than 1ms apart —
// notably over TLS, where MaybeHasData only proves that some ciphertext is
// readable — and the 1ms cap would then time out after consuming the prefix,
// which is treated as fatal and fatally closes a healthy session (failing its
// in-flight misses). The 1ms cap is reserved for speculative no-data probes
// (timedPushDrain, the opaque-transport fallback).
return c.pushDrainWithin(ctx, cn, cscDrainHardReadCap)
}
// timedPushDrain runs a speculative no-data push probe under a 1ms hard read
// deadline: for callers WITHOUT a readiness signal (the opaque-transport
// fallback on a held full-duplex connection), which pay at most 1ms when
// nothing is pending. Callers that HAVE established readiness use
// pushDrainWithin(cscDrainHardReadCap) instead, to tolerate fragmented frames.
func (c *baseClient) timedPushDrain(ctx context.Context, cn *pool.Conn) error {
return c.pushDrainWithin(ctx, cn, time.Millisecond)
}
// pushDrainWithin runs one push drain on cn. HARD read deadlines (not WithReader) so
// an ordinary drain cannot silently inherit an unrelated deadline, and
// WithReaderHardDeadline clears the deadline on exit, so no residue poisons a later
// deadline-less read.
//
// The FIRST-byte wait is bounded by the short cap d; only a frame already begun gets
// the relaxation-aware budget (pushDrainBudget) to finish. Applying the relaxed budget
// to the first-byte wait would let a TLS control record — socket-readable with zero
// RESP bytes — block a speculative drain for the full relaxed timeout (seconds),
// stalling a ready command or the idle drainer (#3989). So under active relaxation,
// probe one byte under d first; a timeout there means no frame began (benign). The
// relaxed budget exists only to tolerate a fragmented frame mid-flight during a
// failover — see pushDrainBudget.
func (c *baseClient) pushDrainWithin(ctx context.Context, cn *pool.Conn, d time.Duration) error {
budget := pushDrainBudget(cn, d)
if budget > d {
// Relaxation active: confirm a RESP byte under the short cap before granting the
// longer budget. The peeked byte stays buffered for the drain below.
if err := cn.WithReaderHardDeadline(d, func(rd *proto.Reader) error {
_, perr := rd.Peek(1)
return perr
}); err != nil {
if isTimeout, hasFlag := isTimeoutError(err); isTimeout && hasFlag {
return nil // no frame began within the short cap
}
return err
}
}
return cn.WithReaderHardDeadline(budget, func(rd *proto.Reader) error {
// A speculative probe on a possibly-idle conn: stop when the reader buffer
// empties (Buffered variant) rather than block waiting for a reply.
return c.drainPushFrames(ctx, cn, rd, false)
})
}
// pushDrainBudget returns the read budget for one push drain: the caller's hard
// cap d, raised to the connection's relaxed timeout while maintenance relaxation
// is active. The raise matters exactly when a push frame is mid-flight on a slow
// server: during a failover/migration — the very window relaxation covers — a
// frame can fragment past a small cap, and a mid-frame timeout is a desync
// (partial frame consumed), so the caller retires the connection and, on the
// pre-command path, fails the command outright when MaxRetries=0. Holding the
// conn up to the relaxed budget is the lesser cost, and it is paid only when
// bytes are actually mid-frame: an empty or completed drain returns without
// blocking regardless of the budget. Without active relaxation
// EffectiveReadTimeout returns d unchanged, keeping the small hard cap that
// protects a small pool from a parked speculative drain.
func pushDrainBudget(cn *pool.Conn, d time.Duration) time.Duration {
if rel := cn.EffectiveReadTimeout(d); rel > d {
return rel
}
return d
}
// drainPushFrames processes pending push notifications on rd. The caller already
// holds rd under a read deadline (WithReader or WithReaderHardDeadline); this
// helper does not set one, so it is safe to call from a reader that must keep
// reading afterward (the CSC miss reader reads the command reply next).
//
// blocking selects the built-in processor's drain discipline:
//
// - blocking=true (reply-expected readers: the CSC miss and refresh readers):
// block on the socket and skip push frames until a NON-push frame — the
// command reply — is next, then return leaving it buffered. This is the same
// non-buffered discipline the full-duplex reader already uses. The Buffered
// variant instead stops the instant the reader buffer empties; if a second
// invalidation is still on the socket ahead of the reply, the caller's
// ReadRawReply would then read THAT push as the reply and cache it under the
// wrong key — a one-frame shift that cascades to every later reply. Note
// PeekReplyType is attribute-aware (it discards a RESP3 attribute prefix and
// recurses), so a fragmented attribute needs no separate buffered scan. A
// boundary-peek TIMEOUT the non-buffered loop swallows is caught by the
// caller's shared read deadline: ReadRawReply hits the same expired deadline
// and fails the session. This is the same non-buffered discipline the
// full-duplex reader already runs; a swallowed NON-timeout peek error would
// need a malformed RESP3 attribute mid-push (a server protocol bug), so this
// path is no weaker than that one. Pub/sub-named pushes, which the drain
// leaves buffered, cannot reach a CSC-held conn: subscriptions route to
// PubSub-owned connections.
//
// - blocking=false (readiness-established probes: pushDrainWithin, the FD
// session tick, the background drainer): use the Buffered variant, which
// drains the current batch and stops when the reader buffer empties so the
// probe never blocks waiting for a reply that will not come on an idle conn.
// An error AFTER partially consuming a fragmented frame is PROPAGATED, not
// swallowed, so a mid-frame desync cannot later be read as a command reply.
//
// A custom processor is handed only a confirmed push frame in either mode: the
// interface does not promise the built-in's peek-and-consume-only-push behavior,
// and the next frame can be a coalesced REPLY. Peek the type first, propagate the
// peek error (PeekReplyType can partially consume an attribute via DiscardNext
// before it errors), and skip a non-push frame so a custom processor cannot
// consume the reply; ProcessPendingNotifications then blocks and skips pushes
// until a non-push is next, matching the built-in blocking discipline.
func (c *baseClient) drainPushFrames(ctx context.Context, cn *pool.Conn, rd *proto.Reader, blocking bool) error {
handlerCtx := c.pushNotificationHandlerContext(cn)
// Nonblocking Close adapter: this drain runs on CSC-held paths (the FD
// session reader and tick among them), where a custom handler calling Close()
// on the raw client would deadlock — Close waits on the coalescer's
// WaitGroup, which includes the very goroutine parked in the handler.
handlerCtx.Client = cscHandlerClient{baseClient: c}
if processor, ok := c.pushProcessor.(*push.Processor); ok {
if blocking {
return processor.ProcessPendingNotifications(ctx, handlerCtx, rd)
}
return processor.ProcessPendingNotificationsBuffered(ctx, handlerCtx, rd)
}
// Custom processor: peek-then-process in a LOOP for blocking mode. A single
// peek+process drained only the first push (and whatever that one invocation
// consumed); a SECOND invalidation still on the socket ahead of the reply would
// then be read by the caller's ReadRawReply as the command value — published to
// the cache under the wrong key and shifting every later reply. PeekReplyType
// blocks on the socket for the next frame, so looping skips every push (buffered
// OR socket-pending) until a non-push (the reply) is next, matching the built-in
// blocking discipline. Non-blocking probes keep the single-pass behavior (one
// drain, never block waiting for a reply that will not come on an idle conn).
for {
t, err := rd.PeekReplyType()
if err != nil {
return err
}
if t != proto.RespPush {
return nil
}
if err := c.pushProcessor.ProcessPendingNotifications(ctx, handlerCtx, rd); err != nil {
return err
}
if !blocking {
return nil
}
}
}
// cscFallbackProbeInterval bounds how often an idle connection without a
// portable readiness mechanism is subjected to a timed read. Post-command
// probes remain immediate; this is only the eventual invalidation fallback.
const cscFallbackProbeInterval = 100 * time.Millisecond
// drainPushNotifications drains push frames buffered on a connection the CSC
// drainer has claimed, under a HARD read deadline. processorSucceeded reports a
// successful processor invocation; it resets custom-processor damping even when
// the frame was hidden inside a transport wrapper. A non-nil error is
// connection-fatal (the drainer removes the conn), including a read timeout
// after reply consumption starts: the reader may be desynchronized. A custom
// processor's error is also fatal because its contract cannot prove no bytes
// were consumed.
func (c *baseClient) drainPushNotifications(cn *pool.Conn) (processorSucceeded bool, err error) {
if c.opt.Protocol != 3 || c.pushProcessor == nil {
return false, nil
}
// Skip only when nothing is buffered (reader) AND nothing on the socket:
// MaybeHasData peeks only the socket, but an invalidate can sit in cn.rd.
readPending := cn.TakeCscReadPending()
periodicReadPending := cn.TakeCscPeriodicReadPending(cscFallbackProbeInterval)
socketData, socketErr := cn.CheckForData()
if socketErr != nil {
return false, socketErr
}
// Capture the already-buffered state BEFORE the probe below: it decides whether a
// custom processor may run (see the custom-processor gate). The probe can peek a
// byte into the buffer, but a byte fetched from the socket/wrapper is NOT the
// "real bytes already buffered" the spec requires for a custom processor.
buffered := cn.HasBufferedData()
hasData := buffered || socketData
if !readPending && !periodicReadPending && !hasData {
return false, nil
}
// No deadline cleanup is needed here: WithReaderHardDeadline (the probe and
// drain reads below) already restores a cleared read deadline in its own defer
// (SetReadDeadline(time.Time{}) — see its doc). A previous WithReader(ctx, 0)
// cleanup here was both redundant and wrong: under an active relaxed
// maintenance timeout it re-armed a relaxed deadline (getEffectiveReadTimeout
// returns the relaxed value even for timeout 0), which a ReadTimeout<0 conn
// then never clears on its next read, causing a spurious timeout later.
if !hasData {
// TLS and opaque wrappers can hide bytes from the socket readiness
// check. Probe one byte without consuming it under a tiny deadline;
// only a confirmed byte gets the longer fragmented-frame budget below.
err := cn.WithReaderHardDeadline(cscDrainProbeReadCap, func(rd *proto.Reader) error {
_, err := rd.Peek(1)
return err
})
if err != nil {
if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout && hasTimeoutFlag {
return false, nil
}
return false, err
}
}
// Custom-processor gate (spec: .claude/specs/push.md): a custom processor
// implements only the blocking loop, so it gets work only when real RESP bytes
// were ALREADY buffered — never on kernel-only readability, which over TLS can be
// a control record with zero RESP bytes, and never on a byte the probe above just
// fetched from the socket/wrapper. Handing it non-buffered data would run the
// blocking loop into the hard cap; its error is always fatal for a custom
// processor and would retire a healthy conn (the spurious-retire the spec warns
// of). Skipping is bounded: the periodic fallback re-probes and MaxStaleness
// backstops. The built-in buffered processor accepts kernel-only readability (a
// boundary timeout is benign), so it still proceeds. Gate on `buffered` captured
// before the probe, mirroring peekAndProcessPushNotifications.
if !buffered {
if _, builtin := c.pushProcessor.(*push.Processor); !builtin {
return false, nil
}
}
handlerCtx := c.pushNotificationHandlerContext(cn)
handlerCtx.Client = cscHandlerClient{baseClient: c}
// Relaxation-aware cap (spec: pushDrainBudget): during a failover/migration a push
// frame can fragment past the small hard cap, and a mid-frame timeout is a fatal
// desync that would evict this conn's CSC coverage in the very window relaxation
// covers. Raise the cap to the relaxed timeout while relaxation is active; without
// it EffectiveReadTimeout returns the unchanged hard cap.
budget := pushDrainBudget(cn, cscDrainHardReadCap)
if budget > cscDrainHardReadCap {
// Relaxation active: confirm a RESP byte under the short cap before granting the
// relaxed budget. Otherwise a TLS control record — socket-readable (hasData) with
// zero RESP bytes and nothing buffered — makes the drain's Peek(1) block for the
// full relaxed timeout, and the single-goroutine idle drainer stalls the whole
// pass for seconds (#3989). A byte already buffered (the common case, including
// the !hasData probe above) makes this Peek return immediately. Mirrors
// pushDrainWithin, which the reply-path checkpoint already routes through.
if perr := cn.WithReaderHardDeadline(cscDrainHardReadCap, func(rd *proto.Reader) error {
_, e := rd.Peek(1)
return e
}); perr != nil {
if isTimeout, hasFlag := isTimeoutError(perr); isTimeout && hasFlag {
return false, nil // no frame began within the short cap — benign
}
return false, perr
}
}
err = cn.WithReaderHardDeadline(budget, func(rd *proto.Reader) error {
if processor, ok := c.pushProcessor.(*push.Processor); ok {
return processor.ProcessPendingNotificationsBuffered(
context.Background(), handlerCtx, rd,
)
}
return c.pushProcessor.ProcessPendingNotifications(context.Background(), handlerCtx, rd)
})
if err != nil {
// The built-in processor surfaces mid-frame ReadReply errors (a benign
// boundary peek timeout returns nil). allowTimeout=false: such an error
// means bytes were consumed mid-frame, leaving the conn desynced —
// re-pooling would corrupt the next command's reply, so remove it.
if _, builtin := c.pushProcessor.(*push.Processor); builtin {
if isBadConn(err, false, c.opt.Addr) {
return true, err // fatal read/protocol/connection error — remove the conn
}
return true, nil
}
// A CUSTOM processor's error contract is unknown: it may have consumed
// part of a frame before failing, and a mid-frame reader silently
// corrupts the next command's reply. The conn is idle and held solely
// by the drainer, so the safe default — removal — costs one reconnect;
// persistent failures are damped by the drainer (cscDrainCustomErrCap).
internal.Logger.Printf(context.Background(), "csc: drain: custom push processor error (removing conn): %v", err)
return true, err
}
// The processor ran successfully. This is stronger evidence than a clean
// connection on which it was never invoked, and prevents successful TLS-
// buffered drains from being counted as if failures were consecutive.
return true, nil
}
// processPendingPushNotificationWithReader processes all pending push notifications on a connection
// This method should be called by the client in WithReader before reading the reply
func (c *baseClient) processPendingPushNotificationWithReader(ctx context.Context, cn *pool.Conn, rd *proto.Reader) error {
// if we have the reader, we don't need to check for data on the socket, we are waiting
// for either a reply or a push notification, so we can block until we get a reply or reach the timeout
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)
}
// pushNotificationHandlerContext creates a handler context for push notification processing
func (c *baseClient) pushNotificationHandlerContext(cn *pool.Conn) push.NotificationHandlerContext {
// Report the pool that actually owns cn, not always the main pool: with a
// dedicated pipeline pool (now created for default clients), a notification
// received while running an ordinary pipeline arrives on a pipeline-owned
// connection, and a handler that inspects or operates on ConnPool must target
// that pool — otherwise it modifies the main pool while the real pipeline
// connection is left untouched. poolForConn derefs cn, so guard nil.
connPool := pool.Pooler(c.connPool)
if cn != nil {
connPool = c.poolForConn(cn)
}
return push.NotificationHandlerContext{
Client: c,
ConnPool: connPool,
Conn: cn, // Wrap in adapter for easier interface access
}
}
package redis
import "time"
// NewCmdResult returns a Cmd initialised with val and err for testing.
func NewCmdResult(val interface{}, err error) *Cmd {
var cmd Cmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewSliceResult returns a SliceCmd initialised with val and err for testing.
func NewSliceResult(val []interface{}, err error) *SliceCmd {
var cmd SliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewStatusResult returns a StatusCmd initialised with val and err for testing.
func NewStatusResult(val string, err error) *StatusCmd {
var cmd StatusCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewIntResult returns an IntCmd initialised with val and err for testing.
func NewIntResult(val int64, err error) *IntCmd {
var cmd IntCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewDurationResult returns a DurationCmd initialised with val and err for testing.
func NewDurationResult(val time.Duration, err error) *DurationCmd {
var cmd DurationCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewBoolResult returns a BoolCmd initialised with val and err for testing.
func NewBoolResult(val bool, err error) *BoolCmd {
var cmd BoolCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewStringResult returns a StringCmd initialised with val and err for testing.
func NewStringResult(val string, err error) *StringCmd {
var cmd StringCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewFloatResult returns a FloatCmd initialised with val and err for testing.
func NewFloatResult(val float64, err error) *FloatCmd {
var cmd FloatCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewStringSliceResult returns a StringSliceCmd initialised with val and err for testing.
func NewStringSliceResult(val []string, err error) *StringSliceCmd {
var cmd StringSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewBoolSliceResult returns a BoolSliceCmd initialised with val and err for testing.
func NewBoolSliceResult(val []bool, err error) *BoolSliceCmd {
var cmd BoolSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewFloatSliceResult returns a FloatSliceCmd initialised with val and err for testing.
func NewFloatSliceResult(val []float64, err error) *FloatSliceCmd {
var cmd FloatSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewMapStringStringResult returns a MapStringStringCmd initialised with val and err for testing.
func NewMapStringStringResult(val map[string]string, err error) *MapStringStringCmd {
var cmd MapStringStringCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewMapStringIntCmdResult returns a MapStringIntCmd initialised with val and err for testing.
func NewMapStringIntCmdResult(val map[string]int64, err error) *MapStringIntCmd {
var cmd MapStringIntCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewTimeCmdResult returns a TimeCmd initialised with val and err for testing.
func NewTimeCmdResult(val time.Time, err error) *TimeCmd {
var cmd TimeCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewZSliceCmdResult returns a ZSliceCmd initialised with val and err for testing.
func NewZSliceCmdResult(val []Z, err error) *ZSliceCmd {
var cmd ZSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewZWithKeyCmdResult returns a ZWithKeyCmd initialised with val and err for testing.
func NewZWithKeyCmdResult(val *ZWithKey, err error) *ZWithKeyCmd {
var cmd ZWithKeyCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewScanCmdResult returns a ScanCmd initialised with val and err for testing.
func NewScanCmdResult(keys []string, cursor uint64, err error) *ScanCmd {
var cmd ScanCmd
cmd.page = keys
cmd.cursor = cursor
cmd.SetErr(err)
return &cmd
}
// NewClusterSlotsCmdResult returns a ClusterSlotsCmd initialised with val and err for testing.
func NewClusterSlotsCmdResult(val []ClusterSlot, err error) *ClusterSlotsCmd {
var cmd ClusterSlotsCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewGeoLocationCmdResult returns a GeoLocationCmd initialised with val and err for testing.
func NewGeoLocationCmdResult(val []GeoLocation, err error) *GeoLocationCmd {
var cmd GeoLocationCmd
cmd.locations = val
cmd.SetErr(err)
return &cmd
}
// NewGeoPosCmdResult returns a GeoPosCmd initialised with val and err for testing.
func NewGeoPosCmdResult(val []*GeoPos, err error) *GeoPosCmd {
var cmd GeoPosCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewCommandsInfoCmdResult returns a CommandsInfoCmd initialised with val and err for testing.
func NewCommandsInfoCmdResult(val map[string]*CommandInfo, err error) *CommandsInfoCmd {
var cmd CommandsInfoCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewXMessageSliceCmdResult returns a XMessageSliceCmd initialised with val and err for testing.
func NewXMessageSliceCmdResult(val []XMessage, err error) *XMessageSliceCmd {
var cmd XMessageSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewXStreamSliceCmdResult returns a XStreamSliceCmd initialised with val and err for testing.
func NewXStreamSliceCmdResult(val []XStream, err error) *XStreamSliceCmd {
var cmd XStreamSliceCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
// NewXPendingResult returns a XPendingCmd initialised with val and err for testing.
func NewXPendingResult(val *XPending, err error) *XPendingCmd {
var cmd XPendingCmd
cmd.val = val
cmd.SetErr(err)
return &cmd
}
package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/hashtag"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
)
var errRingShardsDown = errors.New("redis: all ring shards are down")
// defaultHeartbeatFn is the default function used to check the shard liveness
var defaultHeartbeatFn = func(ctx context.Context, client *Client) bool {
err := client.Ping(ctx).Err()
return err == nil || err == pool.ErrPoolTimeout
}
//------------------------------------------------------------------------------
type ConsistentHash interface {
Get(string) string
}
func newRendezvous(shards []string) ConsistentHash {
return hashtag.NewRendezvousHash(shards)
}
//------------------------------------------------------------------------------
// RingOptions are used to configure a ring client and should be
// passed to NewRing.
type RingOptions struct {
// Map of name => host:port addresses of ring shards.
Addrs map[string]string
// NewClient creates a shard client with provided options.
NewClient func(opt *Options) *Client
// himport is the ring-wide HIMPORT fieldset registry, set by NewRing and
// shared with every shard client (see himport.go, himport_cluster.go).
himport *himportRegistry
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
ClientName string
// Frequency of executing HeartbeatFn to check shards availability.
// Shard is considered down after 3 subsequent failed checks.
HeartbeatFrequency time.Duration
// A function used to check the shard liveness
// if not set, defaults to defaultHeartbeatFn
HeartbeatFn func(ctx context.Context, client *Client) bool
// NewConsistentHash returns a consistent hash that is used
// to distribute keys across the shards.
//
// See https://medium.com/@dgryski/consistent-hashing-algorithmic-tradeoffs-ef6b8e2fcae8
// for consistent hashing algorithmic tradeoffs.
NewConsistentHash func(shards []string) ConsistentHash
// Following options are copied from Options struct.
Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
OnConnect func(ctx context.Context, cn *Conn) error
Protocol int
Username string
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 int
MaxRetries int
MinRetryBackoff time.Duration
MaxRetryBackoff time.Duration
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.
// See Options.DialerRetryBackoff for details.
DialerRetryBackoff func(attempt int) time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
ContextTimeoutEnabled bool
// PoolFIFO uses FIFO mode for each node connection pool GET/PUT (default LIFO).
PoolFIFO bool
PoolSize int
PoolTimeout time.Duration
MinIdleConns int
MaxIdleConns int
MaxActiveConns int
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
ConnMaxLifetimeJitter time.Duration
// 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
// PipelineReadBufferSize, PipelineWriteBufferSize and PipelinePoolSize
// configure the separate connection pool used for pipelining on each shard,
// with its own (typically larger) buffers. See the same-named fields on
// Options for details. Each shard client (a NewClient) creates this pool by
// default; set PipelinePoolSize < 0 to opt out (pipelines run on the main pool).
PipelineReadBufferSize int
PipelineWriteBufferSize int
PipelinePoolSize int
TLSConfig *tls.Config
Limiter Limiter
// 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
IdentitySuffix string
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
}
func (opt *RingOptions) init() {
if opt.NewClient == nil {
opt.NewClient = func(opt *Options) *Client {
return NewClient(opt)
}
}
if opt.HeartbeatFrequency == 0 {
opt.HeartbeatFrequency = 500 * time.Millisecond
}
if opt.HeartbeatFn == nil {
opt.HeartbeatFn = defaultHeartbeatFn
}
if opt.NewConsistentHash == nil {
opt.NewConsistentHash = newRendezvous
}
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 = 10 * time.Millisecond
}
switch opt.MaxRetryBackoff {
case -1:
opt.MaxRetryBackoff = 0
case 0:
opt.MaxRetryBackoff = time.Second
}
if opt.ReadBufferSize == 0 {
opt.ReadBufferSize = proto.DefaultBufferSize
}
if opt.WriteBufferSize == 0 {
opt.WriteBufferSize = proto.DefaultBufferSize
}
}
func (opt *RingOptions) clientOptions() *Options {
return &Options{
ClientName: opt.ClientName,
Dialer: opt.Dialer,
OnConnect: opt.OnConnect,
Protocol: opt.Protocol,
Username: opt.Username,
Password: opt.Password,
CredentialsProvider: opt.CredentialsProvider,
CredentialsProviderContext: opt.CredentialsProviderContext,
StreamingCredentialsProvider: opt.StreamingCredentialsProvider,
DB: opt.DB,
MaxRetries: -1,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
ContextTimeoutEnabled: opt.ContextTimeoutEnabled,
PoolFIFO: opt.PoolFIFO,
PoolSize: opt.PoolSize,
PoolTimeout: opt.PoolTimeout,
MinIdleConns: opt.MinIdleConns,
MaxIdleConns: opt.MaxIdleConns,
MaxActiveConns: opt.MaxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
ReadBufferSize: opt.ReadBufferSize,
WriteBufferSize: opt.WriteBufferSize,
PipelineReadBufferSize: opt.PipelineReadBufferSize,
PipelineWriteBufferSize: opt.PipelineWriteBufferSize,
PipelinePoolSize: opt.PipelinePoolSize,
TLSConfig: opt.TLSConfig,
Limiter: opt.Limiter,
DisableIdentity: opt.DisableIdentity,
DisableIndentity: opt.DisableIndentity,
IdentitySuffix: opt.IdentitySuffix,
UnstableResp3: opt.UnstableResp3,
}
}
//------------------------------------------------------------------------------
type ringShard struct {
Client *Client
down atomic.Int32
addr string
}
func newRingShard(opt *RingOptions, addr string) *ringShard {
clopt := opt.clientOptions()
clopt.Addr = addr
shard := &ringShard{
Client: opt.NewClient(clopt),
addr: addr,
}
// Share the ring-wide HIMPORT fieldset registry so any shard connection
// serving an HIMPORT SET can lazily replay the PREPARE.
if opt.himport != nil {
shard.Client.himport = opt.himport
}
return shard
}
func (shard *ringShard) String() string {
var state string
if shard.IsUp() {
state = "up"
} else {
state = "down"
}
return fmt.Sprintf("%s is %s", shard.Client, state)
}
func (shard *ringShard) IsDown() bool {
const threshold = 3
return shard.down.Load() >= threshold
}
func (shard *ringShard) IsUp() bool {
return !shard.IsDown()
}
// Vote votes to set shard state and returns true if state was changed.
func (shard *ringShard) Vote(up bool) bool {
if up {
changed := shard.IsDown()
shard.down.Store(0)
return changed
}
if shard.IsDown() {
return false
}
shard.down.Add(1)
return shard.IsDown()
}
//------------------------------------------------------------------------------
type ringSharding struct {
opt *RingOptions
mu sync.RWMutex
shards *ringShards
closed bool
hash ConsistentHash
numShard int
onNewNode []func(rdb *Client)
// ensures exclusive access to SetAddrs so there is no need
// to hold mu for the duration of potentially long shard creation
setAddrsMu sync.Mutex
}
type ringShards struct {
m map[string]*ringShard
list []*ringShard
}
func newRingSharding(opt *RingOptions) *ringSharding {
c := &ringSharding{
opt: opt,
}
c.SetAddrs(opt.Addrs)
return c
}
func (c *ringSharding) OnNewNode(fn func(rdb *Client)) {
c.mu.Lock()
c.onNewNode = append(c.onNewNode, fn)
c.mu.Unlock()
}
// SetAddrs replaces the shards in use, such that you can increase and
// decrease number of shards, that you use. It will reuse shards that
// existed before and close the ones that will not be used anymore.
func (c *ringSharding) SetAddrs(addrs map[string]string) {
c.setAddrsMu.Lock()
defer c.setAddrsMu.Unlock()
cleanup := func(shards map[string]*ringShard) {
for addr, shard := range shards {
if err := shard.Client.Close(); err != nil {
internal.Logger.Printf(context.Background(), "shard.Close %s failed: %s", addr, err)
}
}
}
c.mu.RLock()
if c.closed {
c.mu.RUnlock()
return
}
existing := c.shards
onNewNode := c.onNewNode
c.mu.RUnlock()
shards, created, unused := c.newRingShards(addrs, existing, onNewNode)
c.mu.Lock()
if c.closed {
cleanup(created)
c.mu.Unlock()
return
}
c.shards = shards
c.rebalanceLocked()
c.mu.Unlock()
cleanup(unused)
}
func (c *ringSharding) newRingShards(
addrs map[string]string, existing *ringShards, onNewNode []func(rdb *Client),
) (shards *ringShards, created, unused map[string]*ringShard) {
shards = &ringShards{m: make(map[string]*ringShard, len(addrs))}
created = make(map[string]*ringShard) // indexed by addr
unused = make(map[string]*ringShard) // indexed by addr
if existing != nil {
for _, shard := range existing.list {
unused[shard.addr] = shard
}
}
for name, addr := range addrs {
if shard, ok := unused[addr]; ok {
shards.m[name] = shard
delete(unused, addr)
} else {
shard := newRingShard(c.opt, addr)
shards.m[name] = shard
created[addr] = shard
for _, fn := range onNewNode {
fn(shard.Client)
}
}
}
for _, shard := range shards.m {
shards.list = append(shards.list, shard)
}
return
}
// Warning: External exposure of `c.shards.list` may cause data races.
// So keep internal or implement deep copy if exposed.
func (c *ringSharding) List() []*ringShard {
c.mu.RLock()
defer c.mu.RUnlock()
if c.closed {
return nil
}
return c.shards.list
}
func (c *ringSharding) Hash(key string) string {
key = hashtag.Key(key)
var hash string
c.mu.RLock()
defer c.mu.RUnlock()
if c.numShard > 0 {
hash = c.hash.Get(key)
}
return hash
}
func (c *ringSharding) GetByKey(key string) (*ringShard, error) {
key = hashtag.Key(key)
c.mu.RLock()
defer c.mu.RUnlock()
if c.closed {
return nil, pool.ErrClosed
}
if c.numShard == 0 {
return nil, errRingShardsDown
}
shardName := c.hash.Get(key)
if shardName == "" {
return nil, errRingShardsDown
}
return c.shards.m[shardName], nil
}
func (c *ringSharding) GetByName(shardName string) (*ringShard, error) {
if shardName == "" {
return c.Random()
}
c.mu.RLock()
defer c.mu.RUnlock()
shard, ok := c.shards.m[shardName]
if !ok {
return nil, errors.New("redis: the shard is not in the ring")
}
return shard, nil
}
func (c *ringSharding) Random() (*ringShard, error) {
return c.GetByKey(strconv.Itoa(rand.Int()))
}
// Heartbeat monitors state of each shard in the ring.
func (c *ringSharding) Heartbeat(ctx context.Context, frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for {
select {
case <-ticker.C:
var rebalance bool
// note: `c.List()` return a shadow copy of `[]*ringShard`.
for _, shard := range c.List() {
isUp := c.opt.HeartbeatFn(ctx, shard.Client)
if shard.Vote(isUp) {
internal.Logger.Printf(ctx, "ring shard state changed: %s", shard)
rebalance = true
}
}
if rebalance {
c.mu.Lock()
c.rebalanceLocked()
c.mu.Unlock()
}
case <-ctx.Done():
return
}
}
}
// rebalanceLocked removes dead shards from the Ring.
// Requires c.mu locked.
func (c *ringSharding) rebalanceLocked() {
if c.closed {
return
}
if c.shards == nil {
return
}
liveShards := make([]string, 0, len(c.shards.m))
for name, shard := range c.shards.m {
if shard.IsUp() {
liveShards = append(liveShards, name)
}
}
c.hash = c.opt.NewConsistentHash(liveShards)
c.numShard = len(liveShards)
}
func (c *ringSharding) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.numShard
}
func (c *ringSharding) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
var firstErr error
for _, shard := range c.shards.list {
if err := shard.Client.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
c.hash = nil
c.shards = nil
c.numShard = 0
return firstErr
}
//------------------------------------------------------------------------------
// Ring is a Redis client that uses consistent hashing to distribute
// keys across multiple Redis servers (shards). It's safe for
// concurrent use by multiple goroutines.
//
// Ring monitors the state of each shard and removes dead shards from
// the ring. When a shard comes online it is added back to the ring. This
// gives you maximum availability and partition tolerance, but no
// consistency between different shards or even clients. Each client
// uses shards that are available to the client and does not do any
// coordination when shard state is changed.
//
// Ring should be used when you need multiple Redis servers for caching
// and can tolerate losing data when one of the servers dies.
// Otherwise you should use Redis Cluster.
type Ring struct {
cmdable
hooksMixin
opt *RingOptions
sharding *ringSharding
cmdsInfoCache *cmdsInfoCache
heartbeatCancelFn context.CancelFunc
}
// NewRing returns a Redis Ring client to the Redis Server specified by RingOptions.
// Passing nil RingOptions will cause a panic.
func NewRing(opt *RingOptions) *Ring {
if opt == nil {
panic("redis: NewRing nil options")
}
// Shallow-copy the options: the ring-wide HIMPORT registry is carried
// through them to shard construction, and reusing one caller-owned
// RingOptions across several rings must not make the rings share (or
// clobber each other's) registry.
optCopy := *opt
opt = &optCopy
opt.init()
// The registry must exist before the first shard is created; shards
// adopt it in newRingShard.
opt.himport = newHImportRegistry()
hbCtx, hbCancel := context.WithCancel(context.Background())
ring := Ring{
opt: opt,
sharding: newRingSharding(opt),
heartbeatCancelFn: hbCancel,
}
ring.cmdsInfoCache = newCmdsInfoCache(ring.cmdsInfo)
ring.cmdable = ring.Process
ring.initHooks(hooks{
process: ring.process,
pipeline: func(ctx context.Context, cmds []Cmder) error {
return ring.generalProcessPipeline(ctx, cmds, false)
},
txPipeline: func(ctx context.Context, cmds []Cmder) error {
return ring.generalProcessPipeline(ctx, cmds, true)
},
})
go ring.sharding.Heartbeat(hbCtx, opt.HeartbeatFrequency)
return &ring
}
func (c *Ring) SetAddrs(addrs map[string]string) {
c.sharding.SetAddrs(addrs)
}
func (c *Ring) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
// Options returns read-only *RingOptions that were used to create the client.
// Any alteration of the returned *RingOptions may result in undefined behaviour.
func (c *Ring) Options() *RingOptions {
return c.opt
}
func (c *Ring) retryBackoff(attempt int) time.Duration {
return internal.RetryBackoff(attempt, c.opt.MinRetryBackoff, c.opt.MaxRetryBackoff)
}
// PoolStats returns accumulated connection pool stats.
func (c *Ring) PoolStats() *PoolStats {
// note: `c.List()` return a shadow copy of `[]*ringShard`.
shards := c.sharding.List()
var acc PoolStats
var pipe pool.Stats
havePipe := false
for _, shard := range shards {
s := shard.Client.connPool.Stats()
acc.Hits += s.Hits
acc.Misses += s.Misses
acc.Timeouts += s.Timeouts
acc.WaitCount += s.WaitCount
acc.WaitDurationNs += s.WaitDurationNs
acc.TotalConns += s.TotalConns
acc.IdleConns += s.IdleConns
acc.StaleConns += s.StaleConns
// Each shard now creates the dedicated pipeline pool by default; fold its
// stats into acc.PipelineStats so ring monitoring reflects it too.
if pp := shard.Client.getPipelinePool(); pp != nil {
ps := pp.Stats()
pipe.Hits += ps.Hits
pipe.Misses += ps.Misses
pipe.Timeouts += ps.Timeouts
pipe.WaitCount += ps.WaitCount
pipe.WaitDurationNs += ps.WaitDurationNs
pipe.TotalConns += ps.TotalConns
pipe.IdleConns += ps.IdleConns
pipe.StaleConns += ps.StaleConns
havePipe = true
}
}
if havePipe {
acc.PipelineStats = &pipe
}
return &acc
}
// Len returns the current number of shards in the ring.
func (c *Ring) Len() int {
return c.sharding.Len()
}
// Subscribe subscribes the client to the specified channels.
func (c *Ring) Subscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
}
shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
}
return shard.Client.Subscribe(ctx, channels...)
}
// PSubscribe subscribes the client to the given patterns.
func (c *Ring) PSubscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
}
shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
}
return shard.Client.PSubscribe(ctx, channels...)
}
// SSubscribe Subscribes the client to the specified shard channels.
func (c *Ring) SSubscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
}
shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
}
return shard.Client.SSubscribe(ctx, channels...)
}
// Publish posts the message to the channel
func (c *Ring) Publish(ctx context.Context, channel string, message interface{}) *IntCmd {
shard, err := c.sharding.GetByKey(channel)
if err != nil {
cmd := NewIntCmd(ctx, "publish", channel, message)
cmd.SetErr(err)
return cmd
}
return shard.Client.Publish(ctx, channel, message)
}
func (c *Ring) OnNewNode(fn func(rdb *Client)) {
c.sharding.OnNewNode(fn)
}
// ForEachShard concurrently calls the fn on each live shard in the ring.
// It returns the first error if any.
func (c *Ring) ForEachShard(
ctx context.Context,
fn func(ctx context.Context, client *Client) error,
) error {
// note: `c.List()` return a shadow copy of `[]*ringShard`.
shards := c.sharding.List()
var wg sync.WaitGroup
errCh := make(chan error, 1)
for _, shard := range shards {
if shard.IsDown() {
continue
}
wg.Add(1)
go func(shard *ringShard) {
defer wg.Done()
err := fn(ctx, shard.Client)
if err != nil {
select {
case errCh <- err:
default:
}
}
}(shard)
}
wg.Wait()
select {
case err := <-errCh:
return err
default:
return nil
}
}
func (c *Ring) cmdsInfo(ctx context.Context) (map[string]*CommandInfo, error) {
// note: `c.List()` return a shadow copy of `[]*ringShard`.
shards := c.sharding.List()
var firstErr error
for _, shard := range shards {
cmdsInfo, err := shard.Client.Command(ctx).Result()
if err == nil {
return cmdsInfo, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
return nil, errRingShardsDown
}
return nil, firstErr
}
func (c *Ring) cmdShard(cmd Cmder) (*ringShard, error) {
// TODO: populate cmdsInfoCache lazily (via cmdsInfoCache.Get) so that
// the warm-cache branch in cmdFirstKeyPosWithInfo is reachable for Ring,
// mirroring how ClusterClient.cmdInfo works. For now pass nil
pos := cmdFirstKeyPosWithInfo(cmd, nil)
if pos == 0 {
return c.sharding.Random()
}
firstKey := cmd.stringArg(pos)
return c.sharding.GetByKey(firstKey)
}
func (c *Ring) process(ctx context.Context, cmd Cmder) error {
var lastErr error
for attempt := 0; attempt <= c.opt.MaxRetries; attempt++ {
if attempt > 0 {
if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
return err
}
}
shard, err := c.cmdShard(cmd)
if err != nil {
return err
}
lastErr = shard.Client.Process(ctx, cmd)
if lastErr == nil || !shouldRetry(lastErr, cmd.readTimeout() == nil) || cmd.NoRetry() {
return lastErr
}
}
return lastErr
}
func (c *Ring) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipeline().Pipelined(ctx, fn)
}
func (c *Ring) Pipeline() Pipeliner {
pipe := Pipeline{
exec: pipelineExecer(c.processPipelineHook),
}
pipe.init()
return &pipe
}
// ErrRingAutoPipelineUnsupported is returned by Ring's AutoPipeline /
// AsyncAutoPipeline (and their WithOptions forms); check for it with
// errors.Is. Autopipelining is not implemented for Ring; use the per-shard
// clients or a ClusterClient. Ring is part of the UniversalClient
// interface, so these methods exist to satisfy it and fail explicitly rather
// than being silently absent.
var ErrRingAutoPipelineUnsupported = errors.New("redis: AutoPipeline is not supported by Ring")
// AutoPipeline is not supported by Ring; it returns ErrRingAutoPipelineUnsupported.
func (c *Ring) AutoPipeline() (*AutoPipeliner, error) {
return c.AutoPipelineWithOptions(nil)
}
// AutoPipelineWithOptions is not supported by Ring; it returns ErrRingAutoPipelineUnsupported.
func (c *Ring) AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return nil, ErrRingAutoPipelineUnsupported
}
// AsyncAutoPipeline is not supported by Ring; it returns ErrRingAutoPipelineUnsupported.
func (c *Ring) AsyncAutoPipeline() (*AutoPipeliner, error) {
return c.AsyncAutoPipelineWithOptions(nil)
}
// AsyncAutoPipelineWithOptions is not supported by Ring; it returns ErrRingAutoPipelineUnsupported.
func (c *Ring) AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) {
return nil, ErrRingAutoPipelineUnsupported
}
func (c *Ring) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.TxPipeline().Pipelined(ctx, fn)
}
func (c *Ring) TxPipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
cmds = wrapMultiExec(ctx, cmds)
return c.processTxPipelineHook(ctx, cmds)
},
}
pipe.init()
return &pipe
}
func (c *Ring) generalProcessPipeline(
ctx context.Context, cmds []Cmder, tx bool,
) error {
if tx {
// Trim multi .. exec.
cmds = cmds[1 : len(cmds)-1]
}
cmdsMap := make(map[string][]Cmder)
for _, cmd := range cmds {
hash := cmd.stringArg(cmdFirstKeyPosWithInfo(cmd, nil))
if hash != "" {
hash = c.sharding.Hash(hash)
}
cmdsMap[hash] = append(cmdsMap[hash], cmd)
}
var wg sync.WaitGroup
errs := make(chan error, len(cmdsMap))
for hash, cmds := range cmdsMap {
wg.Add(1)
go func(hash string, cmds []Cmder) {
defer wg.Done()
// TODO: retry?
shard, err := c.sharding.GetByName(hash)
if err != nil {
setCmdsErr(cmds, err)
return
}
hook := shard.Client.processPipelineHook
if tx {
cmds = wrapMultiExec(ctx, cmds)
hook = shard.Client.processTxPipelineHook
}
if err = hook(ctx, cmds); err != nil {
errs <- err
}
}(hash, cmds)
}
wg.Wait()
close(errs)
if err := <-errs; err != nil {
return err
}
return cmdsFirstErr(cmds)
}
func (c *Ring) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error {
if len(keys) == 0 {
return fmt.Errorf("redis: Watch requires at least one key")
}
var shards []*ringShard
for _, key := range keys {
if key != "" {
shard, err := c.sharding.GetByKey(key)
if err != nil {
return err
}
shards = append(shards, shard)
}
}
if len(shards) == 0 {
return fmt.Errorf("redis: Watch requires at least one shard")
}
if len(shards) > 1 {
for _, shard := range shards[1:] {
if shard.Client != shards[0].Client {
err := fmt.Errorf("redis: Watch requires all keys to be in the same shard")
return err
}
}
}
return shards[0].Client.Watch(ctx, fn, keys...)
}
// Close closes the ring client, releasing any open resources.
//
// It is rare to Close a Ring, as the Ring is meant to be long-lived
// and shared between many goroutines.
func (c *Ring) Close() error {
c.heartbeatCancelFn()
return c.sharding.Close()
}
// GetShardClients returns a list of all shard clients in the ring.
// This can be used to create dedicated connections (e.g., PubSub) for each shard.
func (c *Ring) GetShardClients() []*Client {
shards := c.sharding.List()
clients := make([]*Client, 0, len(shards))
for _, shard := range shards {
if shard.IsUp() {
clients = append(clients, shard.Client)
}
}
return clients
}
// GetShardClientForKey returns the shard client that would handle the given key.
// This can be used to determine which shard a particular key/channel would be routed to.
func (c *Ring) GetShardClientForKey(key string) (*Client, error) {
shard, err := c.sharding.GetByKey(key)
if err != nil {
return nil, err
}
return shard.Client, nil
}
package redis
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"sync"
)
type Scripter interface {
Eval(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd
EvalSha(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd
EvalRO(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd
EvalShaRO(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd
ScriptExists(ctx context.Context, hashes ...string) *BoolSliceCmd
ScriptLoad(ctx context.Context, script string) *StringCmd
}
var (
_ Scripter = (*Client)(nil)
_ Scripter = (*Ring)(nil)
_ Scripter = (*ClusterClient)(nil)
)
type Script struct {
src string
mu sync.RWMutex
hash string
serverSHA bool // if true: do not compute SHA-1 in Go; load digest from Redis (SCRIPT LOAD)
}
func NewScript(src string) *Script {
h := sha1.New()
_, _ = io.WriteString(h, src)
return &Script{
src: src,
hash: hex.EncodeToString(h.Sum(nil)),
serverSHA: false,
}
}
// NewScriptServerSHA creates a Script that avoids computing SHA-1 in Go.
// The digest is obtained from Redis via SCRIPT LOAD (server-side hashing),
// then EVALSHA/EVALSHA_RO is used.
func NewScriptServerSHA(src string) *Script {
return &Script{
src: src,
serverSHA: true,
}
}
func (s *Script) Hash() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash
}
func (s *Script) Load(ctx context.Context, c Scripter) *StringCmd {
cmd := c.ScriptLoad(ctx, s.src)
if err := cmd.Err(); err == nil {
s.mu.Lock()
s.hash = cmd.Val()
s.mu.Unlock()
}
return cmd
}
func (s *Script) Exists(ctx context.Context, c Scripter) *BoolSliceCmd {
s.mu.RLock()
hash := s.hash
serverSHA := s.serverSHA
s.mu.RUnlock()
if hash == "" && serverSHA {
// For server-side scripts, obtain digest from Redis first.
// If hash is empty, it means SCRIPT LOAD was not called yet, so we check existence of empty hash which will return false.
// This avoids unnecessary SCRIPT LOAD just to check existence.
if err := s.ensureHash(ctx, c); err != nil {
return c.ScriptExists(ctx, "")
}
s.mu.RLock()
hash = s.hash
s.mu.RUnlock()
}
if hash == "" {
return c.ScriptExists(ctx, "")
}
return c.ScriptExists(ctx, hash)
}
func (s *Script) Eval(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
return c.Eval(ctx, s.src, keys, args...)
}
func (s *Script) EvalRO(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
return c.EvalRO(ctx, s.src, keys, args...)
}
// ensureHash ensures that s.hash is populated by using SCRIPT LOAD.
// It never calls SHA-1 in Go; Redis computes and returns the digest.
func (s *Script) ensureHash(ctx context.Context, c Scripter) error {
// Fast path: read lock, return if hash is already set.
s.mu.RLock()
if s.hash != "" {
s.mu.RUnlock()
return nil
}
s.mu.RUnlock()
// Slow path: acquire write lock and load.
s.mu.Lock()
if s.hash != "" {
s.mu.Unlock()
return nil
}
cmd := c.ScriptLoad(ctx, s.src)
if err := cmd.Err(); err != nil {
s.mu.Unlock()
return err
}
s.hash = cmd.Val()
s.mu.Unlock()
return nil
}
func (s *Script) EvalSha(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
// Default behavior: use client-side SHA-1 computed in NewScript.
if !s.serverSHA {
s.mu.RLock()
hash := s.hash
s.mu.RUnlock()
return c.EvalSha(ctx, hash, keys, args...)
}
// Server-side SHA via SCRIPT LOAD + EVALSHA.
if err := s.ensureHash(ctx, c); err != nil {
return s.Eval(ctx, c, keys, args...)
}
s.mu.RLock()
hash := s.hash
s.mu.RUnlock()
r := c.EvalSha(ctx, hash, keys, args...)
if HasErrorPrefix(r.Err(), "NOSCRIPT") {
// Script cache was flushed; reload and retry once.
if err := s.ensureHash(ctx, c); err != nil {
return s.Eval(ctx, c, keys, args...)
}
s.mu.RLock()
hash = s.hash
s.mu.RUnlock()
return c.EvalSha(ctx, hash, keys, args...)
}
return r
}
func (s *Script) EvalShaRO(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
if !s.serverSHA {
s.mu.RLock()
hash := s.hash
s.mu.RUnlock()
return c.EvalShaRO(ctx, hash, keys, args...)
}
if err := s.ensureHash(ctx, c); err != nil {
return s.EvalRO(ctx, c, keys, args...)
}
s.mu.RLock()
hash := s.hash
s.mu.RUnlock()
r := c.EvalShaRO(ctx, hash, keys, args...)
if HasErrorPrefix(r.Err(), "NOSCRIPT") {
if err := s.ensureHash(ctx, c); err != nil {
return s.EvalRO(ctx, c, keys, args...)
}
s.mu.RLock()
hash = s.hash
s.mu.RUnlock()
return c.EvalShaRO(ctx, hash, keys, args...)
}
return r
}
// Run optimistically uses EVALSHA to run the script. If script does not exist
// it is retried using EVAL.
func (s *Script) Run(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
r := s.EvalSha(ctx, c, keys, args...)
if isNoScriptErr(r.Err()) {
return s.Eval(ctx, c, keys, args...)
}
return r
}
// RunRO optimistically uses EVALSHA_RO to run the script. If script does not exist
// it is retried using EVAL_RO.
func (s *Script) RunRO(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
r := s.EvalShaRO(ctx, c, keys, args...)
if isNoScriptErr(r.Err()) {
return s.EvalRO(ctx, c, keys, args...)
}
return r
}
// isNoScriptErr reports whether err means "this digest is not cached", whether
// it arrived already normalized to ErrNoScript or as the server's raw NOSCRIPT
// error. Both are accepted because the Eval wrappers only normalize when the
// result is readable without blocking — on the deferred autopipeline face the
// raw error reaches here untouched (see cmdable.eval).
func isNoScriptErr(err error) bool {
if err == nil {
return false
}
return errors.Is(err, ErrNoScript) || HasErrorPrefix(err, "NOSCRIPT")
}
package redis
import "context"
type ScriptingFunctionsCmdable interface {
Eval(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd
EvalSha(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd
EvalRO(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd
EvalShaRO(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd
ScriptExists(ctx context.Context, hashes ...string) *BoolSliceCmd
ScriptFlush(ctx context.Context) *StatusCmd
ScriptKill(ctx context.Context) *StatusCmd
ScriptLoad(ctx context.Context, script string) *StringCmd
FunctionLoad(ctx context.Context, code string) *StringCmd
FunctionLoadReplace(ctx context.Context, code string) *StringCmd
FunctionDelete(ctx context.Context, libName string) *StringCmd
FunctionFlush(ctx context.Context) *StringCmd
FunctionKill(ctx context.Context) *StringCmd
FunctionFlushAsync(ctx context.Context) *StringCmd
FunctionList(ctx context.Context, q FunctionListQuery) *FunctionListCmd
FunctionDump(ctx context.Context) *StringCmd
FunctionRestore(ctx context.Context, libDump string) *StringCmd
FunctionStats(ctx context.Context) *FunctionStatsCmd
FCall(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd
FCallRo(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd
FCallRO(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd
}
func (c cmdable) Eval(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd {
return c.eval(ctx, "eval", script, keys, args...)
}
func (c cmdable) EvalRO(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd {
return c.eval(ctx, "eval_ro", script, keys, args...)
}
func (c cmdable) EvalSha(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd {
return c.eval(ctx, "evalsha", sha1, keys, args...)
}
func (c cmdable) EvalShaRO(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd {
return c.eval(ctx, "evalsha_ro", sha1, keys, args...)
}
func (c cmdable) eval(ctx context.Context, name, payload string, keys []string, args ...interface{}) *Cmd {
cmdArgs := make([]interface{}, 3+len(keys), 3+len(keys)+len(args))
cmdArgs[0] = name
cmdArgs[1] = payload
cmdArgs[2] = len(keys)
for i, key := range keys {
cmdArgs[3+i] = key
}
cmdArgs = appendArgs(cmdArgs, args)
cmd := NewCmd(ctx, cmdArgs...)
// it is possible that only args exist without a key.
// rdb.eval(ctx, eval, script, nil, arg1, arg2)
if len(keys) > 0 {
cmd.SetFirstKeyPos(3)
}
_ = c(ctx, cmd)
// Normalize NOSCRIPT to ErrNoScript for Script.Run/RunRO's EVAL fallback,
// but only when the result is already readable: on the deferred
// autopipeline face the call above merely enqueues, and reading the outcome
// here would await execution — making the whole Eval family synchronous on
// a face whose contract is to return immediately (review finding by codex
// on #3942). When the result is still pending the normalization is skipped;
// Script.Run/RunRO also match the raw NOSCRIPT prefix, so the fallback
// keeps working on that face.
if cmd.resultReady() {
if err := cmd.rawErr(); err != nil && HasErrorPrefix(err, "NOSCRIPT") {
cmd.SetErr(ErrNoScript)
}
}
return cmd
}
func (c cmdable) 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...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ScriptFlush(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "script", "flush")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ScriptKill(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "script", "kill")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ScriptLoad(ctx context.Context, script string) *StringCmd {
cmd := NewStringCmd(ctx, "script", "load", script)
_ = c(ctx, cmd)
return cmd
}
// ------------------------------------------------------------------------------
// FunctionListQuery is used with FunctionList to query for Redis libraries
//
// LibraryNamePattern - Use an empty string to get all libraries.
// - Use a glob-style pattern to match multiple libraries with a matching name
// - Use a library's full name to match a single library
// WithCode - If true, it will return the code of the library
type FunctionListQuery struct {
LibraryNamePattern string
WithCode bool
}
func (c cmdable) FunctionLoad(ctx context.Context, code string) *StringCmd {
cmd := NewStringCmd(ctx, "function", "load", code)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionLoadReplace(ctx context.Context, code string) *StringCmd {
cmd := NewStringCmd(ctx, "function", "load", "replace", code)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionDelete(ctx context.Context, libName string) *StringCmd {
cmd := NewStringCmd(ctx, "function", "delete", libName)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionFlush(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "function", "flush")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionKill(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "function", "kill")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionFlushAsync(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "function", "flush", "async")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionList(ctx context.Context, q FunctionListQuery) *FunctionListCmd {
args := make([]interface{}, 2, 5)
args[0] = "function"
args[1] = "list"
if q.LibraryNamePattern != "" {
args = append(args, "libraryname", q.LibraryNamePattern)
}
if q.WithCode {
args = append(args, "withcode")
}
cmd := NewFunctionListCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionDump(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "function", "dump")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionRestore(ctx context.Context, libDump string) *StringCmd {
cmd := NewStringCmd(ctx, "function", "restore", libDump)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FunctionStats(ctx context.Context) *FunctionStatsCmd {
cmd := NewFunctionStatsCmd(ctx, "function", "stats")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) FCall(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd {
cmdArgs := fcallArgs("fcall", function, keys, args...)
cmd := NewCmd(ctx, cmdArgs...)
if len(keys) > 0 {
cmd.SetFirstKeyPos(3)
}
_ = c(ctx, cmd)
return cmd
}
// FCallRo this function simply calls FCallRO,
// Deprecated: to maintain convention FCallRO.
func (c cmdable) FCallRo(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd {
return c.FCallRO(ctx, function, keys, args...)
}
func (c cmdable) FCallRO(ctx context.Context, function string, keys []string, args ...interface{}) *Cmd {
cmdArgs := fcallArgs("fcall_ro", function, keys, args...)
cmd := NewCmd(ctx, cmdArgs...)
if len(keys) > 0 {
cmd.SetFirstKeyPos(3)
}
_ = c(ctx, cmd)
return cmd
}
func fcallArgs(command string, function string, keys []string, args ...interface{}) []interface{} {
cmdArgs := make([]interface{}, 3+len(keys), 3+len(keys)+len(args))
cmdArgs[0] = command
cmdArgs[1] = function
cmdArgs[2] = len(keys)
for i, key := range keys {
cmdArgs[3+i] = key
}
cmdArgs = append(cmdArgs, args...)
return cmdArgs
}
package redis
import (
"context"
"fmt"
)
// ----------------------
// Search Module Builders
// ----------------------
// SearchBuilder provides a fluent API for FT.SEARCH
// (see original FTSearchOptions for all options).
// EXPERIMENTAL: this API is subject to change, use with caution.
type SearchBuilder struct {
c *Client
ctx context.Context
index string
query string
options *FTSearchOptions
}
// NewSearchBuilder creates a new SearchBuilder for FT.SEARCH commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewSearchBuilder(ctx context.Context, index, query string) *SearchBuilder {
b := &SearchBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSearchOptions{LimitOffset: -1}}
return b
}
// WithScores includes WITHSCORES.
func (b *SearchBuilder) WithScores() *SearchBuilder {
b.options.WithScores = true
return b
}
// NoContent includes NOCONTENT.
func (b *SearchBuilder) NoContent() *SearchBuilder { b.options.NoContent = true; return b }
// Verbatim includes VERBATIM.
func (b *SearchBuilder) Verbatim() *SearchBuilder { b.options.Verbatim = true; return b }
// NoStopWords includes NOSTOPWORDS.
func (b *SearchBuilder) NoStopWords() *SearchBuilder { b.options.NoStopWords = true; return b }
// WithPayloads includes WITHPAYLOADS.
func (b *SearchBuilder) WithPayloads() *SearchBuilder {
b.options.WithPayloads = true
return b
}
// WithSortKeys includes WITHSORTKEYS.
func (b *SearchBuilder) WithSortKeys() *SearchBuilder {
b.options.WithSortKeys = true
return b
}
// Filter adds a FILTER clause: FILTER <field> <min> <max>.
func (b *SearchBuilder) Filter(field string, min, max interface{}) *SearchBuilder {
b.options.Filters = append(b.options.Filters, FTSearchFilter{
FieldName: field,
Min: min,
Max: max,
})
return b
}
// GeoFilter adds a GEOFILTER clause: GEOFILTER <field> <lon> <lat> <radius> <unit>.
func (b *SearchBuilder) GeoFilter(field string, lon, lat, radius float64, unit string) *SearchBuilder {
b.options.GeoFilter = append(b.options.GeoFilter, FTSearchGeoFilter{
FieldName: field,
Longitude: lon,
Latitude: lat,
Radius: radius,
Unit: unit,
})
return b
}
// InKeys restricts the search to the given keys.
func (b *SearchBuilder) InKeys(keys ...interface{}) *SearchBuilder {
b.options.InKeys = append(b.options.InKeys, keys...)
return b
}
// InFields restricts the search to the given fields.
func (b *SearchBuilder) InFields(fields ...interface{}) *SearchBuilder {
b.options.InFields = append(b.options.InFields, fields...)
return b
}
// ReturnFields adds simple RETURN <n> <field>...
func (b *SearchBuilder) ReturnFields(fields ...string) *SearchBuilder {
for _, f := range fields {
b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: f})
}
return b
}
// ReturnAs adds RETURN <field> AS <alias>.
func (b *SearchBuilder) ReturnAs(field, alias string) *SearchBuilder {
b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: field, As: alias})
return b
}
// Slop adds SLOP <n>.
func (b *SearchBuilder) Slop(slop int) *SearchBuilder {
b.options.Slop = slop
return b
}
// Timeout adds TIMEOUT <ms>.
func (b *SearchBuilder) Timeout(timeout int) *SearchBuilder {
b.options.Timeout = timeout
return b
}
// InOrder includes INORDER.
func (b *SearchBuilder) InOrder() *SearchBuilder {
b.options.InOrder = true
return b
}
// Language sets LANGUAGE <lang>.
func (b *SearchBuilder) Language(lang string) *SearchBuilder {
b.options.Language = lang
return b
}
// Expander sets EXPANDER <expander>.
func (b *SearchBuilder) Expander(expander string) *SearchBuilder {
b.options.Expander = expander
return b
}
// Scorer sets SCORER <scorer>.
func (b *SearchBuilder) Scorer(scorer string) *SearchBuilder {
b.options.Scorer = scorer
return b
}
// ExplainScore includes EXPLAINSCORE.
func (b *SearchBuilder) ExplainScore() *SearchBuilder {
b.options.ExplainScore = true
return b
}
// Payload sets PAYLOAD <payload>.
func (b *SearchBuilder) Payload(payload string) *SearchBuilder {
b.options.Payload = payload
return b
}
// SortBy adds SORTBY <field> ASC|DESC.
func (b *SearchBuilder) SortBy(field string, asc bool) *SearchBuilder {
b.options.SortBy = append(b.options.SortBy, FTSearchSortBy{
FieldName: field,
Asc: asc,
Desc: !asc,
})
return b
}
// WithSortByCount includes WITHCOUNT (when used with SortBy).
func (b *SearchBuilder) WithSortByCount() *SearchBuilder {
b.options.SortByWithCount = true
return b
}
// Param adds a single PARAMS <k> <v>.
func (b *SearchBuilder) Param(key string, value interface{}) *SearchBuilder {
if b.options.Params == nil {
b.options.Params = make(map[string]interface{}, 1)
}
b.options.Params[key] = value
return b
}
// ParamsMap adds multiple PARAMS at once.
func (b *SearchBuilder) ParamsMap(p map[string]interface{}) *SearchBuilder {
if b.options.Params == nil {
b.options.Params = make(map[string]interface{}, len(p))
}
for k, v := range p {
b.options.Params[k] = v
}
return b
}
// Dialect sets DIALECT <version>.
func (b *SearchBuilder) Dialect(version int) *SearchBuilder {
b.options.DialectVersion = version
return b
}
// Limit sets OFFSET and COUNT. CountOnly uses LIMIT 0 0.
func (b *SearchBuilder) Limit(offset, count int) *SearchBuilder {
b.options.LimitOffset = offset
b.options.Limit = count
return b
}
func (b *SearchBuilder) CountOnly() *SearchBuilder { b.options.CountOnly = true; return b }
// Run executes FT.SEARCH and returns a typed result.
func (b *SearchBuilder) Run() (FTSearchResult, error) {
cmd := b.c.FTSearchWithArgs(b.ctx, b.index, b.query, b.options)
return cmd.Result()
}
// ----------------------
// AggregateBuilder for FT.AGGREGATE
// ----------------------
type AggregateBuilder struct {
c *Client
ctx context.Context
index string
query string
options *FTAggregateOptions
err error
}
// NewAggregateBuilder creates a new AggregateBuilder for FT.AGGREGATE commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewAggregateBuilder(ctx context.Context, index, query string) *AggregateBuilder {
return &AggregateBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTAggregateOptions{LimitOffset: -1}}
}
// setErr records the first error produced while building the pipeline.
// Subsequent errors are ignored; the first error is returned from Run.
func (b *AggregateBuilder) setErr(err error) {
if b.err == nil {
b.err = err
}
}
// Verbatim includes VERBATIM.
func (b *AggregateBuilder) Verbatim() *AggregateBuilder { b.options.Verbatim = true; return b }
// AddScores includes ADDSCORES.
func (b *AggregateBuilder) AddScores() *AggregateBuilder { b.options.AddScores = true; return b }
// Scorer sets SCORER <scorer>.
func (b *AggregateBuilder) Scorer(s string) *AggregateBuilder {
b.options.Scorer = s
return b
}
// LoadAll includes LOAD * (mutually exclusive with Load).
func (b *AggregateBuilder) LoadAll() *AggregateBuilder {
b.options.LoadAll = true
return b
}
// Load adds a LOAD <field> [AS alias] step.
// You can call it multiple times; each call becomes a separate LOAD clause
// at its position in the pipeline.
func (b *AggregateBuilder) Load(field string, alias ...string) *AggregateBuilder {
l := &FTAggregateLoad{Field: field}
if len(alias) > 0 {
l.As = alias[0]
}
b.options.Steps = append(b.options.Steps, FTAggregateStep{Load: l})
return b
}
// Timeout sets TIMEOUT <ms>.
func (b *AggregateBuilder) Timeout(ms int) *AggregateBuilder {
b.options.Timeout = ms
return b
}
// Apply adds an APPLY <field> [AS alias] step.
func (b *AggregateBuilder) Apply(field string, alias ...string) *AggregateBuilder {
a := &FTAggregateApply{Field: field}
if len(alias) > 0 {
a.As = alias[0]
}
b.options.Steps = append(b.options.Steps, FTAggregateStep{Apply: a})
return b
}
// GroupBy adds a new GROUPBY <fields...> step.
func (b *AggregateBuilder) GroupBy(fields ...interface{}) *AggregateBuilder {
b.options.Steps = append(b.options.Steps, FTAggregateStep{
GroupBy: &FTAggregateGroupBy{Fields: fields},
})
return b
}
// Reduce adds a REDUCE <fn> [<#args> <args...>] clause to the last step,
// which must be a GROUPBY. If it is not, Run will return an error.
func (b *AggregateBuilder) Reduce(fn SearchAggregator, args ...interface{}) *AggregateBuilder {
n := len(b.options.Steps)
if n == 0 || b.options.Steps[n-1].GroupBy == nil {
b.setErr(fmt.Errorf("FT.AGGREGATE: Reduce must follow a GroupBy step"))
return b
}
g := b.options.Steps[n-1].GroupBy
g.Reduce = append(g.Reduce, FTAggregateReducer{Reducer: fn, Args: args})
return b
}
// ReduceAs does the same but also sets an alias: REDUCE <fn> … AS <alias>.
// The last step must be a GROUPBY; otherwise Run will return an error.
func (b *AggregateBuilder) ReduceAs(fn SearchAggregator, alias string, args ...interface{}) *AggregateBuilder {
n := len(b.options.Steps)
if n == 0 || b.options.Steps[n-1].GroupBy == nil {
b.setErr(fmt.Errorf("FT.AGGREGATE: ReduceAs must follow a GroupBy step"))
return b
}
g := b.options.Steps[n-1].GroupBy
g.Reduce = append(g.Reduce, FTAggregateReducer{Reducer: fn, Args: args, As: alias})
return b
}
// Collect adds a REDUCE COLLECT clause to the last step, which must be a
// GROUPBY. The COLLECT options (FIELDS/DISTINCT/SORTBY/LIMIT/AS) are rendered
// and the argument count is computed automatically; field and sort names are
// normalized to a single "@" prefix. Set FTAggregateCollect.As to alias the
// output column.
//
// If the last step is not a GROUPBY, or the options are invalid (no FIELDS
// selector), Run returns the recorded error without issuing the command.
// COLLECT requires Redis 8.8+ with unstable features enabled.
func (b *AggregateBuilder) Collect(o FTAggregateCollect) *AggregateBuilder {
n := len(b.options.Steps)
if n == 0 || b.options.Steps[n-1].GroupBy == nil {
b.setErr(fmt.Errorf("FT.AGGREGATE: Collect must follow a GroupBy step"))
return b
}
reducer, err := NewCollectReducer(o)
if err != nil {
b.setErr(err)
return b
}
g := b.options.Steps[n-1].GroupBy
g.Reduce = append(g.Reduce, reducer)
return b
}
// SortBy adds SORTBY <field> ASC|DESC. Consecutive SortBy calls (with no
// other step in between) are merged into a single SORTBY clause so fields
// act as tiebreakers. A SortBy call after a non-SortBy step starts a new
// SORTBY step.
//
// Note: this is a semantics change from earlier experimental versions of
// the builder, where SortBy always accumulated into a single SORTBY clause
// regardless of position in the pipeline.
func (b *AggregateBuilder) SortBy(field string, asc bool) *AggregateBuilder {
sb := FTAggregateSortBy{FieldName: field, Asc: asc, Desc: !asc}
if n := len(b.options.Steps); n > 0 && b.options.Steps[n-1].SortBy != nil {
b.options.Steps[n-1].SortBy.Fields = append(b.options.Steps[n-1].SortBy.Fields, sb)
return b
}
b.options.Steps = append(b.options.Steps, FTAggregateStep{
SortBy: &FTAggregateSortByStep{Fields: []FTAggregateSortBy{sb}},
})
return b
}
// SortByMax sets MAX <n> on the last SORTBY step. The last step must be a
// SORTBY; otherwise Run will return an error.
func (b *AggregateBuilder) SortByMax(max int) *AggregateBuilder {
n := len(b.options.Steps)
if n == 0 || b.options.Steps[n-1].SortBy == nil {
b.setErr(fmt.Errorf("FT.AGGREGATE: SortByMax must follow a SortBy step"))
return b
}
b.options.Steps[n-1].SortBy.Max = max
return b
}
// Filter sets FILTER <expr>.
func (b *AggregateBuilder) Filter(expr string) *AggregateBuilder {
b.options.Filter = expr
return b
}
// WithCursor enables WITHCURSOR [COUNT <n>] [MAXIDLE <ms>].
func (b *AggregateBuilder) WithCursor(count, maxIdle int) *AggregateBuilder {
b.options.WithCursor = true
if b.options.WithCursorOptions == nil {
b.options.WithCursorOptions = &FTAggregateWithCursor{}
}
b.options.WithCursorOptions.Count = count
b.options.WithCursorOptions.MaxIdle = maxIdle
return b
}
// Params adds PARAMS <k v> pairs.
func (b *AggregateBuilder) Params(p map[string]interface{}) *AggregateBuilder {
if b.options.Params == nil {
b.options.Params = make(map[string]interface{}, len(p))
}
for k, v := range p {
b.options.Params[k] = v
}
return b
}
// Dialect sets DIALECT <version>.
func (b *AggregateBuilder) Dialect(version int) *AggregateBuilder {
b.options.DialectVersion = version
return b
}
// Run executes FT.AGGREGATE and returns a typed result. If the builder
// recorded a validation error while constructing the pipeline (for example,
// calling SortByMax when the last step is not a SortBy), that error is
// returned without issuing the command.
func (b *AggregateBuilder) Run() (*FTAggregateResult, error) {
if b.err != nil {
return nil, b.err
}
cmd := b.c.FTAggregateWithArgs(b.ctx, b.index, b.query, b.options)
return cmd.Result()
}
// ----------------------
// CreateIndexBuilder for FT.CREATE
// ----------------------
// CreateIndexBuilder is builder for FT.CREATE
// EXPERIMENTAL: this API is subject to change, use with caution.
type CreateIndexBuilder struct {
c *Client
ctx context.Context
index string
options *FTCreateOptions
schema []*FieldSchema
}
// NewCreateIndexBuilder creates a new CreateIndexBuilder for FT.CREATE commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewCreateIndexBuilder(ctx context.Context, index string) *CreateIndexBuilder {
return &CreateIndexBuilder{c: c, ctx: ctx, index: index, options: &FTCreateOptions{}}
}
// OnHash sets ON HASH.
func (b *CreateIndexBuilder) OnHash() *CreateIndexBuilder { b.options.OnHash = true; return b }
// OnJSON sets ON JSON.
func (b *CreateIndexBuilder) OnJSON() *CreateIndexBuilder { b.options.OnJSON = true; return b }
// Prefix sets PREFIX.
func (b *CreateIndexBuilder) Prefix(prefixes ...interface{}) *CreateIndexBuilder {
b.options.Prefix = prefixes
return b
}
// Filter sets FILTER.
func (b *CreateIndexBuilder) Filter(filter string) *CreateIndexBuilder {
b.options.Filter = filter
return b
}
// DefaultLanguage sets LANGUAGE.
func (b *CreateIndexBuilder) DefaultLanguage(lang string) *CreateIndexBuilder {
b.options.DefaultLanguage = lang
return b
}
// LanguageField sets LANGUAGE_FIELD.
func (b *CreateIndexBuilder) LanguageField(field string) *CreateIndexBuilder {
b.options.LanguageField = field
return b
}
// Score sets SCORE.
func (b *CreateIndexBuilder) Score(score float64) *CreateIndexBuilder {
b.options.Score = score
return b
}
// ScoreField sets SCORE_FIELD.
func (b *CreateIndexBuilder) ScoreField(field string) *CreateIndexBuilder {
b.options.ScoreField = field
return b
}
// PayloadField sets PAYLOAD_FIELD.
func (b *CreateIndexBuilder) PayloadField(field string) *CreateIndexBuilder {
b.options.PayloadField = field
return b
}
// NoOffsets includes NOOFFSETS.
func (b *CreateIndexBuilder) NoOffsets() *CreateIndexBuilder { b.options.NoOffsets = true; return b }
// Temporary sets TEMPORARY seconds.
func (b *CreateIndexBuilder) Temporary(sec int) *CreateIndexBuilder {
b.options.Temporary = sec
return b
}
// NoHL includes NOHL.
func (b *CreateIndexBuilder) NoHL() *CreateIndexBuilder { b.options.NoHL = true; return b }
// NoFields includes NOFIELDS.
func (b *CreateIndexBuilder) NoFields() *CreateIndexBuilder { b.options.NoFields = true; return b }
// NoFreqs includes NOFREQS.
func (b *CreateIndexBuilder) NoFreqs() *CreateIndexBuilder { b.options.NoFreqs = true; return b }
// StopWords sets STOPWORDS.
func (b *CreateIndexBuilder) StopWords(words ...interface{}) *CreateIndexBuilder {
b.options.StopWords = words
return b
}
// SkipInitialScan includes SKIPINITIALSCAN.
func (b *CreateIndexBuilder) SkipInitialScan() *CreateIndexBuilder {
b.options.SkipInitialScan = true
return b
}
// Schema adds a FieldSchema.
func (b *CreateIndexBuilder) Schema(field *FieldSchema) *CreateIndexBuilder {
b.schema = append(b.schema, field)
return b
}
// Run executes FT.CREATE and returns the status.
func (b *CreateIndexBuilder) Run() (string, error) {
cmd := b.c.FTCreate(b.ctx, b.index, b.options, b.schema...)
return cmd.Result()
}
// ----------------------
// DropIndexBuilder for FT.DROPINDEX
// ----------------------
// DropIndexBuilder is a builder for FT.DROPINDEX
// EXPERIMENTAL: this API is subject to change, use with caution.
type DropIndexBuilder struct {
c *Client
ctx context.Context
index string
options *FTDropIndexOptions
}
// NewDropIndexBuilder creates a new DropIndexBuilder for FT.DROPINDEX commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewDropIndexBuilder(ctx context.Context, index string) *DropIndexBuilder {
return &DropIndexBuilder{c: c, ctx: ctx, index: index}
}
// DeleteRuncs includes DD.
func (b *DropIndexBuilder) DeleteDocs() *DropIndexBuilder { b.options.DeleteDocs = true; return b }
// Run executes FT.DROPINDEX.
func (b *DropIndexBuilder) Run() (string, error) {
cmd := b.c.FTDropIndexWithArgs(b.ctx, b.index, b.options)
return cmd.Result()
}
// ----------------------
// AliasBuilder for FT.ALIAS* commands
// ----------------------
// AliasBuilder is builder for FT.ALIAS* commands
// EXPERIMENTAL: this API is subject to change, use with caution.
type AliasBuilder struct {
c *Client
ctx context.Context
alias string
index string
action string // add|del|update
}
// NewAliasBuilder creates a new AliasBuilder for FT.ALIAS* commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewAliasBuilder(ctx context.Context, alias string) *AliasBuilder {
return &AliasBuilder{c: c, ctx: ctx, alias: alias}
}
// Action sets the action for the alias builder.
func (b *AliasBuilder) Action(action string) *AliasBuilder {
b.action = action
return b
}
// Add sets the action to "add" and requires an index.
func (b *AliasBuilder) Add(index string) *AliasBuilder {
b.action = "add"
b.index = index
return b
}
// Del sets the action to "del".
func (b *AliasBuilder) Del() *AliasBuilder {
b.action = "del"
return b
}
// Update sets the action to "update" and requires an index.
func (b *AliasBuilder) Update(index string) *AliasBuilder {
b.action = "update"
b.index = index
return b
}
// Run executes the configured alias command.
func (b *AliasBuilder) Run() (string, error) {
switch b.action {
case "add":
cmd := b.c.FTAliasAdd(b.ctx, b.index, b.alias)
return cmd.Result()
case "del":
cmd := b.c.FTAliasDel(b.ctx, b.alias)
return cmd.Result()
case "update":
cmd := b.c.FTAliasUpdate(b.ctx, b.index, b.alias)
return cmd.Result()
}
return "", nil
}
// ----------------------
// ExplainBuilder for FT.EXPLAIN
// ----------------------
// ExplainBuilder is builder for FT.EXPLAIN
// EXPERIMENTAL: this API is subject to change, use with caution.
type ExplainBuilder struct {
c *Client
ctx context.Context
index string
query string
options *FTExplainOptions
}
// NewExplainBuilder creates a new ExplainBuilder for FT.EXPLAIN commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewExplainBuilder(ctx context.Context, index, query string) *ExplainBuilder {
return &ExplainBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTExplainOptions{}}
}
// Dialect sets dialect for EXPLAINCLI.
func (b *ExplainBuilder) Dialect(d string) *ExplainBuilder { b.options.Dialect = d; return b }
// Run executes FT.EXPLAIN and returns the plan.
func (b *ExplainBuilder) Run() (string, error) {
cmd := b.c.FTExplainWithArgs(b.ctx, b.index, b.query, b.options)
return cmd.Result()
}
// ----------------------
// InfoBuilder for FT.INFO
// ----------------------
type FTInfoBuilder struct {
c *Client
ctx context.Context
index string
}
// NewSearchInfoBuilder creates a new FTInfoBuilder for FT.INFO commands.
func (c *Client) NewSearchInfoBuilder(ctx context.Context, index string) *FTInfoBuilder {
return &FTInfoBuilder{c: c, ctx: ctx, index: index}
}
// Run executes FT.INFO and returns detailed info.
func (b *FTInfoBuilder) Run() (FTInfoResult, error) {
cmd := b.c.FTInfo(b.ctx, b.index)
return cmd.Result()
}
// ----------------------
// SpellCheckBuilder for FT.SPELLCHECK
// ----------------------
// SpellCheckBuilder is builder for FT.SPELLCHECK
// EXPERIMENTAL: this API is subject to change, use with caution.
type SpellCheckBuilder struct {
c *Client
ctx context.Context
index string
query string
options *FTSpellCheckOptions
}
// NewSpellCheckBuilder creates a new SpellCheckBuilder for FT.SPELLCHECK commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewSpellCheckBuilder(ctx context.Context, index, query string) *SpellCheckBuilder {
return &SpellCheckBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSpellCheckOptions{}}
}
// Distance sets MAXDISTANCE.
func (b *SpellCheckBuilder) Distance(d int) *SpellCheckBuilder { b.options.Distance = d; return b }
// Terms sets INCLUDE or EXCLUDE terms.
func (b *SpellCheckBuilder) Terms(include bool, dictionary string, terms ...interface{}) *SpellCheckBuilder {
if b.options.Terms == nil {
b.options.Terms = &FTSpellCheckTerms{}
}
if include {
b.options.Terms.Inclusion = "INCLUDE"
} else {
b.options.Terms.Inclusion = "EXCLUDE"
}
b.options.Terms.Dictionary = dictionary
b.options.Terms.Terms = terms
return b
}
// Dialect sets dialect version.
func (b *SpellCheckBuilder) Dialect(d int) *SpellCheckBuilder { b.options.Dialect = d; return b }
// Run executes FT.SPELLCHECK and returns suggestions.
func (b *SpellCheckBuilder) Run() ([]SpellCheckResult, error) {
cmd := b.c.FTSpellCheckWithArgs(b.ctx, b.index, b.query, b.options)
return cmd.Result()
}
// ----------------------
// DictBuilder for FT.DICT* commands
// ----------------------
// DictBuilder is builder for FT.DICT* commands
// EXPERIMENTAL: this API is subject to change, use with caution.
type DictBuilder struct {
c *Client
ctx context.Context
dict string
terms []interface{}
action string // add|del|dump
}
// NewDictBuilder creates a new DictBuilder for FT.DICT* commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewDictBuilder(ctx context.Context, dict string) *DictBuilder {
return &DictBuilder{c: c, ctx: ctx, dict: dict}
}
// Action sets the action for the dictionary builder.
func (b *DictBuilder) Action(action string) *DictBuilder {
b.action = action
return b
}
// Add sets the action to "add" and requires terms.
func (b *DictBuilder) Add(terms ...interface{}) *DictBuilder {
b.action = "add"
b.terms = terms
return b
}
// Del sets the action to "del" and requires terms.
func (b *DictBuilder) Del(terms ...interface{}) *DictBuilder {
b.action = "del"
b.terms = terms
return b
}
// Dump sets the action to "dump".
func (b *DictBuilder) Dump() *DictBuilder {
b.action = "dump"
return b
}
// Run executes the configured dictionary command.
func (b *DictBuilder) Run() (interface{}, error) {
switch b.action {
case "add":
cmd := b.c.FTDictAdd(b.ctx, b.dict, b.terms...)
return cmd.Result()
case "del":
cmd := b.c.FTDictDel(b.ctx, b.dict, b.terms...)
return cmd.Result()
case "dump":
cmd := b.c.FTDictDump(b.ctx, b.dict)
return cmd.Result()
}
return nil, nil
}
// ----------------------
// TagValsBuilder for FT.TAGVALS
// ----------------------
// TagValsBuilder is builder for FT.TAGVALS
// EXPERIMENTAL: this API is subject to change, use with caution.
type TagValsBuilder struct {
c *Client
ctx context.Context
index string
field string
}
// NewTagValsBuilder creates a new TagValsBuilder for FT.TAGVALS commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewTagValsBuilder(ctx context.Context, index, field string) *TagValsBuilder {
return &TagValsBuilder{c: c, ctx: ctx, index: index, field: field}
}
// Run executes FT.TAGVALS and returns tag values.
func (b *TagValsBuilder) Run() ([]string, error) {
cmd := b.c.FTTagVals(b.ctx, b.index, b.field)
return cmd.Result()
}
// ----------------------
// CursorBuilder for FT.CURSOR*
// ----------------------
// CursorBuilder is builder for FT.CURSOR* commands
// EXPERIMENTAL: this API is subject to change, use with caution.
type CursorBuilder struct {
c *Client
ctx context.Context
index string
cursorId int64
count int
action string // read|del
}
// NewCursorBuilder creates a new CursorBuilder for FT.CURSOR* commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewCursorBuilder(ctx context.Context, index string, cursorId int64) *CursorBuilder {
return &CursorBuilder{c: c, ctx: ctx, index: index, cursorId: cursorId}
}
// Action sets the action for the cursor builder.
func (b *CursorBuilder) Action(action string) *CursorBuilder {
b.action = action
return b
}
// Read sets the action to "read".
func (b *CursorBuilder) Read() *CursorBuilder {
b.action = "read"
return b
}
// Del sets the action to "del".
func (b *CursorBuilder) Del() *CursorBuilder {
b.action = "del"
return b
}
// Count for READ.
func (b *CursorBuilder) Count(count int) *CursorBuilder { b.count = count; return b }
// Run executes the cursor command.
func (b *CursorBuilder) Run() (interface{}, error) {
switch b.action {
case "read":
cmd := b.c.FTCursorRead(b.ctx, b.index, int(b.cursorId), b.count)
return cmd.Result()
case "del":
cmd := b.c.FTCursorDel(b.ctx, b.index, int(b.cursorId))
return cmd.Result()
}
return nil, nil
}
// ----------------------
// SynUpdateBuilder for FT.SYNUPDATE
// ----------------------
// SyncUpdateBuilder is builder for FT.SYNCUPDATE
// EXPERIMENTAL: this API is subject to change, use with caution.
type SynUpdateBuilder struct {
c *Client
ctx context.Context
index string
groupId interface{}
options *FTSynUpdateOptions
terms []interface{}
}
// NewSynUpdateBuilder creates a new SynUpdateBuilder for FT.SYNUPDATE commands.
// EXPERIMENTAL: this API is subject to change, use with caution.
func (c *Client) NewSynUpdateBuilder(ctx context.Context, index string, groupId interface{}) *SynUpdateBuilder {
return &SynUpdateBuilder{c: c, ctx: ctx, index: index, groupId: groupId, options: &FTSynUpdateOptions{}}
}
// SkipInitialScan includes SKIPINITIALSCAN.
func (b *SynUpdateBuilder) SkipInitialScan() *SynUpdateBuilder {
b.options.SkipInitialScan = true
return b
}
// Terms adds synonyms to the group.
func (b *SynUpdateBuilder) Terms(terms ...interface{}) *SynUpdateBuilder { b.terms = terms; return b }
// Run executes FT.SYNUPDATE.
func (b *SynUpdateBuilder) Run() (string, error) {
cmd := b.c.FTSynUpdateWithArgs(b.ctx, b.index, b.groupId, b.options, b.terms)
return cmd.Result()
}
package redis
import (
"fmt"
"strings"
)
// ----------------------
// FT.AGGREGATE COLLECT reducer
// ----------------------
//
// COLLECT is a GROUPBY reducer for FT.AGGREGATE (Redis 8.8+, gated behind
// search-enable-unstable-features). Within each group it projects a chosen
// set of fields from every row, optionally deduplicates, sorts, and limits
// them, and emits the result as an array of per-entry maps under the reducer
// alias.
//
// COLLECT is not a standalone command; it is a REDUCE clause inside
// FT.AGGREGATE. The helpers below assemble the reducer token list and compute
// its argument count, so callers do not have to hand-write FIELDS/SORTBY/LIMIT
// tokens or remember to @-prefix every name.
// FTAggregateCollect describes a COLLECT reducer. It is rendered into a
// standard FTAggregateReducer via NewCollectReducer, or appended to a builder
// via AggregateBuilder.Collect.
//
// Field and sort names may be supplied with or without a leading "@"; each is
// normalized to a single "@<name>" on the wire. Output map keys returned by
// the server are the bare names (see AggregateRow.Collect).
type FTAggregateCollect struct {
// FieldsAll emits FIELDS *, projecting every field present in the
// pipeline at the COLLECT stage. It is not a whole-document fetch; pair
// it with an upstream LOAD * to collect complete documents. FieldsAll
// takes precedence over Fields when both are set.
FieldsAll bool
// Fields is the explicit list of fields to project (FIELDS <n> @f ...).
// Ignored when FieldsAll is true. Exactly one of FieldsAll or a non-empty
// Fields must be set.
Fields []string
// Distinct emits DISTINCT, deduplicating entries with identical projected
// fields.
//
// NOTE: DISTINCT is specified by the product but not yet implemented by
// the server. Sending it currently produces a server error. The option is
// kept for forward compatibility; leave it false unless the target server
// supports it.
Distinct bool
// SortBy orders entries within each group. Direction defaults to ASC when
// neither Asc nor Desc is set. With Limit, SORTBY acts as a top-N
// selection. Reuses FTAggregateSortBy for consistency with the rest of the
// aggregate API.
SortBy []FTAggregateSortBy
// Limit returns at most Count entries per group after skipping Offset.
// nil means no LIMIT clause (distinct from LIMIT 0 0).
Limit *FTAggregateCollectLimit
// As sets the reducer output column name (AS <alias>). It is emitted
// outside the reducer argument count.
As string
}
// FTAggregateCollectLimit is the LIMIT <offset> <count> clause of a COLLECT
// reducer. Numeric bounds are enforced by the server, not the client.
type FTAggregateCollectLimit struct {
Offset int
Count int
}
// ensureAtPrefix normalizes a field or sort name to exactly one leading "@",
// collapsing any number of leading "@" (including none) to a single prefix.
func ensureAtPrefix(name string) string {
return "@" + strings.TrimLeft(name, "@")
}
// buildCollectArgs renders a FTAggregateCollect into the reducer argument
// token list (everything after "REDUCE COLLECT <narg>", excluding AS <alias>).
// The serializer computes <narg> as len(args), which matches the COLLECT
// contract: narg counts every FIELDS/DISTINCT/SORTBY/LIMIT token.
func buildCollectArgs(o FTAggregateCollect) ([]interface{}, error) {
args := make([]interface{}, 0, 8)
// FIELDS (required): either * or a counted list of @-names.
switch {
case o.FieldsAll:
args = append(args, "FIELDS", "*")
case len(o.Fields) > 0:
args = append(args, "FIELDS", len(o.Fields))
for _, f := range o.Fields {
if strings.TrimLeft(f, "@") == "" {
return nil, fmt.Errorf("redis: FT.AGGREGATE COLLECT: empty field name in Fields")
}
args = append(args, ensureAtPrefix(f))
}
default:
return nil, fmt.Errorf("redis: FT.AGGREGATE COLLECT requires FieldsAll or a non-empty Fields list")
}
// DISTINCT (optional, forward-compatible).
if o.Distinct {
args = append(args, "DISTINCT")
}
// SORTBY (optional). sort_narg counts each field plus its optional
// direction token.
if len(o.SortBy) > 0 {
sortTokens := make([]interface{}, 0, len(o.SortBy)*2)
for _, s := range o.SortBy {
if strings.TrimLeft(s.FieldName, "@") == "" {
return nil, fmt.Errorf("redis: FT.AGGREGATE COLLECT: empty field name in SortBy")
}
if s.Asc && s.Desc {
return nil, fmt.Errorf("redis: FT.AGGREGATE COLLECT: ASC and DESC are mutually exclusive")
}
sortTokens = append(sortTokens, ensureAtPrefix(s.FieldName))
switch {
case s.Desc:
sortTokens = append(sortTokens, "DESC")
case s.Asc:
sortTokens = append(sortTokens, "ASC")
// neither set: ASC is the server default; emit nothing.
}
}
args = append(args, "SORTBY", len(sortTokens))
args = append(args, sortTokens...)
}
// LIMIT (optional).
if o.Limit != nil {
args = append(args, "LIMIT", o.Limit.Offset, o.Limit.Count)
}
return args, nil
}
// NewCollectReducer builds a COLLECT FTAggregateReducer for use with
// FTAggregateOptions.GroupBy[i].Reduce. It normalizes field/sort names and
// computes the argument count automatically.
//
// It returns an error only for local API misuse: a missing FIELDS selector, an
// empty field name (in Fields or SortBy), or a SortBy entry with both Asc and
// Desc set. Numeric bounds and the unstable-features gate are enforced by the
// server and surface unchanged through the command reply.
func NewCollectReducer(o FTAggregateCollect) (FTAggregateReducer, error) {
args, err := buildCollectArgs(o)
if err != nil {
return FTAggregateReducer{}, err
}
return FTAggregateReducer{Reducer: SearchCollect, Args: args, As: o.As}, nil
}
// ----------------------
// COLLECT response decoding
// ----------------------
// CollectEntry is a single collected row: a sparse map of bare field name to
// value. A field absent from a row is omitted from its entry (no NULL
// placeholder), so entries in the same column may have different key sets.
type CollectEntry = map[string]interface{}
// CollectColumn is the value stored under a COLLECT reducer alias: the ordered
// list of collected entries for a group.
type CollectColumn = []CollectEntry
// Collect decodes the COLLECT reducer column stored under alias in this row
// into a uniform CollectColumn, hiding the RESP2/RESP3 representation
// difference (RESP3 entries are maps; RESP2 entries are flat key/value
// arrays).
//
// It returns (nil, nil) when the alias is absent from the row. Entry order is
// preserved as returned by the server; it is meaningful only when the COLLECT
// reducer was given a SORTBY.
func (r AggregateRow) Collect(alias string) (CollectColumn, error) {
v, ok := r.Fields[alias]
if !ok {
return nil, nil
}
return parseCollectValue(v)
}
// parseCollectValue decodes a raw COLLECT alias value (an array of entries)
// into a CollectColumn.
func parseCollectValue(v interface{}) (CollectColumn, error) {
if v == nil {
return nil, nil
}
arr, ok := v.([]interface{})
if !ok {
return nil, fmt.Errorf("redis: COLLECT value has type %T, want array of entries", v)
}
out := make(CollectColumn, 0, len(arr))
for i, e := range arr {
entry, err := parseCollectEntry(e)
if err != nil {
return nil, fmt.Errorf("redis: COLLECT entry %d: %w", i, err)
}
out = append(out, entry)
}
return out, nil
}
// parseCollectEntry decodes a single collected entry from either the RESP3
// map form or the RESP2 flat key/value array form into a CollectEntry. Keys
// are passed through as-is: the server already returns them without the "@"
// prefix.
func parseCollectEntry(e interface{}) (CollectEntry, error) {
switch m := e.(type) {
case map[interface{}]interface{}: // RESP3
out := make(CollectEntry, len(m))
for k, val := range m {
out[fmt.Sprint(k)] = val
}
return out, nil
case map[string]interface{}: // already string-keyed
return m, nil
case []interface{}: // RESP2 flat [field, value, field, value, ...]
if len(m)%2 != 0 {
return nil, fmt.Errorf("odd-length key/value array of length %d", len(m))
}
out := make(CollectEntry, len(m)/2)
for i := 0; i < len(m); i += 2 {
key, ok := m[i].(string)
if !ok {
key = fmt.Sprint(m[i])
}
out[key] = m[i+1]
}
return out, nil
default:
return nil, fmt.Errorf("unexpected type %T, want map or key/value array", e)
}
}
package redis
import (
"context"
"fmt"
"maps"
"slices"
"strconv"
"strings"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/proto"
)
type SearchCmdable interface {
FT_List(ctx context.Context) *StringSliceCmd
FTAggregate(ctx context.Context, index string, query string) *MapStringInterfaceCmd
FTAggregateWithArgs(ctx context.Context, index string, query string, options *FTAggregateOptions) *AggregateCmd
FTAliasAdd(ctx context.Context, index string, alias string) *StatusCmd
FTAliasDel(ctx context.Context, alias string) *StatusCmd
FTAliasList(ctx context.Context, index string) *StringSliceCmd
FTAliasUpdate(ctx context.Context, index string, alias string) *StatusCmd
FTAlter(ctx context.Context, index string, skipInitialScan bool, definition []interface{}) *StatusCmd
FTConfigGet(ctx context.Context, option string) *MapMapStringInterfaceCmd
FTConfigSet(ctx context.Context, option string, value interface{}) *StatusCmd
FTCreate(ctx context.Context, index string, options *FTCreateOptions, schema ...*FieldSchema) *StatusCmd
FTCursorDel(ctx context.Context, index string, cursorId int) *StatusCmd
FTCursorRead(ctx context.Context, index string, cursorId int, count int) *MapStringInterfaceCmd
FTDictAdd(ctx context.Context, dict string, term ...interface{}) *IntCmd
FTDictDel(ctx context.Context, dict string, term ...interface{}) *IntCmd
FTDictDump(ctx context.Context, dict string) *StringSliceCmd
FTDropIndex(ctx context.Context, index string) *StatusCmd
FTDropIndexWithArgs(ctx context.Context, index string, options *FTDropIndexOptions) *StatusCmd
FTExplain(ctx context.Context, index string, query string) *StringCmd
FTExplainWithArgs(ctx context.Context, index string, query string, options *FTExplainOptions) *StringCmd
FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd
FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd
FTInfo(ctx context.Context, index string) *FTInfoCmd
FTSpellCheck(ctx context.Context, index string, query string) *FTSpellCheckCmd
FTSpellCheckWithArgs(ctx context.Context, index string, query string, options *FTSpellCheckOptions) *FTSpellCheckCmd
FTSearch(ctx context.Context, index string, query string) *FTSearchCmd
FTSearchWithArgs(ctx context.Context, index string, query string, options *FTSearchOptions) *FTSearchCmd
FTSynDump(ctx context.Context, index string) *FTSynDumpCmd
FTSynUpdate(ctx context.Context, index string, synGroupId interface{}, terms []interface{}) *StatusCmd
FTSynUpdateWithArgs(ctx context.Context, index string, synGroupId interface{}, options *FTSynUpdateOptions, terms []interface{}) *StatusCmd
FTTagVals(ctx context.Context, index string, field string) *StringSliceCmd
}
type FTCreateOptions struct {
OnHash bool
OnJSON bool
Prefix []interface{}
Filter string
DefaultLanguage string
LanguageField string
Score float64
ScoreField string
PayloadField string
MaxTextFields int
NoOffsets bool
Temporary int
NoHL bool
NoFields bool
NoFreqs bool
StopWords []interface{}
SkipInitialScan bool
}
type FieldSchema struct {
FieldName string
As string
FieldType SearchFieldType
Sortable bool
UNF bool
NoStem bool
NoIndex bool
PhoneticMatcher string
Weight float64
Separator string
CaseSensitive bool
WithSuffixtrie bool
VectorArgs *FTVectorArgs
GeoShapeFieldType string
IndexEmpty bool
IndexMissing bool
}
type FTVectorArgs struct {
FlatOptions *FTFlatOptions
HNSWOptions *FTHNSWOptions
VamanaOptions *FTVamanaOptions
}
type FTFlatOptions struct {
Type string
Dim int
DistanceMetric string
InitialCapacity int
BlockSize int
}
type FTHNSWOptions struct {
Type string
Dim int
DistanceMetric string
InitialCapacity int
MaxEdgesPerNode int
MaxAllowedEdgesPerNode int
EFRunTime int
Epsilon float64
// Rerank toggles the exact re-scoring pass over approximate candidates on
// disk-backed HNSW indexes (Redis 8.10+), where the server requires it to
// be set explicitly. Rerank=true emits RERANK TRUE on its own; to emit
// RERANK FALSE, set HasRerank=true with Rerank=false, so that an explicit
// false can be distinguished from unset (omitted).
Rerank bool
HasRerank bool
}
type FTVamanaOptions struct {
Type string
Dim int
DistanceMetric string
Compression string
ConstructionWindowSize int
GraphMaxDegree int
SearchWindowSize int
Epsilon float64
TrainingThreshold int
ReduceDim int
}
type FTDropIndexOptions struct {
DeleteDocs bool
}
type SpellCheckTerms struct {
Include bool
Exclude bool
Dictionary string
}
type FTExplainOptions struct {
// Dialect 1,3 and 4 are deprecated since redis 8.0
Dialect string
}
type FTSynUpdateOptions struct {
SkipInitialScan bool
}
type SearchAggregator int
const (
SearchInvalid = SearchAggregator(iota)
SearchAvg
SearchSum
SearchMin
SearchMax
SearchCount
SearchCountDistinct
SearchCountDistinctish
SearchStdDev
SearchQuantile
SearchToList
SearchFirstValue
SearchRandomSample
// SearchCollect is the COLLECT reducer for FT.AGGREGATE. Within each
// GROUPBY group it projects a chosen set of fields from every row and
// emits them as an array of per-entry maps under the reducer alias.
// Requires Redis 8.8+ with unstable features enabled
// (CONFIG SET search-enable-unstable-features yes).
SearchCollect
)
func (a SearchAggregator) String() string {
switch a {
case SearchInvalid:
return ""
case SearchAvg:
return "AVG"
case SearchSum:
return "SUM"
case SearchMin:
return "MIN"
case SearchMax:
return "MAX"
case SearchCount:
return "COUNT"
case SearchCountDistinct:
return "COUNT_DISTINCT"
case SearchCountDistinctish:
return "COUNT_DISTINCTISH"
case SearchStdDev:
return "STDDEV"
case SearchQuantile:
return "QUANTILE"
case SearchToList:
return "TOLIST"
case SearchFirstValue:
return "FIRST_VALUE"
case SearchRandomSample:
return "RANDOM_SAMPLE"
case SearchCollect:
return "COLLECT"
default:
return ""
}
}
type SearchFieldType int
const (
SearchFieldTypeInvalid = SearchFieldType(iota)
SearchFieldTypeNumeric
SearchFieldTypeTag
SearchFieldTypeText
SearchFieldTypeGeo
SearchFieldTypeVector
SearchFieldTypeGeoShape
)
func (t SearchFieldType) String() string {
switch t {
case SearchFieldTypeInvalid:
return ""
case SearchFieldTypeNumeric:
return "NUMERIC"
case SearchFieldTypeTag:
return "TAG"
case SearchFieldTypeText:
return "TEXT"
case SearchFieldTypeGeo:
return "GEO"
case SearchFieldTypeVector:
return "VECTOR"
case SearchFieldTypeGeoShape:
return "GEOSHAPE"
default:
return "TEXT"
}
}
// Each AggregateReducer have different args.
// Please follow https://redis.io/docs/interact/search-and-query/search/aggregations/#supported-groupby-reducers for more information.
type FTAggregateReducer struct {
Reducer SearchAggregator
Args []interface{}
As string
}
type FTAggregateGroupBy struct {
Fields []interface{}
Reduce []FTAggregateReducer
}
type FTAggregateSortBy struct {
FieldName string
Asc bool
Desc bool
}
type FTAggregateApply struct {
Field string
As string
}
type FTAggregateLoad struct {
Field string
As string
}
type FTAggregateWithCursor struct {
Count int
MaxIdle int
}
// FTAggregateSortByStep represents a SORTBY operation with optional MAX.
// Used inside FTAggregateStep to place SORTBY at an arbitrary position in
// the aggregation pipeline.
type FTAggregateSortByStep struct {
Fields []FTAggregateSortBy
Max int // 0 means no MAX
}
// FTAggregateStep represents a single operation in the aggregation pipeline.
// LOAD, APPLY, SORTBY and GROUPBY can all appear multiple times in any order.
// Exactly one of the fields should be set per step.
type FTAggregateStep struct {
Load *FTAggregateLoad
Apply *FTAggregateApply
GroupBy *FTAggregateGroupBy
SortBy *FTAggregateSortByStep
}
type FTAggregateOptions struct {
Verbatim bool
LoadAll bool
Timeout int
// Scorer is used to set scoring function, if not set passed, a default will be used.
// The default scorer depends on the Redis version:
// - `BM25` for Redis >= 8
// - `TFIDF` for Redis < 8
Scorer string
// AddScores is available in Redis CE 8
AddScores bool
// Steps is the ordered sequence of aggregation pipeline operations.
// It can contain LOAD, APPLY, GROUPBY and SORTBY in any order, multiple times.
// Steps cannot be combined with the deprecated Load, Apply, GroupBy, SortBy
// and SortByMax fields: doing so returns an error.
Steps []FTAggregateStep
LimitOffset int
Limit int
Filter string
WithCursor bool
WithCursorOptions *FTAggregateWithCursor
Params map[string]interface{}
// Dialect 1,3 and 4 are deprecated since redis 8.0
DialectVersion int
// Deprecated: Use Steps instead.
Load []FTAggregateLoad
// Deprecated: Use Steps instead.
GroupBy []FTAggregateGroupBy
// Deprecated: Use Steps instead.
SortBy []FTAggregateSortBy
// Deprecated: Use Steps instead.
SortByMax int
// Deprecated: Use Steps instead.
Apply []FTAggregateApply
}
type FTSearchFilter struct {
FieldName interface{}
Min interface{}
Max interface{}
}
type FTSearchGeoFilter struct {
FieldName string
Longitude float64
Latitude float64
Radius float64
Unit string
}
type FTSearchReturn struct {
FieldName string
As string
}
type FTSearchSortBy struct {
FieldName string
Asc bool
Desc bool
}
// FTSearchOptions hold options that can be passed to the FT.SEARCH command.
// More information about the options can be found
// in the documentation for FT.SEARCH https://redis.io/docs/latest/commands/ft.search/
type FTSearchOptions struct {
NoContent bool
Verbatim bool
NoStopWords bool
WithScores bool
WithPayloads bool
WithSortKeys bool
Filters []FTSearchFilter
GeoFilter []FTSearchGeoFilter
InKeys []interface{}
InFields []interface{}
Return []FTSearchReturn
Slop int
Timeout int
InOrder bool
Language string
Expander string
// Scorer is used to set scoring function, if not set passed, a default will be used.
// The default scorer depends on the Redis version:
// - `BM25` for Redis >= 8
// - `TFIDF` for Redis < 8
Scorer string
ExplainScore bool
Payload string
SortBy []FTSearchSortBy
SortByWithCount bool
LimitOffset int
Limit int
// CountOnly sets LIMIT 0 0 to get the count - number of documents in the result set without actually returning the result set.
// When using this option, the Limit and LimitOffset options are ignored.
CountOnly bool
Params map[string]interface{}
// Dialect 1,3 and 4 are deprecated since redis 8.0
DialectVersion int
}
// FTHybridCombineMethod represents the fusion method for combining search and vector results
type FTHybridCombineMethod string
const (
FTHybridCombineRRF FTHybridCombineMethod = "RRF"
FTHybridCombineLinear FTHybridCombineMethod = "LINEAR"
FTHybridCombineFunction FTHybridCombineMethod = "FUNCTION"
)
// FTHybridSearchExpression represents a search expression in hybrid search
type FTHybridSearchExpression struct {
Query string
Scorer string
ScorerParams []interface{}
YieldScoreAs string
}
type FTHybridVectorMethod = string
const (
KNN FTHybridCombineMethod = "KNN"
RANGE FTHybridCombineMethod = "RANGE"
)
// FTHybridVectorExpression represents a vector expression in hybrid search
type FTHybridVectorExpression struct {
VectorField string
VectorData Vector
// VectorParamName optionally specifies the parameter name used to pass the
// vector data via the PARAMS mechanism.
// Vector data is always passed via PARAMS because inline vector blobs are no
// longer supported by Redis. When left empty, the library generates a unique
// parameter name automatically (e.g. "__vector_param_0") without mutating
// FTHybridOptions.Params and without colliding with any explicit names.
// The vector blob is passed as: VSIM @field $VectorParamName ... PARAMS ... VectorParamName <blob>
VectorParamName string
Method FTHybridVectorMethod
MethodParams []interface{}
// ShardKRatio controls how many results each shard returns relative to the
// requested KNN K, trading recall for latency in Redis cluster setups.
// Valid range: 0.1 - 1.0. The zero value means "unset" and falls back to
// the server default of 1.0 (no per-shard reduction). Has no effect on
// standalone Redis, and only applies to the KNN method. Requires Redis 8.8+.
// See https://redis.io/docs/latest/develop/ai/search-and-query/query/vector-search/
ShardKRatio float64
Filter string
YieldScoreAs string
}
// FTHybridCombineOptions represents options for result fusion
type FTHybridCombineOptions struct {
Method FTHybridCombineMethod
Count int
Window int // For RRF
Constant float64 // For RRF
Alpha float64 // For LINEAR
Beta float64 // For LINEAR
YieldScoreAs string
}
// FTHybridGroupBy represents GROUP BY functionality
type FTHybridGroupBy struct {
Count int
Fields []string
ReduceFunc string
ReduceCount int
ReduceParams []interface{}
}
// FTHybridApply represents APPLY functionality
type FTHybridApply struct {
Expression string
AsField string
}
// FTHybridWithCursor represents cursor configuration for hybrid search
type FTHybridWithCursor struct {
Count int // Number of results to return per cursor read
MaxIdle int // Maximum idle time in milliseconds before cursor is automatically deleted
}
// FTHybridOptions hold options that can be passed to the FT.HYBRID command
type FTHybridOptions struct {
CountExpressions int // Number of search/vector expressions
SearchExpressions []FTHybridSearchExpression // Multiple search expressions
VectorExpressions []FTHybridVectorExpression // Multiple vector expressions
Combine *FTHybridCombineOptions // Fusion step options
Load []string // Projected fields
GroupBy *FTHybridGroupBy // Aggregation grouping
Apply []FTHybridApply // Field transformations
SortBy []FTSearchSortBy // Reuse from FTSearch
Filter string // Post-filter expression
LimitOffset int // Result limiting
Limit int
Params map[string]interface{} // Parameter substitution
ExplainScore bool // Include score explanations
Timeout int // Runtime timeout
WithCursor bool // Enable cursor support for large result sets
WithCursorOptions *FTHybridWithCursor // Cursor configuration options
}
type FTSynDumpResult struct {
Term string
Synonyms []string
}
type FTSynDumpCmd struct {
baseCmd
val []FTSynDumpResult
}
// FTAggregateResult represents the result of an aggregate operation
// NOTE: For RESP3 Total is not reliable (before Redis 8.8)
type FTAggregateResult struct {
Total int
Rows []AggregateRow
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict). RESP3 only; the fail policy returns an error instead.
Warnings []string
}
type AggregateRow struct {
Fields map[string]interface{}
}
type AggregateCmd struct {
baseCmd
val *FTAggregateResult
}
type FTInfoResult struct {
IndexErrors IndexErrors
Attributes []FTAttribute
BytesPerRecordAvg string
Cleaning int
CursorStats CursorStats
DialectStats map[string]int
DocTableSizeMB float64
FieldStatistics []FieldStatistic
GCStats GCStats
GeoshapesSzMB float64
HashIndexingFailures int
IndexDefinition IndexDefinition
IndexName string
IndexOptions []string
Indexing int
InvertedSzMB float64
KeyTableSizeMB float64
MaxDocID int
NumDocs int
NumRecords int
NumTerms int
NumberOfUses int
OffsetBitsPerRecordAvg string
OffsetVectorsSzMB float64
OffsetsPerTermAvg string
PercentIndexed float64
RecordsPerDocAvg string
SortableValuesSizeMB float64
TagOverheadSzMB float64
TextOverheadSzMB float64
TotalIndexMemorySzMB float64
TotalIndexingTime int
TotalInvertedIndexBlocks int
VectorIndexSzMB float64
}
type IndexErrors struct {
IndexingFailures int
LastIndexingError string
LastIndexingErrorKey string
}
type FTAttribute struct {
Identifier string
Attribute string
Type string
Weight float64
Sortable bool
NoStem bool
NoIndex bool
UNF bool
PhoneticMatcher string
CaseSensitive bool
WithSuffixtrie bool
// Vector specific attributes
Algorithm string
DataType string
Dim int
DistanceMetric string
M int
EFConstruction int
}
type CursorStats struct {
GlobalIdle int
GlobalTotal int
IndexCapacity int
IndexTotal int
}
type FieldStatistic struct {
Identifier string
Attribute string
IndexErrors IndexErrors
}
type GCStats struct {
BytesCollected int
TotalMsRun int
TotalCycles int
AverageCycleTimeMs string
LastRunTimeMs int
GCNumericTreesMissed int
GCBlocksDenied int
}
type IndexDefinition struct {
KeyType string
Prefixes []string
DefaultScore float64
}
type FTSpellCheckOptions struct {
Distance int
Terms *FTSpellCheckTerms
// Dialect 1,3 and 4 are deprecated since redis 8.0
Dialect int
}
type FTSpellCheckTerms struct {
Inclusion string // Either "INCLUDE" or "EXCLUDE"
Dictionary string
Terms []interface{}
}
type SpellCheckResult struct {
Term string
Suggestions []SpellCheckSuggestion
}
type SpellCheckSuggestion struct {
Score float64
Suggestion string
}
type FTSearchResult struct {
Total int
Docs []Document
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict). RESP3 only; the fail policy returns an error instead.
Warnings []string
}
type Document struct {
ID string
Score *float64
Payload *string
SortKey *string
Fields map[string]string
Error error
}
type AggregateQuery []interface{}
// FT_List - Lists all the existing indexes in the database.
// For more information, please refer to the Redis documentation:
// [FT._LIST]: (https://redis.io/commands/ft._list/)
func (c cmdable) FT_List(ctx context.Context) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "FT._LIST")
_ = c(ctx, cmd)
return cmd
}
// FTAggregate - Performs a search query on an index and applies a series of aggregate transformations to the result.
// The 'index' parameter specifies the index to search, and the 'query' parameter specifies the search query.
// For more information, please refer to the Redis documentation:
// [FT.AGGREGATE]: (https://redis.io/commands/ft.aggregate/)
func (c cmdable) FTAggregate(ctx context.Context, index string, query string) *MapStringInterfaceCmd {
args := []interface{}{"FT.AGGREGATE", index, query}
cmd := NewMapStringInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// validateFTAggregateOptions validates mutually exclusive combinations of
// FTAggregateOptions fields before any command arguments are constructed.
func validateFTAggregateOptions(options *FTAggregateOptions) error {
if len(options.Steps) > 0 {
if options.Load != nil || options.Apply != nil || options.GroupBy != nil ||
options.SortBy != nil || options.SortByMax != 0 {
return fmt.Errorf("FT.AGGREGATE: Steps cannot be combined with the deprecated Load, Apply, GroupBy, SortBy and SortByMax fields")
}
if options.LoadAll {
for _, step := range options.Steps {
if step.Load != nil {
return fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive")
}
}
}
}
if options.LoadAll && options.Load != nil {
return fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive")
}
return nil
}
// appendFTAggregateStep appends the Redis command arguments for a single
// aggregation pipeline step. Each step must set exactly one of Load, Apply,
// GroupBy or SortBy.
func appendFTAggregateStep(args []interface{}, step FTAggregateStep) ([]interface{}, error) {
set := 0
if step.Load != nil {
set++
}
if step.Apply != nil {
set++
}
if step.GroupBy != nil {
set++
}
if step.SortBy != nil {
set++
}
if set != 1 {
return args, fmt.Errorf("FT.AGGREGATE: each step must set exactly one of Load, Apply, GroupBy, SortBy (got %d)", set)
}
switch {
case step.Load != nil:
args = append(args, "LOAD")
countIdx := len(args)
args = append(args, 0)
count := 0
args = append(args, step.Load.Field)
count++
if step.Load.As != "" {
args = append(args, "AS", step.Load.As)
count += 2
}
args[countIdx] = count
case step.Apply != nil:
args = append(args, "APPLY", step.Apply.Field)
if step.Apply.As != "" {
args = append(args, "AS", step.Apply.As)
}
case step.GroupBy != nil:
args = append(args, "GROUPBY", len(step.GroupBy.Fields))
args = append(args, step.GroupBy.Fields...)
for _, reducer := range step.GroupBy.Reduce {
args = append(args, "REDUCE", reducer.Reducer.String())
if reducer.Args != nil {
args = append(args, len(reducer.Args))
args = append(args, reducer.Args...)
} else {
args = append(args, 0)
}
if reducer.As != "" {
args = append(args, "AS", reducer.As)
}
}
case step.SortBy != nil:
args = append(args, "SORTBY")
sortByOptions := []interface{}{}
for _, sortBy := range step.SortBy.Fields {
if sortBy.Asc && sortBy.Desc {
return args, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive")
}
sortByOptions = append(sortByOptions, sortBy.FieldName)
if sortBy.Asc {
sortByOptions = append(sortByOptions, "ASC")
}
if sortBy.Desc {
sortByOptions = append(sortByOptions, "DESC")
}
}
args = append(args, len(sortByOptions))
args = append(args, sortByOptions...)
if step.SortBy.Max > 0 {
args = append(args, "MAX", step.SortBy.Max)
}
}
return args, nil
}
func FTAggregateQuery(query string, options *FTAggregateOptions) (AggregateQuery, error) {
queryArgs := []interface{}{query}
if options != nil {
if err := validateFTAggregateOptions(options); err != nil {
return nil, err
}
if options.Verbatim {
queryArgs = append(queryArgs, "VERBATIM")
}
if options.Scorer != "" {
queryArgs = append(queryArgs, "SCORER", options.Scorer)
}
if options.AddScores {
queryArgs = append(queryArgs, "ADDSCORES")
}
if options.LoadAll {
queryArgs = append(queryArgs, "LOAD", "*")
}
if len(options.Steps) == 0 && options.Load != nil {
queryArgs = append(queryArgs, "LOAD", len(options.Load))
index, count := len(queryArgs)-1, 0
for _, load := range options.Load {
queryArgs = append(queryArgs, load.Field)
count++
if load.As != "" {
queryArgs = append(queryArgs, "AS", load.As)
count += 2
}
}
queryArgs[index] = count
}
if options.Timeout > 0 {
queryArgs = append(queryArgs, "TIMEOUT", options.Timeout)
}
if len(options.Steps) > 0 {
for _, step := range options.Steps {
var err error
queryArgs, err = appendFTAggregateStep(queryArgs, step)
if err != nil {
return nil, err
}
}
} else {
for _, apply := range options.Apply {
queryArgs = append(queryArgs, "APPLY", apply.Field)
if apply.As != "" {
queryArgs = append(queryArgs, "AS", apply.As)
}
}
if options.GroupBy != nil {
for _, groupBy := range options.GroupBy {
queryArgs = append(queryArgs, "GROUPBY", len(groupBy.Fields))
queryArgs = append(queryArgs, groupBy.Fields...)
for _, reducer := range groupBy.Reduce {
queryArgs = append(queryArgs, "REDUCE")
queryArgs = append(queryArgs, reducer.Reducer.String())
if reducer.Args != nil {
queryArgs = append(queryArgs, len(reducer.Args))
queryArgs = append(queryArgs, reducer.Args...)
} else {
queryArgs = append(queryArgs, 0)
}
if reducer.As != "" {
queryArgs = append(queryArgs, "AS", reducer.As)
}
}
}
}
if options.SortBy != nil {
queryArgs = append(queryArgs, "SORTBY")
sortByOptions := []interface{}{}
for _, sortBy := range options.SortBy {
sortByOptions = append(sortByOptions, sortBy.FieldName)
if sortBy.Asc && sortBy.Desc {
return nil, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive")
}
if sortBy.Asc {
sortByOptions = append(sortByOptions, "ASC")
}
if sortBy.Desc {
sortByOptions = append(sortByOptions, "DESC")
}
}
queryArgs = append(queryArgs, len(sortByOptions))
queryArgs = append(queryArgs, sortByOptions...)
}
if options.SortByMax > 0 {
queryArgs = append(queryArgs, "MAX", options.SortByMax)
}
}
if options.LimitOffset >= 0 && options.Limit > 0 {
queryArgs = append(queryArgs, "LIMIT", options.LimitOffset, options.Limit)
}
if options.Filter != "" {
queryArgs = append(queryArgs, "FILTER", options.Filter)
}
if options.WithCursor {
queryArgs = append(queryArgs, "WITHCURSOR")
if options.WithCursorOptions != nil {
if options.WithCursorOptions.Count > 0 {
queryArgs = append(queryArgs, "COUNT", options.WithCursorOptions.Count)
}
if options.WithCursorOptions.MaxIdle > 0 {
queryArgs = append(queryArgs, "MAXIDLE", options.WithCursorOptions.MaxIdle)
}
}
}
if options.Params != nil {
queryArgs = append(queryArgs, "PARAMS", len(options.Params)*2)
for key, value := range options.Params {
queryArgs = append(queryArgs, key, value)
}
}
if options.DialectVersion > 0 {
queryArgs = append(queryArgs, "DIALECT", options.DialectVersion)
} else {
queryArgs = append(queryArgs, "DIALECT", 2)
}
}
return queryArgs, nil
}
func ProcessAggregateResult(data []interface{}) (*FTAggregateResult, error) {
if len(data) == 0 {
return nil, fmt.Errorf("no data returned")
}
total, ok := data[0].(int64)
if !ok {
return nil, fmt.Errorf("invalid total format")
}
rows := make([]AggregateRow, 0, len(data)-1)
for _, row := range data[1:] {
fields, ok := row.([]interface{})
if !ok {
return nil, fmt.Errorf("invalid row format")
}
rowMap := make(map[string]interface{})
for i := 0; i < len(fields); i += 2 {
key, ok := fields[i].(string)
if !ok {
return nil, fmt.Errorf("invalid field key format")
}
value := fields[i+1]
rowMap[key] = value
}
rows = append(rows, AggregateRow{Fields: rowMap})
}
result := &FTAggregateResult{
Total: int(total),
Rows: rows,
}
return result, nil
}
func NewAggregateCmd(ctx context.Context, args ...interface{}) *AggregateCmd {
return &AggregateCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeAggregate,
},
}
}
func (cmd *AggregateCmd) SetVal(val *FTAggregateResult) {
cmd.val = val
}
func (cmd *AggregateCmd) Val() *FTAggregateResult {
cmd.await()
return cmd.val
}
func (cmd *AggregateCmd) Result() (*FTAggregateResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *AggregateCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *AggregateCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func (cmd *AggregateCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *AggregateCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array
if readType == proto.RespMap {
// Read raw response first for backwards compatibility
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
// Parse the raw response into structured result
if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok {
cmd.val, err = parseFTAggregateMapRESP3(mapVal)
} else {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
return err
}
// RESP2 format or error response - use ReadReply to handle errors properly
data, err := rd.ReadReply()
if err != nil {
return err
}
cmd.rawVal = data // Store raw value for debugging
if dataSlice, ok := data.([]interface{}); ok {
cmd.val, err = ProcessAggregateResult(dataSlice)
return err
}
return fmt.Errorf("unexpected response type: %T", data)
}
// parseFTAggregateMapRESP3 parses the RESP3 format response from FT.AGGREGATE.
// It takes a map[interface{}]interface{} which is the raw response from ReadReply().
// RESP3 format:
//
// %5
// $10 attributes => *0
// $13 total_results => :N
// $6 format => $6 STRING
// $7 results => *N (array of maps with extra_attributes, values)
// $7 warning => *N (array of strings)
func parseFTAggregateMapRESP3(data map[interface{}]interface{}) (*FTAggregateResult, error) {
result := &FTAggregateResult{
Rows: make([]AggregateRow, 0),
}
for k, v := range data {
key, ok := k.(string)
if !ok {
continue
}
switch key {
case "total_results":
result.Total = internal.ToInteger(v)
case "results":
if resultsData, ok := v.([]interface{}); ok {
rows, err := parseFTAggregateResultsMapRESP3(resultsData)
if err != nil {
return nil, err
}
result.Rows = rows
}
case "warning":
if warningsData, ok := v.([]interface{}); ok {
result.Warnings = make([]string, 0, len(warningsData))
for _, w := range warningsData {
if ws, ok := w.(string); ok {
result.Warnings = append(result.Warnings, ws)
}
}
}
// Ignore "attributes", "format", and other fields as per the spec
}
}
return result, nil
}
// parseFTAggregateResultsMapRESP3 parses the results array from RESP3 FT.AGGREGATE response.
func parseFTAggregateResultsMapRESP3(resultsData []interface{}) ([]AggregateRow, error) {
rows := make([]AggregateRow, 0, len(resultsData))
for _, item := range resultsData {
if itemMap, ok := item.(map[interface{}]interface{}); ok {
row, err := parseFTAggregateRowMapRESP3(itemMap)
if err != nil {
return nil, err
}
rows = append(rows, row)
}
}
return rows, nil
}
// parseFTAggregateRowMapRESP3 parses a single row from RESP3 FT.AGGREGATE response.
func parseFTAggregateRowMapRESP3(itemMap map[interface{}]interface{}) (AggregateRow, error) {
row := AggregateRow{
Fields: make(map[string]interface{}),
}
for k, v := range itemMap {
key, ok := k.(string)
if !ok {
continue
}
switch key {
case "extra_attributes":
if extraAttrs, ok := v.(map[interface{}]interface{}); ok {
for ek, ev := range extraAttrs {
if ekStr, ok := ek.(string); ok {
row.Fields[ekStr] = ev
}
}
}
// Ignore "values" and other fields as per the spec
}
}
return row, nil
}
func (cmd *AggregateCmd) Clone() Cmder {
var val *FTAggregateResult
if cmd.val != nil {
val = &FTAggregateResult{
Total: cmd.val.Total,
}
if cmd.val.Rows != nil {
val.Rows = make([]AggregateRow, len(cmd.val.Rows))
for i, row := range cmd.val.Rows {
val.Rows[i] = AggregateRow{}
if row.Fields != nil {
val.Rows[i].Fields = make(map[string]interface{}, len(row.Fields))
for k, v := range row.Fields {
val.Rows[i].Fields[k] = v
}
}
}
}
if cmd.val.Warnings != nil {
val.Warnings = make([]string, len(cmd.val.Warnings))
copy(val.Warnings, cmd.val.Warnings)
}
}
return &AggregateCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// FTAggregateWithArgs - Performs a search query on an index and applies a series of aggregate transformations to the result.
// The 'index' parameter specifies the index to search, and the 'query' parameter specifies the search query.
// This function also allows for specifying additional options such as: Verbatim, LoadAll, Load, Timeout, GroupBy, SortBy, SortByMax, Apply, LimitOffset, Limit, Filter, WithCursor, Params, and DialectVersion.
// For more information, please refer to the Redis documentation:
// [FT.AGGREGATE]: (https://redis.io/commands/ft.aggregate/)
func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query string, options *FTAggregateOptions) *AggregateCmd {
args := []interface{}{"FT.AGGREGATE", index, query}
if options != nil {
if err := validateFTAggregateOptions(options); err != nil {
cmd := NewAggregateCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
if options.Verbatim {
args = append(args, "VERBATIM")
}
if options.Scorer != "" {
args = append(args, "SCORER", options.Scorer)
}
if options.AddScores {
args = append(args, "ADDSCORES")
}
if options.LoadAll {
args = append(args, "LOAD", "*")
}
if len(options.Steps) == 0 && options.Load != nil {
args = append(args, "LOAD", len(options.Load))
index, count := len(args)-1, 0
for _, load := range options.Load {
args = append(args, load.Field)
count++
if load.As != "" {
args = append(args, "AS", load.As)
count += 2
}
}
args[index] = count
}
if options.Timeout > 0 {
args = append(args, "TIMEOUT", options.Timeout)
}
if len(options.Steps) > 0 {
for _, step := range options.Steps {
var err error
args, err = appendFTAggregateStep(args, step)
if err != nil {
cmd := NewAggregateCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
}
} else {
for _, apply := range options.Apply {
args = append(args, "APPLY", apply.Field)
if apply.As != "" {
args = append(args, "AS", apply.As)
}
}
if options.GroupBy != nil {
for _, groupBy := range options.GroupBy {
args = append(args, "GROUPBY", len(groupBy.Fields))
args = append(args, groupBy.Fields...)
for _, reducer := range groupBy.Reduce {
args = append(args, "REDUCE")
args = append(args, reducer.Reducer.String())
if reducer.Args != nil {
args = append(args, len(reducer.Args))
args = append(args, reducer.Args...)
} else {
args = append(args, 0)
}
if reducer.As != "" {
args = append(args, "AS", reducer.As)
}
}
}
}
if options.SortBy != nil {
args = append(args, "SORTBY")
sortByOptions := []interface{}{}
for _, sortBy := range options.SortBy {
sortByOptions = append(sortByOptions, sortBy.FieldName)
if sortBy.Asc && sortBy.Desc {
cmd := NewAggregateCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive"))
return cmd
}
if sortBy.Asc {
sortByOptions = append(sortByOptions, "ASC")
}
if sortBy.Desc {
sortByOptions = append(sortByOptions, "DESC")
}
}
args = append(args, len(sortByOptions))
args = append(args, sortByOptions...)
}
if options.SortByMax > 0 {
args = append(args, "MAX", options.SortByMax)
}
}
if options.LimitOffset >= 0 && options.Limit > 0 {
args = append(args, "LIMIT", options.LimitOffset, options.Limit)
}
if options.Filter != "" {
args = append(args, "FILTER", options.Filter)
}
if options.WithCursor {
args = append(args, "WITHCURSOR")
if options.WithCursorOptions != nil {
if options.WithCursorOptions.Count > 0 {
args = append(args, "COUNT", options.WithCursorOptions.Count)
}
if options.WithCursorOptions.MaxIdle > 0 {
args = append(args, "MAXIDLE", options.WithCursorOptions.MaxIdle)
}
}
}
if options.Params != nil {
args = append(args, "PARAMS", len(options.Params)*2)
for key, value := range options.Params {
args = append(args, key, value)
}
}
if options.DialectVersion > 0 {
args = append(args, "DIALECT", options.DialectVersion)
} else {
args = append(args, "DIALECT", 2)
}
}
cmd := NewAggregateCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTAliasAdd - Adds an alias to an index.
// The 'index' parameter specifies the index to which the alias is added, and the 'alias' parameter specifies the alias.
// For more information, please refer to the Redis documentation:
// [FT.ALIASADD]: (https://redis.io/commands/ft.aliasadd/)
func (c cmdable) FTAliasAdd(ctx context.Context, index string, alias string) *StatusCmd {
args := []interface{}{"FT.ALIASADD", alias, index}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTAliasDel - Removes an alias from an index.
// The 'alias' parameter specifies the alias to be removed.
// For more information, please refer to the Redis documentation:
// [FT.ALIASDEL]: (https://redis.io/commands/ft.aliasdel/)
func (c cmdable) FTAliasDel(ctx context.Context, alias string) *StatusCmd {
cmd := NewStatusCmd(ctx, "FT.ALIASDEL", alias)
_ = c(ctx, cmd)
return cmd
}
// FTAliasList - Lists all aliases associated with an index.
// The 'index' parameter specifies the index whose aliases are listed; it must
// be the name of an index created with FT.CREATE, not an alias.
// The reply is an unordered collection of alias names, already deduplicated
// by the server; an index with no aliases yields an empty result, not an
// error. Available since Redis 8.10.
// For more information, please refer to the Redis documentation:
// [FT.ALIASLIST]: (https://redis.io/commands/ft.aliaslist/)
func (c cmdable) FTAliasList(ctx context.Context, index string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "FT.ALIASLIST", index)
_ = c(ctx, cmd)
return cmd
}
// FTAliasUpdate - Updates an alias to an index.
// The 'index' parameter specifies the index to which the alias is updated, and the 'alias' parameter specifies the alias.
// If the alias already exists for a different index, it updates the alias to point to the specified index instead.
// For more information, please refer to the Redis documentation:
// [FT.ALIASUPDATE]: (https://redis.io/commands/ft.aliasupdate/)
func (c cmdable) FTAliasUpdate(ctx context.Context, index string, alias string) *StatusCmd {
cmd := NewStatusCmd(ctx, "FT.ALIASUPDATE", alias, index)
_ = c(ctx, cmd)
return cmd
}
// FTAlter - Alters the definition of an existing index.
// The 'index' parameter specifies the index to alter, and the 'skipInitialScan' parameter specifies whether to skip the initial scan.
// The 'definition' parameter specifies the new definition for the index.
// For more information, please refer to the Redis documentation:
// [FT.ALTER]: (https://redis.io/commands/ft.alter/)
func (c cmdable) FTAlter(ctx context.Context, index string, skipInitialScan bool, definition []interface{}) *StatusCmd {
args := []interface{}{"FT.ALTER", index}
if skipInitialScan {
args = append(args, "SKIPINITIALSCAN")
}
args = append(args, "SCHEMA", "ADD")
args = append(args, definition...)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Retrieves the value of a RediSearch configuration parameter.
// The 'option' parameter specifies the configuration parameter to retrieve.
// For more information, please refer to the Redis [FT.CONFIG GET] documentation.
//
// Deprecated: FTConfigGet is deprecated in Redis 8.
// All configuration will be done with the CONFIG GET command.
// For more information check [Client.ConfigGet] and [CONFIG GET Documentation]
//
// [CONFIG GET Documentation]: https://redis.io/commands/config-get/
// [FT.CONFIG GET]: https://redis.io/commands/ft.config-get/
func (c cmdable) FTConfigGet(ctx context.Context, option string) *MapMapStringInterfaceCmd {
cmd := NewMapMapStringInterfaceCmd(ctx, "FT.CONFIG", "GET", option)
_ = c(ctx, cmd)
return cmd
}
// Sets the value of a RediSearch configuration parameter.
// The 'option' parameter specifies the configuration parameter to set, and the 'value' parameter specifies the new value.
// For more information, please refer to the Redis [FT.CONFIG SET] documentation.
//
// Deprecated: FTConfigSet is deprecated in Redis 8.
// All configuration will be done with the CONFIG SET command.
// For more information check [Client.ConfigSet] and [CONFIG SET Documentation]
//
// [CONFIG SET Documentation]: https://redis.io/commands/config-set/
// [FT.CONFIG SET]: https://redis.io/commands/ft.config-set/
func (c cmdable) FTConfigSet(ctx context.Context, option string, value interface{}) *StatusCmd {
cmd := NewStatusCmd(ctx, "FT.CONFIG", "SET", option, value)
_ = c(ctx, cmd)
return cmd
}
// FTCreate - Creates a new index with the given options and schema.
// The 'index' parameter specifies the name of the index to create.
// The 'options' parameter specifies various options for the index, such as:
// whether to index hashes or JSONs, prefixes, filters, default language, score, score field, payload field, etc.
// The 'schema' parameter specifies the schema for the index, which includes the field name, field type, etc.
// For more information, please refer to the Redis documentation:
// [FT.CREATE]: (https://redis.io/commands/ft.create/)
func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOptions, schema ...*FieldSchema) *StatusCmd {
args := []interface{}{"FT.CREATE", index}
if options != nil {
if options.OnHash && !options.OnJSON {
args = append(args, "ON", "HASH")
}
if options.OnJSON && !options.OnHash {
args = append(args, "ON", "JSON")
}
if options.OnHash && options.OnJSON {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: ON HASH and ON JSON are mutually exclusive"))
return cmd
}
if options.Prefix != nil {
args = append(args, "PREFIX", len(options.Prefix))
args = append(args, options.Prefix...)
}
if options.Filter != "" {
args = append(args, "FILTER", options.Filter)
}
if options.DefaultLanguage != "" {
args = append(args, "LANGUAGE", options.DefaultLanguage)
}
if options.LanguageField != "" {
args = append(args, "LANGUAGE_FIELD", options.LanguageField)
}
if options.Score > 0 {
args = append(args, "SCORE", options.Score)
}
if options.ScoreField != "" {
args = append(args, "SCORE_FIELD", options.ScoreField)
}
if options.PayloadField != "" {
args = append(args, "PAYLOAD_FIELD", options.PayloadField)
}
if options.MaxTextFields > 0 {
args = append(args, "MAXTEXTFIELDS", options.MaxTextFields)
}
if options.NoOffsets {
args = append(args, "NOOFFSETS")
}
if options.Temporary > 0 {
args = append(args, "TEMPORARY", options.Temporary)
}
if options.NoHL {
args = append(args, "NOHL")
}
if options.NoFields {
args = append(args, "NOFIELDS")
}
if options.NoFreqs {
args = append(args, "NOFREQS")
}
if options.StopWords != nil {
args = append(args, "STOPWORDS", len(options.StopWords))
args = append(args, options.StopWords...)
}
if options.SkipInitialScan {
args = append(args, "SKIPINITIALSCAN")
}
}
if schema == nil {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA is required"))
return cmd
}
args = append(args, "SCHEMA")
for _, schema := range schema {
if schema.FieldName == "" || schema.FieldType == SearchFieldTypeInvalid {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldName and FieldType are required"))
return cmd
}
args = append(args, schema.FieldName)
if schema.As != "" {
args = append(args, "AS", schema.As)
}
args = append(args, schema.FieldType.String())
if schema.VectorArgs != nil {
if schema.FieldType != SearchFieldTypeVector {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldType VECTOR is required for VectorArgs"))
return cmd
}
// Check mutual exclusivity of vector options
optionCount := 0
if schema.VectorArgs.FlatOptions != nil {
optionCount++
}
if schema.VectorArgs.HNSWOptions != nil {
optionCount++
}
if schema.VectorArgs.VamanaOptions != nil {
optionCount++
}
if optionCount != 1 {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA VectorArgs must have exactly one of FlatOptions, HNSWOptions, or VamanaOptions"))
return cmd
}
if schema.VectorArgs.FlatOptions != nil {
args = append(args, "FLAT")
if schema.VectorArgs.FlatOptions.Type == "" || schema.VectorArgs.FlatOptions.Dim == 0 || schema.VectorArgs.FlatOptions.DistanceMetric == "" {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR FLAT"))
return cmd
}
flatArgs := []interface{}{
"TYPE", schema.VectorArgs.FlatOptions.Type,
"DIM", schema.VectorArgs.FlatOptions.Dim,
"DISTANCE_METRIC", schema.VectorArgs.FlatOptions.DistanceMetric,
}
if schema.VectorArgs.FlatOptions.InitialCapacity > 0 {
flatArgs = append(flatArgs, "INITIAL_CAP", schema.VectorArgs.FlatOptions.InitialCapacity)
}
if schema.VectorArgs.FlatOptions.BlockSize > 0 {
flatArgs = append(flatArgs, "BLOCK_SIZE", schema.VectorArgs.FlatOptions.BlockSize)
}
args = append(args, len(flatArgs))
args = append(args, flatArgs...)
}
if schema.VectorArgs.HNSWOptions != nil {
args = append(args, "HNSW")
if schema.VectorArgs.HNSWOptions.Type == "" || schema.VectorArgs.HNSWOptions.Dim == 0 || schema.VectorArgs.HNSWOptions.DistanceMetric == "" {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR HNSW"))
return cmd
}
hnswArgs := []interface{}{
"TYPE", schema.VectorArgs.HNSWOptions.Type,
"DIM", schema.VectorArgs.HNSWOptions.Dim,
"DISTANCE_METRIC", schema.VectorArgs.HNSWOptions.DistanceMetric,
}
if schema.VectorArgs.HNSWOptions.InitialCapacity > 0 {
hnswArgs = append(hnswArgs, "INITIAL_CAP", schema.VectorArgs.HNSWOptions.InitialCapacity)
}
if schema.VectorArgs.HNSWOptions.MaxEdgesPerNode > 0 {
hnswArgs = append(hnswArgs, "M", schema.VectorArgs.HNSWOptions.MaxEdgesPerNode)
}
if schema.VectorArgs.HNSWOptions.MaxAllowedEdgesPerNode > 0 {
hnswArgs = append(hnswArgs, "EF_CONSTRUCTION", schema.VectorArgs.HNSWOptions.MaxAllowedEdgesPerNode)
}
if schema.VectorArgs.HNSWOptions.EFRunTime > 0 {
hnswArgs = append(hnswArgs, "EF_RUNTIME", schema.VectorArgs.HNSWOptions.EFRunTime)
}
if schema.VectorArgs.HNSWOptions.Epsilon > 0 {
hnswArgs = append(hnswArgs, "EPSILON", schema.VectorArgs.HNSWOptions.Epsilon)
}
if schema.VectorArgs.HNSWOptions.Rerank || schema.VectorArgs.HNSWOptions.HasRerank {
rerank := "FALSE"
if schema.VectorArgs.HNSWOptions.Rerank {
rerank = "TRUE"
}
hnswArgs = append(hnswArgs, "RERANK", rerank)
}
args = append(args, len(hnswArgs))
args = append(args, hnswArgs...)
}
if schema.VectorArgs.VamanaOptions != nil {
args = append(args, "SVS-VAMANA")
if schema.VectorArgs.VamanaOptions.Type == "" || schema.VectorArgs.VamanaOptions.Dim == 0 || schema.VectorArgs.VamanaOptions.DistanceMetric == "" {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR VAMANA"))
return cmd
}
vamanaArgs := []interface{}{
"TYPE", schema.VectorArgs.VamanaOptions.Type,
"DIM", schema.VectorArgs.VamanaOptions.Dim,
"DISTANCE_METRIC", schema.VectorArgs.VamanaOptions.DistanceMetric,
}
if schema.VectorArgs.VamanaOptions.Compression != "" {
vamanaArgs = append(vamanaArgs, "COMPRESSION", schema.VectorArgs.VamanaOptions.Compression)
}
if schema.VectorArgs.VamanaOptions.ConstructionWindowSize > 0 {
vamanaArgs = append(vamanaArgs, "CONSTRUCTION_WINDOW_SIZE", schema.VectorArgs.VamanaOptions.ConstructionWindowSize)
}
if schema.VectorArgs.VamanaOptions.GraphMaxDegree > 0 {
vamanaArgs = append(vamanaArgs, "GRAPH_MAX_DEGREE", schema.VectorArgs.VamanaOptions.GraphMaxDegree)
}
if schema.VectorArgs.VamanaOptions.SearchWindowSize > 0 {
vamanaArgs = append(vamanaArgs, "SEARCH_WINDOW_SIZE", schema.VectorArgs.VamanaOptions.SearchWindowSize)
}
if schema.VectorArgs.VamanaOptions.Epsilon > 0 {
vamanaArgs = append(vamanaArgs, "EPSILON", schema.VectorArgs.VamanaOptions.Epsilon)
}
if schema.VectorArgs.VamanaOptions.TrainingThreshold > 0 {
vamanaArgs = append(vamanaArgs, "TRAINING_THRESHOLD", schema.VectorArgs.VamanaOptions.TrainingThreshold)
}
if schema.VectorArgs.VamanaOptions.ReduceDim > 0 {
vamanaArgs = append(vamanaArgs, "REDUCE", schema.VectorArgs.VamanaOptions.ReduceDim)
}
args = append(args, len(vamanaArgs))
args = append(args, vamanaArgs...)
}
}
if schema.GeoShapeFieldType != "" {
if schema.FieldType != SearchFieldTypeGeoShape {
cmd := NewStatusCmd(ctx, args...)
cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldType GEOSHAPE is required for GeoShapeFieldType"))
return cmd
}
args = append(args, schema.GeoShapeFieldType)
}
if schema.NoStem {
args = append(args, "NOSTEM")
}
if schema.Sortable {
args = append(args, "SORTABLE")
}
if schema.UNF {
args = append(args, "UNF")
}
if schema.NoIndex {
args = append(args, "NOINDEX")
}
if schema.PhoneticMatcher != "" {
args = append(args, "PHONETIC", schema.PhoneticMatcher)
}
if schema.Weight > 0 {
args = append(args, "WEIGHT", schema.Weight)
}
if schema.Separator != "" {
args = append(args, "SEPARATOR", schema.Separator)
}
if schema.CaseSensitive {
args = append(args, "CASESENSITIVE")
}
if schema.WithSuffixtrie {
args = append(args, "WITHSUFFIXTRIE")
}
if schema.IndexEmpty {
args = append(args, "INDEXEMPTY")
}
if schema.IndexMissing {
args = append(args, "INDEXMISSING")
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTCursorDel - Deletes a cursor from an existing index.
// The 'index' parameter specifies the index from which to delete the cursor, and the 'cursorId' parameter specifies the ID of the cursor to delete.
// For more information, please refer to the Redis documentation:
// [FT.CURSOR DEL]: (https://redis.io/commands/ft.cursor-del/)
func (c cmdable) FTCursorDel(ctx context.Context, index string, cursorId int) *StatusCmd {
cmd := NewStatusCmd(ctx, "FT.CURSOR", "DEL", index, cursorId)
_ = c(ctx, cmd)
return cmd
}
// FTCursorRead - Reads the next results from an existing cursor.
// The 'index' parameter specifies the index from which to read the cursor, the 'cursorId' parameter specifies the ID of the cursor to read, and the 'count' parameter specifies the number of results to read.
// For more information, please refer to the Redis documentation:
// [FT.CURSOR READ]: (https://redis.io/commands/ft.cursor-read/)
func (c cmdable) FTCursorRead(ctx context.Context, index string, cursorId int, count int) *MapStringInterfaceCmd {
args := []interface{}{"FT.CURSOR", "READ", index, cursorId}
if count > 0 {
args = append(args, "COUNT", count)
}
cmd := NewMapStringInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTDictAdd - Adds terms to a dictionary.
// The 'dict' parameter specifies the dictionary to which to add the terms, and the 'term' parameter specifies the terms to add.
// For more information, please refer to the Redis documentation:
// [FT.DICTADD]: (https://redis.io/commands/ft.dictadd/)
func (c cmdable) FTDictAdd(ctx context.Context, dict string, term ...interface{}) *IntCmd {
args := []interface{}{"FT.DICTADD", dict}
args = append(args, term...)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTDictDel - Deletes terms from a dictionary.
// The 'dict' parameter specifies the dictionary from which to delete the terms, and the 'term' parameter specifies the terms to delete.
// For more information, please refer to the Redis documentation:
// [FT.DICTDEL]: (https://redis.io/commands/ft.dictdel/)
func (c cmdable) FTDictDel(ctx context.Context, dict string, term ...interface{}) *IntCmd {
args := []interface{}{"FT.DICTDEL", dict}
args = append(args, term...)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTDictDump - Returns all terms in the specified dictionary.
// The 'dict' parameter specifies the dictionary from which to return the terms.
// For more information, please refer to the Redis documentation:
// [FT.DICTDUMP]: (https://redis.io/commands/ft.dictdump/)
func (c cmdable) FTDictDump(ctx context.Context, dict string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "FT.DICTDUMP", dict)
_ = c(ctx, cmd)
return cmd
}
// FTDropIndex - Deletes an index.
// The 'index' parameter specifies the index to delete.
// For more information, please refer to the Redis documentation:
// [FT.DROPINDEX]: (https://redis.io/commands/ft.dropindex/)
func (c cmdable) FTDropIndex(ctx context.Context, index string) *StatusCmd {
args := []interface{}{"FT.DROPINDEX", index}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTDropIndexWithArgs - Deletes an index with options.
// The 'index' parameter specifies the index to delete, and the 'options' parameter specifies the DeleteDocs option for docs deletion.
// For more information, please refer to the Redis documentation:
// [FT.DROPINDEX]: (https://redis.io/commands/ft.dropindex/)
func (c cmdable) FTDropIndexWithArgs(ctx context.Context, index string, options *FTDropIndexOptions) *StatusCmd {
args := []interface{}{"FT.DROPINDEX", index}
if options != nil {
if options.DeleteDocs {
args = append(args, "DD")
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTExplain - Returns the execution plan for a complex query.
// The 'index' parameter specifies the index to query, and the 'query' parameter specifies the query string.
// For more information, please refer to the Redis documentation:
// [FT.EXPLAIN]: (https://redis.io/commands/ft.explain/)
func (c cmdable) FTExplain(ctx context.Context, index string, query string) *StringCmd {
cmd := NewStringCmd(ctx, "FT.EXPLAIN", index, query)
_ = c(ctx, cmd)
return cmd
}
// FTExplainWithArgs - Returns the execution plan for a complex query with options.
// The 'index' parameter specifies the index to query, the 'query' parameter specifies the query string, and the 'options' parameter specifies the Dialect for the query.
// For more information, please refer to the Redis documentation:
// [FT.EXPLAIN]: (https://redis.io/commands/ft.explain/)
func (c cmdable) FTExplainWithArgs(ctx context.Context, index string, query string, options *FTExplainOptions) *StringCmd {
args := []interface{}{"FT.EXPLAIN", index, query}
if options.Dialect != "" {
args = append(args, "DIALECT", options.Dialect)
} else {
args = append(args, "DIALECT", 2)
}
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTExplainCli - Returns the execution plan for a complex query. [Not Implemented]
// For more information, see https://redis.io/commands/ft.explaincli/
func (c cmdable) FTExplainCli(ctx context.Context, key, path string) error {
return fmt.Errorf("FTExplainCli is not implemented")
}
// parseFTAttributeFromMap parses an FTAttribute from a RESP3 map format
func parseFTAttributeFromMap(attrMap map[interface{}]interface{}) FTAttribute {
att := FTAttribute{}
for k, v := range attrMap {
key := internal.ToLower(internal.ToString(k))
switch key {
case "attribute":
att.Attribute = internal.ToString(v)
case "identifier":
att.Identifier = internal.ToString(v)
case "type":
att.Type = internal.ToString(v)
case "weight":
att.Weight = internal.ToFloat(v)
case "phonetic":
att.PhoneticMatcher = internal.ToString(v)
case "algorithm":
att.Algorithm = internal.ToString(v)
case "data_type":
att.DataType = internal.ToString(v)
case "dim":
att.Dim = internal.ToInteger(v)
case "distance_metric":
att.DistanceMetric = internal.ToString(v)
case "m":
att.M = internal.ToInteger(v)
case "ef_construction":
att.EFConstruction = internal.ToInteger(v)
case "flags":
// flags is an array of strings like ["SORTABLE", "NOSTEM"]
if flags, ok := v.([]interface{}); ok {
for _, flag := range flags {
flagStr := internal.ToLower(internal.ToString(flag))
switch flagStr {
case "nostem":
att.NoStem = true
case "sortable":
att.Sortable = true
case "noindex":
att.NoIndex = true
case "unf":
att.UNF = true
case "case_sensitive":
att.CaseSensitive = true
case "withsuffixtrie":
att.WithSuffixtrie = true
}
}
}
}
}
return att
}
// getMapStringKey extracts a string value from a map with interface{} keys
func getMapStringKey(m map[interface{}]interface{}, key string) interface{} {
if v, ok := m[key]; ok {
return v
}
return nil
}
// parseIndexErrorsRESP3 parses Index Errors from RESP3 map format
func parseIndexErrorsRESP3(m map[interface{}]interface{}) IndexErrors {
return IndexErrors{
IndexingFailures: internal.ToInteger(getMapStringKey(m, "indexing failures")),
LastIndexingError: internal.ToString(getMapStringKey(m, "last indexing error")),
LastIndexingErrorKey: internal.ToString(getMapStringKey(m, "last indexing error key")),
}
}
// parseCursorStatsRESP3 parses cursor_stats from RESP3 map format
func parseCursorStatsRESP3(m map[interface{}]interface{}) CursorStats {
return CursorStats{
GlobalIdle: internal.ToInteger(getMapStringKey(m, "global_idle")),
GlobalTotal: internal.ToInteger(getMapStringKey(m, "global_total")),
IndexCapacity: internal.ToInteger(getMapStringKey(m, "index_capacity")),
IndexTotal: internal.ToInteger(getMapStringKey(m, "index_total")),
}
}
// parseGCStatsRESP3 parses gc_stats from RESP3 map format
func parseGCStatsRESP3(m map[interface{}]interface{}) GCStats {
// Handle average_cycle_time_ms which can be a float64 (including NaN) or string
avgCycleTime := ""
if v := getMapStringKey(m, "average_cycle_time_ms"); v != nil {
switch val := v.(type) {
case string:
// Normalize to lowercase for consistency with RESP2
avgCycleTime = strings.ToLower(val)
case float64:
avgCycleTime = internal.FormatFloat(val)
}
}
return GCStats{
BytesCollected: ftInfoNumInt(getMapStringKey(m, "bytes_collected")),
TotalMsRun: ftInfoNumInt(getMapStringKey(m, "total_ms_run")),
TotalCycles: ftInfoNumInt(getMapStringKey(m, "total_cycles")),
AverageCycleTimeMs: avgCycleTime,
LastRunTimeMs: ftInfoNumInt(getMapStringKey(m, "last_run_time_ms")),
GCNumericTreesMissed: ftInfoNumInt(getMapStringKey(m, "gc_numeric_trees_missed")),
GCBlocksDenied: ftInfoNumInt(getMapStringKey(m, "gc_blocks_denied")),
}
}
// parseIndexDefinitionRESP3 parses index_definition from RESP3 map format
func parseIndexDefinitionRESP3(m map[interface{}]interface{}) IndexDefinition {
def := IndexDefinition{
KeyType: internal.ToString(getMapStringKey(m, "key_type")),
DefaultScore: internal.ToFloat(getMapStringKey(m, "default_score")),
}
if prefixes, ok := getMapStringKey(m, "prefixes").([]interface{}); ok {
def.Prefixes = internal.ToStringSlice(prefixes)
}
return def
}
// parseDialectStatsRESP3 parses dialect_stats from RESP3 map format
func parseDialectStatsRESP3(m map[interface{}]interface{}) map[string]int {
result := make(map[string]int)
for k, v := range m {
if kStr, ok := k.(string); ok {
result[kStr] = internal.ToInteger(v)
}
}
return result
}
// ftInfoNumString stringifies a value that RediSearch emits via REPLY_KVNUM
// (RedisModule_ReplyWithDouble): a bulk string in RESP2 but a native double
// in RESP3. Used for FTInfoResult fields whose public type is string.
// Special float values (NaN, +Inf, -Inf) are normalized to lowercase to match
// the RESP2 wire format.
func ftInfoNumString(val interface{}) string {
switch v := val.(type) {
case string:
return v
case float64:
return internal.FormatFloat(v)
case float32:
return internal.FormatFloat(float64(v))
case int64:
return strconv.FormatInt(v, 10)
case int:
return strconv.Itoa(v)
default:
return ""
}
}
// ftInfoNumInt converts a value that RediSearch emits via REPLY_KVNUM to int.
// In RESP2 the value is a bulk string; in RESP3 it is a native double, even
// for logically-integer fields (counters, byte sizes). This helper exists so
// the internal.ToInteger helper can remain strict about float-to-int coercion
// while still letting the RediSearch parsers read those values correctly.
func ftInfoNumInt(val interface{}) int {
switch v := val.(type) {
case float64:
return int(v)
case float32:
return int(v)
default:
return internal.ToInteger(v)
}
}
func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) {
var ftInfo FTInfoResult
// Parse Index Errors - handle both RESP2 (array) and RESP3 (map) formats
if indexErrors, ok := data["Index Errors"].([]interface{}); ok {
// RESP2 format: array with key-value pairs
ftInfo.IndexErrors = IndexErrors{
IndexingFailures: internal.ToInteger(indexErrors[1]),
LastIndexingError: internal.ToString(indexErrors[3]),
LastIndexingErrorKey: internal.ToString(indexErrors[5]),
}
} else if indexErrors, ok := data["Index Errors"].(map[interface{}]interface{}); ok {
// RESP3 format: map
ftInfo.IndexErrors = parseIndexErrorsRESP3(indexErrors)
}
if attributes, ok := data["attributes"].([]interface{}); ok {
for _, attr := range attributes {
att := FTAttribute{}
// Handle RESP2 format: attribute is []interface{}
if attrSlice, ok := attr.([]interface{}); ok {
attrLen := len(attrSlice)
for i := 0; i < attrLen; i++ {
if internal.ToLower(internal.ToString(attrSlice[i])) == "attribute" && i+1 < attrLen {
att.Attribute = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "identifier" && i+1 < attrLen {
att.Identifier = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "type" && i+1 < attrLen {
att.Type = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "weight" && i+1 < attrLen {
att.Weight = internal.ToFloat(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "nostem" {
att.NoStem = true
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "sortable" {
att.Sortable = true
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "noindex" {
att.NoIndex = true
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "unf" {
att.UNF = true
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "phonetic" && i+1 < attrLen {
att.PhoneticMatcher = internal.ToString(attrSlice[i+1])
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "case_sensitive" {
att.CaseSensitive = true
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "withsuffixtrie" {
att.WithSuffixtrie = true
continue
}
// vector specific attributes
if internal.ToLower(internal.ToString(attrSlice[i])) == "algorithm" && i+1 < attrLen {
att.Algorithm = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "data_type" && i+1 < attrLen {
att.DataType = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "dim" && i+1 < attrLen {
att.Dim = internal.ToInteger(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "distance_metric" && i+1 < attrLen {
att.DistanceMetric = internal.ToString(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "m" && i+1 < attrLen {
att.M = internal.ToInteger(attrSlice[i+1])
i++
continue
}
if internal.ToLower(internal.ToString(attrSlice[i])) == "ef_construction" && i+1 < attrLen {
att.EFConstruction = internal.ToInteger(attrSlice[i+1])
i++
continue
}
}
ftInfo.Attributes = append(ftInfo.Attributes, att)
} else if attrMap, ok := attr.(map[interface{}]interface{}); ok {
// Handle RESP3 format: attribute is map[interface{}]interface{}
att = parseFTAttributeFromMap(attrMap)
ftInfo.Attributes = append(ftInfo.Attributes, att)
}
}
}
ftInfo.BytesPerRecordAvg = ftInfoNumString(data["bytes_per_record_avg"])
ftInfo.Cleaning = internal.ToInteger(data["cleaning"])
// Parse cursor_stats - handle both RESP2 (array) and RESP3 (map) formats
if cursorStats, ok := data["cursor_stats"].([]interface{}); ok {
// RESP2 format
ftInfo.CursorStats = CursorStats{
GlobalIdle: internal.ToInteger(cursorStats[1]),
GlobalTotal: internal.ToInteger(cursorStats[3]),
IndexCapacity: internal.ToInteger(cursorStats[5]),
IndexTotal: internal.ToInteger(cursorStats[7]),
}
} else if cursorStats, ok := data["cursor_stats"].(map[interface{}]interface{}); ok {
// RESP3 format
ftInfo.CursorStats = parseCursorStatsRESP3(cursorStats)
}
// Parse dialect_stats - handle both RESP2 (array) and RESP3 (map) formats
if dialectStats, ok := data["dialect_stats"].([]interface{}); ok {
// RESP2 format
ftInfo.DialectStats = make(map[string]int)
for i := 0; i < len(dialectStats); i += 2 {
ftInfo.DialectStats[internal.ToString(dialectStats[i])] = internal.ToInteger(dialectStats[i+1])
}
} else if dialectStats, ok := data["dialect_stats"].(map[interface{}]interface{}); ok {
// RESP3 format
ftInfo.DialectStats = parseDialectStatsRESP3(dialectStats)
}
ftInfo.DocTableSizeMB = internal.ToFloat(data["doc_table_size_mb"])
// Parse field statistics - handle both RESP2 and RESP3 formats
if fieldStats, ok := data["field statistics"].([]interface{}); ok {
for _, stat := range fieldStats {
if statMap, ok := stat.([]interface{}); ok {
// RESP2 format
ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, FieldStatistic{
Identifier: internal.ToString(statMap[1]),
Attribute: internal.ToString(statMap[3]),
IndexErrors: IndexErrors{
IndexingFailures: internal.ToInteger(statMap[5].([]interface{})[1]),
LastIndexingError: internal.ToString(statMap[5].([]interface{})[3]),
LastIndexingErrorKey: internal.ToString(statMap[5].([]interface{})[5]),
},
})
} else if statMap, ok := stat.(map[interface{}]interface{}); ok {
// RESP3 format
fs := FieldStatistic{
Identifier: internal.ToString(getMapStringKey(statMap, "identifier")),
Attribute: internal.ToString(getMapStringKey(statMap, "attribute")),
}
if indexErrors, ok := getMapStringKey(statMap, "Index Errors").(map[interface{}]interface{}); ok {
fs.IndexErrors = parseIndexErrorsRESP3(indexErrors)
}
ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, fs)
}
}
}
// Parse gc_stats - handle both RESP2 (array) and RESP3 (map) formats
if gcStats, ok := data["gc_stats"].([]interface{}); ok {
// RESP2 format
ftInfo.GCStats = GCStats{}
for i := 0; i < len(gcStats); i += 2 {
if internal.ToLower(internal.ToString(gcStats[i])) == "bytes_collected" {
ftInfo.GCStats.BytesCollected = internal.ToInteger(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "total_ms_run" {
ftInfo.GCStats.TotalMsRun = internal.ToInteger(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "total_cycles" {
ftInfo.GCStats.TotalCycles = internal.ToInteger(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "average_cycle_time_ms" {
ftInfo.GCStats.AverageCycleTimeMs = internal.ToString(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "last_run_time_ms" {
ftInfo.GCStats.LastRunTimeMs = internal.ToInteger(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "gc_numeric_trees_missed" {
ftInfo.GCStats.GCNumericTreesMissed = internal.ToInteger(gcStats[i+1])
continue
}
if internal.ToLower(internal.ToString(gcStats[i])) == "gc_blocks_denied" {
ftInfo.GCStats.GCBlocksDenied = internal.ToInteger(gcStats[i+1])
continue
}
}
} else if gcStats, ok := data["gc_stats"].(map[interface{}]interface{}); ok {
// RESP3 format
ftInfo.GCStats = parseGCStatsRESP3(gcStats)
}
ftInfo.GeoshapesSzMB = internal.ToFloat(data["geoshapes_sz_mb"])
ftInfo.HashIndexingFailures = internal.ToInteger(data["hash_indexing_failures"])
// Parse index_definition - handle both RESP2 (array) and RESP3 (map) formats
if indexDef, ok := data["index_definition"].([]interface{}); ok {
// RESP2 format
ftInfo.IndexDefinition = IndexDefinition{
KeyType: internal.ToString(indexDef[1]),
Prefixes: internal.ToStringSlice(indexDef[3]),
DefaultScore: internal.ToFloat(indexDef[5]),
}
} else if indexDef, ok := data["index_definition"].(map[interface{}]interface{}); ok {
// RESP3 format
ftInfo.IndexDefinition = parseIndexDefinitionRESP3(indexDef)
}
ftInfo.IndexName = internal.ToString(data["index_name"])
if indexOptions, ok := data["index_options"].([]interface{}); ok {
ftInfo.IndexOptions = internal.ToStringSlice(indexOptions)
}
ftInfo.Indexing = internal.ToInteger(data["indexing"])
ftInfo.InvertedSzMB = internal.ToFloat(data["inverted_sz_mb"])
ftInfo.KeyTableSizeMB = internal.ToFloat(data["key_table_size_mb"])
ftInfo.MaxDocID = internal.ToInteger(data["max_doc_id"])
ftInfo.NumDocs = internal.ToInteger(data["num_docs"])
ftInfo.NumRecords = internal.ToInteger(data["num_records"])
ftInfo.NumTerms = internal.ToInteger(data["num_terms"])
ftInfo.NumberOfUses = internal.ToInteger(data["number_of_uses"])
ftInfo.OffsetBitsPerRecordAvg = ftInfoNumString(data["offset_bits_per_record_avg"])
ftInfo.OffsetVectorsSzMB = internal.ToFloat(data["offset_vectors_sz_mb"])
ftInfo.OffsetsPerTermAvg = ftInfoNumString(data["offsets_per_term_avg"])
ftInfo.PercentIndexed = internal.ToFloat(data["percent_indexed"])
ftInfo.RecordsPerDocAvg = ftInfoNumString(data["records_per_doc_avg"])
ftInfo.SortableValuesSizeMB = internal.ToFloat(data["sortable_values_size_mb"])
ftInfo.TagOverheadSzMB = internal.ToFloat(data["tag_overhead_sz_mb"])
ftInfo.TextOverheadSzMB = internal.ToFloat(data["text_overhead_sz_mb"])
ftInfo.TotalIndexMemorySzMB = internal.ToFloat(data["total_index_memory_sz_mb"])
ftInfo.TotalIndexingTime = ftInfoNumInt(data["total_indexing_time"])
ftInfo.TotalInvertedIndexBlocks = internal.ToInteger(data["total_inverted_index_blocks"])
ftInfo.VectorIndexSzMB = internal.ToFloat(data["vector_index_sz_mb"])
return ftInfo, nil
}
type FTInfoCmd struct {
baseCmd
val FTInfoResult
}
func newFTInfoCmd(ctx context.Context, args ...interface{}) *FTInfoCmd {
return &FTInfoCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFTInfo,
},
}
}
func (cmd *FTInfoCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FTInfoCmd) SetVal(val FTInfoResult) {
cmd.val = val
}
func (cmd *FTInfoCmd) Result() (FTInfoResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FTInfoCmd) Val() FTInfoResult {
cmd.await()
return cmd.val
}
func (cmd *FTInfoCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *FTInfoCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func (cmd *FTInfoCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array
if readType == proto.RespMap {
// Read raw response first for backwards compatibility
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
// Convert map[interface{}]interface{} to map[string]interface{}
rawMap, ok := cmd.rawVal.(map[interface{}]interface{})
if !ok {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
data := make(map[string]interface{}, len(rawMap))
for k, v := range rawMap {
if kStr, ok := k.(string); ok {
data[kStr] = v
}
}
cmd.val, err = parseFTInfo(data)
return err
}
// RESP2 format - read as map
n, err := rd.ReadMapLen()
if err != nil {
return err
}
data := make(map[string]interface{}, n)
for i := 0; i < n; i++ {
k, err := rd.ReadString()
if err != nil {
return err
}
v, err := rd.ReadReply()
if err != nil {
if err == Nil {
data[k] = Nil
continue
}
if err, ok := err.(proto.RedisError); ok {
data[k] = err
continue
}
return err
}
data[k] = v
}
cmd.val, err = parseFTInfo(data)
return err
}
func (cmd *FTInfoCmd) Clone() Cmder {
val := FTInfoResult{
IndexErrors: cmd.val.IndexErrors,
BytesPerRecordAvg: cmd.val.BytesPerRecordAvg,
Cleaning: cmd.val.Cleaning,
CursorStats: cmd.val.CursorStats,
DocTableSizeMB: cmd.val.DocTableSizeMB,
GCStats: cmd.val.GCStats,
GeoshapesSzMB: cmd.val.GeoshapesSzMB,
HashIndexingFailures: cmd.val.HashIndexingFailures,
IndexDefinition: cmd.val.IndexDefinition,
IndexName: cmd.val.IndexName,
Indexing: cmd.val.Indexing,
InvertedSzMB: cmd.val.InvertedSzMB,
KeyTableSizeMB: cmd.val.KeyTableSizeMB,
MaxDocID: cmd.val.MaxDocID,
NumDocs: cmd.val.NumDocs,
NumRecords: cmd.val.NumRecords,
NumTerms: cmd.val.NumTerms,
NumberOfUses: cmd.val.NumberOfUses,
OffsetBitsPerRecordAvg: cmd.val.OffsetBitsPerRecordAvg,
OffsetVectorsSzMB: cmd.val.OffsetVectorsSzMB,
OffsetsPerTermAvg: cmd.val.OffsetsPerTermAvg,
PercentIndexed: cmd.val.PercentIndexed,
RecordsPerDocAvg: cmd.val.RecordsPerDocAvg,
SortableValuesSizeMB: cmd.val.SortableValuesSizeMB,
TagOverheadSzMB: cmd.val.TagOverheadSzMB,
TextOverheadSzMB: cmd.val.TextOverheadSzMB,
TotalIndexMemorySzMB: cmd.val.TotalIndexMemorySzMB,
TotalIndexingTime: cmd.val.TotalIndexingTime,
TotalInvertedIndexBlocks: cmd.val.TotalInvertedIndexBlocks,
VectorIndexSzMB: cmd.val.VectorIndexSzMB,
}
// Clone slices and maps
if cmd.val.Attributes != nil {
val.Attributes = slices.Clone(cmd.val.Attributes)
}
if cmd.val.DialectStats != nil {
val.DialectStats = maps.Clone(cmd.val.DialectStats)
}
if cmd.val.FieldStatistics != nil {
val.FieldStatistics = slices.Clone(cmd.val.FieldStatistics)
}
if cmd.val.IndexOptions != nil {
val.IndexOptions = slices.Clone(cmd.val.IndexOptions)
}
if cmd.val.IndexDefinition.Prefixes != nil {
val.IndexDefinition.Prefixes = slices.Clone(cmd.val.IndexDefinition.Prefixes)
}
return &FTInfoCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// FTInfo - Retrieves information about an index.
// The 'index' parameter specifies the index to retrieve information about.
// For more information, please refer to the Redis documentation:
// [FT.INFO]: (https://redis.io/commands/ft.info/)
func (c cmdable) FTInfo(ctx context.Context, index string) *FTInfoCmd {
cmd := newFTInfoCmd(ctx, "FT.INFO", index)
_ = c(ctx, cmd)
return cmd
}
// FTSpellCheck - Checks a query string for spelling errors.
// For more details about spellcheck query please follow:
// https://redis.io/docs/interact/search-and-query/advanced-concepts/spellcheck/
// For more information, please refer to the Redis documentation:
// [FT.SPELLCHECK]: (https://redis.io/commands/ft.spellcheck/)
func (c cmdable) FTSpellCheck(ctx context.Context, index string, query string) *FTSpellCheckCmd {
args := []interface{}{"FT.SPELLCHECK", index, query}
cmd := newFTSpellCheckCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTSpellCheckWithArgs - Checks a query string for spelling errors with additional options.
// For more details about spellcheck query please follow:
// https://redis.io/docs/interact/search-and-query/advanced-concepts/spellcheck/
// For more information, please refer to the Redis documentation:
// [FT.SPELLCHECK]: (https://redis.io/commands/ft.spellcheck/)
func (c cmdable) FTSpellCheckWithArgs(ctx context.Context, index string, query string, options *FTSpellCheckOptions) *FTSpellCheckCmd {
args := []interface{}{"FT.SPELLCHECK", index, query}
if options != nil {
if options.Distance > 0 {
args = append(args, "DISTANCE", options.Distance)
}
if options.Terms != nil {
args = append(args, "TERMS", options.Terms.Inclusion, options.Terms.Dictionary)
args = append(args, options.Terms.Terms...)
}
if options.Dialect > 0 {
args = append(args, "DIALECT", options.Dialect)
} else {
args = append(args, "DIALECT", 2)
}
}
cmd := newFTSpellCheckCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type FTSpellCheckCmd struct {
baseCmd
val []SpellCheckResult
}
func newFTSpellCheckCmd(ctx context.Context, args ...interface{}) *FTSpellCheckCmd {
return &FTSpellCheckCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFTSpellCheck,
},
}
}
func (cmd *FTSpellCheckCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FTSpellCheckCmd) SetVal(val []SpellCheckResult) {
cmd.val = val
}
func (cmd *FTSpellCheckCmd) Result() ([]SpellCheckResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FTSpellCheckCmd) Val() []SpellCheckResult {
cmd.await()
return cmd.val
}
func (cmd *FTSpellCheckCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *FTSpellCheckCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func (cmd *FTSpellCheckCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array
if readType == proto.RespMap {
// Read raw response first for backwards compatibility
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
// Parse the raw response into structured result
rawMap, ok := cmd.rawVal.(map[interface{}]interface{})
if !ok {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
cmd.val, err = parseFTSpellCheckRESP3(rawMap)
return err
}
// RESP2 format
data, err := rd.ReadSlice()
if err != nil {
return err
}
cmd.val, err = parseFTSpellCheck(data)
return err
}
// parseFTSpellCheckRESP3 parses the RESP3 format response from FT.SPELLCHECK.
// RESP3 format:
//
// map{
// "results": map{
// "misspelled_term": [
// map{"suggestion": score},
// ...
// ],
// ...
// }
// }
func parseFTSpellCheckRESP3(data map[interface{}]interface{}) ([]SpellCheckResult, error) {
results := make([]SpellCheckResult, 0)
resultsData, ok := data["results"]
if !ok {
return results, nil
}
resultsMap, ok := resultsData.(map[interface{}]interface{})
if !ok {
return nil, fmt.Errorf("invalid results format: expected map, got %T", resultsData)
}
for termKey, suggestionsData := range resultsMap {
term, ok := termKey.(string)
if !ok {
continue
}
suggestionsArray, ok := suggestionsData.([]interface{})
if !ok {
continue
}
suggestions := make([]SpellCheckSuggestion, 0, len(suggestionsArray))
for _, suggestionData := range suggestionsArray {
suggestionMap, ok := suggestionData.(map[interface{}]interface{})
if !ok {
continue
}
for suggKey, scoreVal := range suggestionMap {
suggestion, ok := suggKey.(string)
if !ok {
continue
}
var score float64
switch v := scoreVal.(type) {
case float64:
score = v
case int64:
score = float64(v)
case string:
var err error
score, err = strconv.ParseFloat(v, 64)
if err != nil {
continue
}
default:
continue
}
suggestions = append(suggestions, SpellCheckSuggestion{
Score: score,
Suggestion: suggestion,
})
}
}
results = append(results, SpellCheckResult{
Term: term,
Suggestions: suggestions,
})
}
return results, nil
}
func parseFTSpellCheck(data []interface{}) ([]SpellCheckResult, error) {
results := make([]SpellCheckResult, 0, len(data))
for _, termData := range data {
termInfo, ok := termData.([]interface{})
if !ok || len(termInfo) != 3 {
return nil, fmt.Errorf("invalid term format")
}
term, ok := termInfo[1].(string)
if !ok {
return nil, fmt.Errorf("invalid term format")
}
suggestionsData, ok := termInfo[2].([]interface{})
if !ok {
return nil, fmt.Errorf("invalid suggestions format")
}
suggestions := make([]SpellCheckSuggestion, 0, len(suggestionsData))
for _, suggestionData := range suggestionsData {
suggestionInfo, ok := suggestionData.([]interface{})
if !ok || len(suggestionInfo) != 2 {
return nil, fmt.Errorf("invalid suggestion format")
}
scoreStr, ok := suggestionInfo[0].(string)
if !ok {
return nil, fmt.Errorf("invalid suggestion score format")
}
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil {
return nil, fmt.Errorf("invalid suggestion score value")
}
suggestion, ok := suggestionInfo[1].(string)
if !ok {
return nil, fmt.Errorf("invalid suggestion format")
}
suggestions = append(suggestions, SpellCheckSuggestion{
Score: score,
Suggestion: suggestion,
})
}
results = append(results, SpellCheckResult{
Term: term,
Suggestions: suggestions,
})
}
return results, nil
}
func (cmd *FTSpellCheckCmd) Clone() Cmder {
var val []SpellCheckResult
if cmd.val != nil {
val = make([]SpellCheckResult, len(cmd.val))
for i, result := range cmd.val {
val[i] = SpellCheckResult{
Term: result.Term,
}
if result.Suggestions != nil {
val[i].Suggestions = slices.Clone(result.Suggestions)
}
}
}
return &FTSpellCheckCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
func parseFTSearch(data []interface{}, noContent, withScores, withPayloads, withSortKeys bool) (FTSearchResult, error) {
if len(data) < 1 {
return FTSearchResult{}, fmt.Errorf("unexpected search result format")
}
total, ok := data[0].(int64)
if !ok {
return FTSearchResult{}, fmt.Errorf("invalid total results format")
}
var results []Document
for i := 1; i < len(data); {
docID, ok := data[i].(string)
if !ok {
return FTSearchResult{}, fmt.Errorf("invalid document ID format")
}
doc := Document{
ID: docID,
Fields: make(map[string]string),
}
i++
if noContent {
results = append(results, doc)
continue
}
if withScores && i < len(data) {
if scoreStr, ok := data[i].(string); ok {
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil {
return FTSearchResult{}, fmt.Errorf("invalid score format")
}
doc.Score = &score
i++
}
}
if withPayloads && i < len(data) {
if payload, ok := data[i].(string); ok {
doc.Payload = &payload
i++
}
}
if withSortKeys && i < len(data) {
if sortKey, ok := data[i].(string); ok {
doc.SortKey = &sortKey
i++
}
}
if i < len(data) {
fields, ok := data[i].([]interface{})
if !ok {
if data[i] == proto.Nil || data[i] == nil {
doc.Error = proto.Nil
doc.Fields = map[string]string{}
fields = []interface{}{}
} else {
return FTSearchResult{}, fmt.Errorf("invalid document fields format")
}
}
for j := 0; j < len(fields); j += 2 {
key, ok := fields[j].(string)
if !ok {
return FTSearchResult{}, fmt.Errorf("invalid field key format")
}
value, ok := fields[j+1].(string)
if !ok {
return FTSearchResult{}, fmt.Errorf("invalid field value format")
}
doc.Fields[key] = value
}
i++
}
results = append(results, doc)
}
return FTSearchResult{
Total: int(total),
Docs: results,
}, nil
}
type FTSearchCmd struct {
baseCmd
val FTSearchResult
options *FTSearchOptions
}
func newFTSearchCmd(ctx context.Context, options *FTSearchOptions, args ...interface{}) *FTSearchCmd {
return &FTSearchCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFTSearch,
},
options: options,
}
}
func (cmd *FTSearchCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FTSearchCmd) SetVal(val FTSearchResult) {
cmd.val = val
}
func (cmd *FTSearchCmd) Result() (FTSearchResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FTSearchCmd) Val() FTSearchResult {
cmd.await()
return cmd.val
}
func (cmd *FTSearchCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *FTSearchCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array
if readType == proto.RespMap {
// Read raw response first for backwards compatibility
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
// Parse the raw response into structured result
if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok {
cmd.val, err = parseFTSearchMapRESP3(mapVal)
} else {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
return err
}
// RESP2 format or error response - use ReadReply to handle errors properly
data, err := rd.ReadReply()
if err != nil {
return err
}
if dataSlice, ok := data.([]interface{}); ok {
cmd.val, err = parseFTSearch(dataSlice, cmd.options.NoContent, cmd.options.WithScores, cmd.options.WithPayloads, cmd.options.WithSortKeys)
return err
}
return fmt.Errorf("unexpected response type: %T", data)
}
// parseFTSearchMapRESP3 parses the RESP3 format response from FT.SEARCH.
// It takes a map[interface{}]interface{} which is the raw response from ReadReply().
// RESP3 format:
//
// %5
// $10 attributes => *0
// $13 total_results => :N
// $6 format => $6 STRING
// $7 results => *N (array of maps with id, score, extra_attributes, values)
// $7 warning => *N (array of strings)
func parseFTSearchMapRESP3(data map[interface{}]interface{}) (FTSearchResult, error) {
var result FTSearchResult
result.Docs = make([]Document, 0)
for k, v := range data {
key, ok := k.(string)
if !ok {
continue
}
switch key {
case "total_results":
result.Total = internal.ToInteger(v)
case "results":
if resultsData, ok := v.([]interface{}); ok {
docs, err := parseFTSearchResultsMapRESP3(resultsData)
if err != nil {
return FTSearchResult{}, err
}
result.Docs = docs
}
case "warning":
if warningsData, ok := v.([]interface{}); ok {
result.Warnings = make([]string, 0, len(warningsData))
for _, w := range warningsData {
if ws, ok := w.(string); ok {
result.Warnings = append(result.Warnings, ws)
}
}
}
// Ignore "attributes", "format", and other fields as per the spec
}
}
return result, nil
}
// parseFTSearchResultsMapRESP3 parses the results array from RESP3 FT.SEARCH response.
func parseFTSearchResultsMapRESP3(resultsData []interface{}) ([]Document, error) {
docs := make([]Document, 0, len(resultsData))
for _, item := range resultsData {
if itemMap, ok := item.(map[interface{}]interface{}); ok {
doc, err := parseFTSearchDocumentMapRESP3(itemMap)
if err != nil {
return nil, err
}
docs = append(docs, doc)
}
}
return docs, nil
}
// parseFTSearchDocumentMapRESP3 parses a single document from RESP3 FT.SEARCH response.
func parseFTSearchDocumentMapRESP3(itemMap map[interface{}]interface{}) (Document, error) {
doc := Document{
Fields: make(map[string]string),
}
for k, v := range itemMap {
key, ok := k.(string)
if !ok {
continue
}
switch key {
case "id":
if id, ok := v.(string); ok {
doc.ID = id
}
case "score":
if score, ok := v.(float64); ok {
doc.Score = &score
}
case "payload":
if payload, ok := v.(string); ok {
doc.Payload = &payload
}
case "sortkey":
if sortKey, ok := v.(string); ok {
doc.SortKey = &sortKey
}
case "extra_attributes":
if extraAttrs, ok := v.(map[interface{}]interface{}); ok {
for ek, ev := range extraAttrs {
if ekStr, ok := ek.(string); ok {
if evStr, ok := ev.(string); ok {
doc.Fields[ekStr] = evStr
}
}
}
}
// Ignore "values" and other fields as per the spec
}
}
return doc, nil
}
func (cmd *FTSearchCmd) Clone() Cmder {
val := FTSearchResult{
Total: cmd.val.Total,
}
if cmd.val.Docs != nil {
val.Docs = make([]Document, len(cmd.val.Docs))
for i, doc := range cmd.val.Docs {
val.Docs[i] = Document{
ID: doc.ID,
Score: doc.Score,
Payload: doc.Payload,
SortKey: doc.SortKey,
}
if doc.Fields != nil {
val.Docs[i].Fields = make(map[string]string, len(doc.Fields))
for k, v := range doc.Fields {
val.Docs[i].Fields[k] = v
}
}
}
}
if cmd.val.Warnings != nil {
val.Warnings = make([]string, len(cmd.val.Warnings))
copy(val.Warnings, cmd.val.Warnings)
}
var options *FTSearchOptions
if cmd.options != nil {
options = &FTSearchOptions{
NoContent: cmd.options.NoContent,
Verbatim: cmd.options.Verbatim,
NoStopWords: cmd.options.NoStopWords,
WithScores: cmd.options.WithScores,
WithPayloads: cmd.options.WithPayloads,
WithSortKeys: cmd.options.WithSortKeys,
Slop: cmd.options.Slop,
Timeout: cmd.options.Timeout,
InOrder: cmd.options.InOrder,
Language: cmd.options.Language,
Expander: cmd.options.Expander,
Scorer: cmd.options.Scorer,
ExplainScore: cmd.options.ExplainScore,
Payload: cmd.options.Payload,
SortByWithCount: cmd.options.SortByWithCount,
LimitOffset: cmd.options.LimitOffset,
Limit: cmd.options.Limit,
CountOnly: cmd.options.CountOnly,
DialectVersion: cmd.options.DialectVersion,
}
// Clone slices and maps
if cmd.options.Filters != nil {
options.Filters = slices.Clone(cmd.options.Filters)
}
if cmd.options.GeoFilter != nil {
options.GeoFilter = slices.Clone(cmd.options.GeoFilter)
}
if cmd.options.InKeys != nil {
options.InKeys = slices.Clone(cmd.options.InKeys)
}
if cmd.options.InFields != nil {
options.InFields = slices.Clone(cmd.options.InFields)
}
if cmd.options.Return != nil {
options.Return = slices.Clone(cmd.options.Return)
}
if cmd.options.SortBy != nil {
options.SortBy = slices.Clone(cmd.options.SortBy)
}
if cmd.options.Params != nil {
options.Params = maps.Clone(cmd.options.Params)
}
}
return &FTSearchCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
options: options,
}
}
// FTHybridResult represents the result of a hybrid search operation
type FTHybridResult struct {
TotalResults int
Results []map[string]interface{}
// Warnings holds server warnings for a partial result (search-on-timeout
// return/return-strict), on RESP2 and RESP3; the fail policy returns an error.
Warnings []string
ExecutionTime float64
}
// FTHybridCursorResult represents cursor result for hybrid search
type FTHybridCursorResult struct {
SearchCursorID int
VsimCursorID int
}
type FTHybridCmd struct {
baseCmd
val FTHybridResult
cursorVal *FTHybridCursorResult
options *FTHybridOptions
withCursor bool
}
func newFTHybridCmd(ctx context.Context, options *FTHybridOptions, args ...interface{}) *FTHybridCmd {
var withCursor bool
if options != nil && options.WithCursor {
withCursor = true
}
return &FTHybridCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
},
options: options,
withCursor: withCursor,
}
}
func (cmd *FTHybridCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FTHybridCmd) SetVal(val FTHybridResult) {
cmd.val = val
}
func (cmd *FTHybridCmd) Result() (FTHybridResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FTHybridCmd) CursorResult() (*FTHybridCursorResult, error) {
cmd.await()
return cmd.cursorVal, cmd.err
}
func (cmd *FTHybridCmd) Val() FTHybridResult {
cmd.await()
return cmd.val
}
func (cmd *FTHybridCmd) CursorVal() *FTHybridCursorResult {
cmd.await()
return cmd.cursorVal
}
func (cmd *FTHybridCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *FTHybridCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func parseFTHybrid(data []interface{}, withCursor bool) (FTHybridResult, *FTHybridCursorResult, error) {
// Convert to map
resultMap := make(map[string]interface{})
for i := 0; i < len(data); i += 2 {
if i+1 < len(data) {
key, ok := data[i].(string)
if !ok {
return FTHybridResult{}, nil, fmt.Errorf("invalid key type at index %d", i)
}
resultMap[key] = data[i+1]
}
}
// Handle cursor result
if withCursor {
searchCursorID, ok1 := resultMap["SEARCH"].(int64)
vsimCursorID, ok2 := resultMap["VSIM"].(int64)
if !ok1 || !ok2 {
return FTHybridResult{}, nil, fmt.Errorf("invalid cursor result format")
}
return FTHybridResult{}, &FTHybridCursorResult{
SearchCursorID: int(searchCursorID),
VsimCursorID: int(vsimCursorID),
}, nil
}
// Parse regular result
totalResults, ok := resultMap["total_results"].(int64)
if !ok {
return FTHybridResult{}, nil, fmt.Errorf("invalid total_results format")
}
resultsData, ok := resultMap["results"].([]interface{})
if !ok {
return FTHybridResult{}, nil, fmt.Errorf("invalid results format")
}
// Parse each result item
results := make([]map[string]interface{}, 0, len(resultsData))
for _, item := range resultsData {
// Try parsing as map[string]interface{} first (RESP3 format)
if itemMap, ok := item.(map[string]interface{}); ok {
results = append(results, itemMap)
continue
}
// Try parsing as map[interface{}]interface{} (alternative RESP3 format)
if rawMap, ok := item.(map[interface{}]interface{}); ok {
itemMap := make(map[string]interface{})
for k, v := range rawMap {
if keyStr, ok := k.(string); ok {
itemMap[keyStr] = v
}
}
results = append(results, itemMap)
continue
}
// Fall back to array format (RESP2 format - key-value pairs)
itemData, ok := item.([]interface{})
if !ok {
return FTHybridResult{}, nil, fmt.Errorf("invalid result item format")
}
itemMap := make(map[string]interface{})
for i := 0; i < len(itemData); i += 2 {
if i+1 < len(itemData) {
key, ok := itemData[i].(string)
if !ok {
return FTHybridResult{}, nil, fmt.Errorf("invalid item key format")
}
itemMap[key] = itemData[i+1]
}
}
results = append(results, itemMap)
}
// Optional warnings; accept both "warning" (as FT.SEARCH/FT.AGGREGATE) and "warnings".
var warnings []string
warningsData, ok := resultMap["warning"].([]interface{})
if !ok {
warningsData, ok = resultMap["warnings"].([]interface{})
}
if ok {
warnings = make([]string, 0, len(warningsData))
for _, w := range warningsData {
if ws, ok := w.(string); ok {
warnings = append(warnings, ws)
}
}
}
// Parse execution time (optional field)
var executionTime float64
if execTimeVal, exists := resultMap["execution_time"]; exists {
switch v := execTimeVal.(type) {
case string:
var err error
executionTime, err = strconv.ParseFloat(v, 64)
if err != nil {
return FTHybridResult{}, nil, fmt.Errorf("invalid execution_time format: %v", err)
}
case float64:
executionTime = v
case int64:
executionTime = float64(v)
}
}
return FTHybridResult{
TotalResults: int(totalResults),
Results: results,
Warnings: warnings,
ExecutionTime: executionTime,
}, nil, nil
}
func (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array. ReadSlice reads a map
// header's declared length as an element count, so it consumes only half
// the key/value frames and leaves the rest on the connection; ReadReply
// consumes the whole map. Flatten it into the key/value list parseFTHybrid
// expects, matching AggregateCmd/FTSearchCmd/FTSpellCheckCmd/FTSynDumpCmd.
var data []interface{}
if readType == proto.RespMap {
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
rawMap, ok := cmd.rawVal.(map[interface{}]interface{})
if !ok {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
data = make([]interface{}, 0, len(rawMap)*2)
for k, v := range rawMap {
data = append(data, k, v)
}
} else {
data, err = rd.ReadSlice()
if err != nil {
return err
}
// Populate rawVal on the RESP2 path too, so RawVal()/RawResult() behave
// the same regardless of protocol (the RESP3 branch sets it above).
cmd.rawVal = data
}
result, cursorResult, err := parseFTHybrid(data, cmd.withCursor)
if err != nil {
return err
}
if cmd.withCursor {
cmd.cursorVal = cursorResult
} else {
cmd.val = result
}
return nil
}
func (cmd *FTHybridCmd) Clone() Cmder {
val := FTHybridResult{
TotalResults: cmd.val.TotalResults,
ExecutionTime: cmd.val.ExecutionTime,
}
if cmd.val.Results != nil {
val.Results = make([]map[string]interface{}, len(cmd.val.Results))
for i, result := range cmd.val.Results {
val.Results[i] = make(map[string]interface{}, len(result))
for k, v := range result {
val.Results[i][k] = v
}
}
}
if cmd.val.Warnings != nil {
val.Warnings = slices.Clone(cmd.val.Warnings)
}
var cursorVal *FTHybridCursorResult
if cmd.cursorVal != nil {
cursorVal = &FTHybridCursorResult{
SearchCursorID: cmd.cursorVal.SearchCursorID,
VsimCursorID: cmd.cursorVal.VsimCursorID,
}
}
var options *FTHybridOptions
if cmd.options != nil {
options = &FTHybridOptions{
CountExpressions: cmd.options.CountExpressions,
Load: cmd.options.Load,
Filter: cmd.options.Filter,
LimitOffset: cmd.options.LimitOffset,
Limit: cmd.options.Limit,
ExplainScore: cmd.options.ExplainScore,
Timeout: cmd.options.Timeout,
WithCursor: cmd.options.WithCursor,
}
// Clone slices and maps
if cmd.options.SearchExpressions != nil {
options.SearchExpressions = make([]FTHybridSearchExpression, len(cmd.options.SearchExpressions))
copy(options.SearchExpressions, cmd.options.SearchExpressions)
}
if cmd.options.VectorExpressions != nil {
options.VectorExpressions = make([]FTHybridVectorExpression, len(cmd.options.VectorExpressions))
copy(options.VectorExpressions, cmd.options.VectorExpressions)
}
if cmd.options.Combine != nil {
options.Combine = &FTHybridCombineOptions{
Method: cmd.options.Combine.Method,
Count: cmd.options.Combine.Count,
Window: cmd.options.Combine.Window,
Constant: cmd.options.Combine.Constant,
Alpha: cmd.options.Combine.Alpha,
Beta: cmd.options.Combine.Beta,
YieldScoreAs: cmd.options.Combine.YieldScoreAs,
}
}
if cmd.options.GroupBy != nil {
options.GroupBy = &FTHybridGroupBy{
Count: cmd.options.GroupBy.Count,
ReduceFunc: cmd.options.GroupBy.ReduceFunc,
ReduceCount: cmd.options.GroupBy.ReduceCount,
}
if cmd.options.GroupBy.Fields != nil {
options.GroupBy.Fields = make([]string, len(cmd.options.GroupBy.Fields))
copy(options.GroupBy.Fields, cmd.options.GroupBy.Fields)
}
if cmd.options.GroupBy.ReduceParams != nil {
options.GroupBy.ReduceParams = make([]interface{}, len(cmd.options.GroupBy.ReduceParams))
copy(options.GroupBy.ReduceParams, cmd.options.GroupBy.ReduceParams)
}
}
if cmd.options.Apply != nil {
options.Apply = make([]FTHybridApply, len(cmd.options.Apply))
copy(options.Apply, cmd.options.Apply)
}
if cmd.options.SortBy != nil {
options.SortBy = make([]FTSearchSortBy, len(cmd.options.SortBy))
copy(options.SortBy, cmd.options.SortBy)
}
if cmd.options.Params != nil {
options.Params = make(map[string]interface{}, len(cmd.options.Params))
for k, v := range cmd.options.Params {
options.Params[k] = v
}
}
if cmd.options.WithCursorOptions != nil {
options.WithCursorOptions = &FTHybridWithCursor{
MaxIdle: cmd.options.WithCursorOptions.MaxIdle,
Count: cmd.options.WithCursorOptions.Count,
}
}
}
return &FTHybridCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
cursorVal: cursorVal,
options: options,
withCursor: cmd.withCursor,
}
}
// FTSearch - Executes a search query on an index.
// The 'index' parameter specifies the index to search, and the 'query' parameter specifies the search query.
// For more information, please refer to the Redis documentation about [FT.SEARCH].
//
// [FT.SEARCH]: (https://redis.io/commands/ft.search/)
func (c cmdable) FTSearch(ctx context.Context, index string, query string) *FTSearchCmd {
args := []interface{}{"FT.SEARCH", index, query}
cmd := newFTSearchCmd(ctx, &FTSearchOptions{}, args...)
_ = c(ctx, cmd)
return cmd
}
type SearchQuery []interface{}
// FTSearchQuery - Executes a search query on an index with additional options.
// The 'index' parameter specifies the index to search, the 'query' parameter specifies the search query,
// and the 'options' parameter specifies additional options for the search.
// For more information, please refer to the Redis documentation about [FT.SEARCH].
//
// [FT.SEARCH]: (https://redis.io/commands/ft.search/)
func FTSearchQuery(query string, options *FTSearchOptions) (SearchQuery, error) {
queryArgs := []interface{}{query}
if options != nil {
if options.NoContent {
queryArgs = append(queryArgs, "NOCONTENT")
}
if options.Verbatim {
queryArgs = append(queryArgs, "VERBATIM")
}
if options.NoStopWords {
queryArgs = append(queryArgs, "NOSTOPWORDS")
}
if options.WithScores {
queryArgs = append(queryArgs, "WITHSCORES")
}
if options.WithPayloads {
queryArgs = append(queryArgs, "WITHPAYLOADS")
}
if options.WithSortKeys {
queryArgs = append(queryArgs, "WITHSORTKEYS")
}
if options.Filters != nil {
for _, filter := range options.Filters {
queryArgs = append(queryArgs, "FILTER", filter.FieldName, filter.Min, filter.Max)
}
}
if options.GeoFilter != nil {
for _, geoFilter := range options.GeoFilter {
queryArgs = append(queryArgs, "GEOFILTER", geoFilter.FieldName, geoFilter.Longitude, geoFilter.Latitude, geoFilter.Radius, geoFilter.Unit)
}
}
if options.InKeys != nil {
queryArgs = append(queryArgs, "INKEYS", len(options.InKeys))
queryArgs = append(queryArgs, options.InKeys...)
}
if options.InFields != nil {
queryArgs = append(queryArgs, "INFIELDS", len(options.InFields))
queryArgs = append(queryArgs, options.InFields...)
}
if options.Return != nil {
queryArgs = append(queryArgs, "RETURN")
queryArgsReturn := []interface{}{}
for _, ret := range options.Return {
queryArgsReturn = append(queryArgsReturn, ret.FieldName)
if ret.As != "" {
queryArgsReturn = append(queryArgsReturn, "AS", ret.As)
}
}
queryArgs = append(queryArgs, len(queryArgsReturn))
queryArgs = append(queryArgs, queryArgsReturn...)
}
if options.Slop > 0 {
queryArgs = append(queryArgs, "SLOP", options.Slop)
}
if options.Timeout > 0 {
queryArgs = append(queryArgs, "TIMEOUT", options.Timeout)
}
if options.InOrder {
queryArgs = append(queryArgs, "INORDER")
}
if options.Language != "" {
queryArgs = append(queryArgs, "LANGUAGE", options.Language)
}
if options.Expander != "" {
queryArgs = append(queryArgs, "EXPANDER", options.Expander)
}
if options.Scorer != "" {
queryArgs = append(queryArgs, "SCORER", options.Scorer)
}
if options.ExplainScore {
queryArgs = append(queryArgs, "EXPLAINSCORE")
}
if options.Payload != "" {
queryArgs = append(queryArgs, "PAYLOAD", options.Payload)
}
if options.SortBy != nil {
queryArgs = append(queryArgs, "SORTBY")
for _, sortBy := range options.SortBy {
queryArgs = append(queryArgs, sortBy.FieldName)
if sortBy.Asc && sortBy.Desc {
return nil, fmt.Errorf("FT.SEARCH: ASC and DESC are mutually exclusive")
}
if sortBy.Asc {
queryArgs = append(queryArgs, "ASC")
}
if sortBy.Desc {
queryArgs = append(queryArgs, "DESC")
}
}
if options.SortByWithCount {
queryArgs = append(queryArgs, "WITHCOUNT")
}
}
if options.LimitOffset >= 0 && options.Limit > 0 {
queryArgs = append(queryArgs, "LIMIT", options.LimitOffset, options.Limit)
}
if options.Params != nil {
queryArgs = append(queryArgs, "PARAMS", len(options.Params)*2)
for key, value := range options.Params {
queryArgs = append(queryArgs, key, value)
}
}
if options.DialectVersion > 0 {
queryArgs = append(queryArgs, "DIALECT", options.DialectVersion)
} else {
queryArgs = append(queryArgs, "DIALECT", 2)
}
}
return queryArgs, nil
}
// FTSearchWithArgs - Executes a search query on an index with additional options.
// The 'index' parameter specifies the index to search, the 'query' parameter specifies the search query,
// and the 'options' parameter specifies additional options for the search.
// For more information, please refer to the Redis documentation about [FT.SEARCH].
//
// [FT.SEARCH]: (https://redis.io/commands/ft.search/)
func (c cmdable) FTSearchWithArgs(ctx context.Context, index string, query string, options *FTSearchOptions) *FTSearchCmd {
args := []interface{}{"FT.SEARCH", index, query}
if options != nil {
if options.NoContent {
args = append(args, "NOCONTENT")
}
if options.Verbatim {
args = append(args, "VERBATIM")
}
if options.NoStopWords {
args = append(args, "NOSTOPWORDS")
}
if options.WithScores {
args = append(args, "WITHSCORES")
}
if options.WithPayloads {
args = append(args, "WITHPAYLOADS")
}
if options.WithSortKeys {
args = append(args, "WITHSORTKEYS")
}
if options.Filters != nil {
for _, filter := range options.Filters {
args = append(args, "FILTER", filter.FieldName, filter.Min, filter.Max)
}
}
if options.GeoFilter != nil {
for _, geoFilter := range options.GeoFilter {
args = append(args, "GEOFILTER", geoFilter.FieldName, geoFilter.Longitude, geoFilter.Latitude, geoFilter.Radius, geoFilter.Unit)
}
}
if options.InKeys != nil {
args = append(args, "INKEYS", len(options.InKeys))
args = append(args, options.InKeys...)
}
if options.InFields != nil {
args = append(args, "INFIELDS", len(options.InFields))
args = append(args, options.InFields...)
}
if options.Return != nil {
args = append(args, "RETURN")
argsReturn := []interface{}{}
for _, ret := range options.Return {
argsReturn = append(argsReturn, ret.FieldName)
if ret.As != "" {
argsReturn = append(argsReturn, "AS", ret.As)
}
}
args = append(args, len(argsReturn))
args = append(args, argsReturn...)
}
if options.Slop > 0 {
args = append(args, "SLOP", options.Slop)
}
if options.Timeout > 0 {
args = append(args, "TIMEOUT", options.Timeout)
}
if options.InOrder {
args = append(args, "INORDER")
}
if options.Language != "" {
args = append(args, "LANGUAGE", options.Language)
}
if options.Expander != "" {
args = append(args, "EXPANDER", options.Expander)
}
if options.Scorer != "" {
args = append(args, "SCORER", options.Scorer)
}
if options.ExplainScore {
args = append(args, "EXPLAINSCORE")
}
if options.Payload != "" {
args = append(args, "PAYLOAD", options.Payload)
}
if options.SortBy != nil {
args = append(args, "SORTBY")
for _, sortBy := range options.SortBy {
args = append(args, sortBy.FieldName)
if sortBy.Asc && sortBy.Desc {
cmd := newFTSearchCmd(ctx, options, args...)
cmd.SetErr(fmt.Errorf("FT.SEARCH: ASC and DESC are mutually exclusive"))
return cmd
}
if sortBy.Asc {
args = append(args, "ASC")
}
if sortBy.Desc {
args = append(args, "DESC")
}
}
if options.SortByWithCount {
args = append(args, "WITHCOUNT")
}
}
if options.CountOnly {
args = append(args, "LIMIT", 0, 0)
} else {
if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 {
args = append(args, "LIMIT", options.LimitOffset, options.Limit)
}
}
if options.Params != nil {
args = append(args, "PARAMS", len(options.Params)*2)
for key, value := range options.Params {
args = append(args, key, value)
}
}
if options.DialectVersion > 0 {
args = append(args, "DIALECT", options.DialectVersion)
} else {
args = append(args, "DIALECT", 2)
}
}
cmd := newFTSearchCmd(ctx, options, args...)
_ = c(ctx, cmd)
return cmd
}
func NewFTSynDumpCmd(ctx context.Context, args ...interface{}) *FTSynDumpCmd {
return &FTSynDumpCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeFTSynDump,
},
}
}
func (cmd *FTSynDumpCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *FTSynDumpCmd) SetVal(val []FTSynDumpResult) {
cmd.val = val
}
func (cmd *FTSynDumpCmd) Val() []FTSynDumpResult {
cmd.await()
return cmd.val
}
func (cmd *FTSynDumpCmd) Result() ([]FTSynDumpResult, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *FTSynDumpCmd) RawVal() interface{} {
cmd.await()
return cmd.rawVal
}
func (cmd *FTSynDumpCmd) RawResult() (interface{}, error) {
cmd.await()
return cmd.rawVal, cmd.err
}
func (cmd *FTSynDumpCmd) readReply(rd *proto.Reader) error {
readType, err := rd.PeekReplyType()
if err != nil {
return err
}
// RESP3 returns a map, RESP2 returns an array
if readType == proto.RespMap {
// Read raw response first for backwards compatibility
cmd.rawVal, err = rd.ReadReply()
if err != nil {
return err
}
// Parse the raw response into structured result
rawMap, ok := cmd.rawVal.(map[interface{}]interface{})
if !ok {
return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal)
}
cmd.val, err = parseFTSynDumpRESP3(rawMap)
return err
}
// RESP2 format
termSynonymPairs, err := rd.ReadSlice()
if err != nil {
return err
}
var results []FTSynDumpResult
for i := 0; i < len(termSynonymPairs); i += 2 {
term, ok := termSynonymPairs[i].(string)
if !ok {
return fmt.Errorf("invalid term format")
}
synonyms, ok := termSynonymPairs[i+1].([]interface{})
if !ok {
return fmt.Errorf("invalid synonyms format")
}
synonymList := make([]string, len(synonyms))
for j, syn := range synonyms {
synonym, ok := syn.(string)
if !ok {
return fmt.Errorf("invalid synonym format")
}
synonymList[j] = synonym
}
results = append(results, FTSynDumpResult{
Term: term,
Synonyms: synonymList,
})
}
cmd.val = results
return nil
}
// parseFTSynDumpRESP3 parses the RESP3 format response from FT.SYNDUMP.
// RESP3 format:
//
// map{
// "term1": ["synonym_group_id1", ...],
// "term2": ["synonym_group_id2", ...],
// ...
// }
func parseFTSynDumpRESP3(data map[interface{}]interface{}) ([]FTSynDumpResult, error) {
results := make([]FTSynDumpResult, 0, len(data))
for termKey, synonymsData := range data {
term, ok := termKey.(string)
if !ok {
continue
}
synonymsArray, ok := synonymsData.([]interface{})
if !ok {
continue
}
synonymList := make([]string, 0, len(synonymsArray))
for _, syn := range synonymsArray {
if synonym, ok := syn.(string); ok {
synonymList = append(synonymList, synonym)
}
}
results = append(results, FTSynDumpResult{
Term: term,
Synonyms: synonymList,
})
}
return results, nil
}
func (cmd *FTSynDumpCmd) Clone() Cmder {
var val []FTSynDumpResult
if cmd.val != nil {
val = make([]FTSynDumpResult, len(cmd.val))
for i, result := range cmd.val {
val[i] = FTSynDumpResult{
Term: result.Term,
}
if result.Synonyms != nil {
val[i].Synonyms = make([]string, len(result.Synonyms))
copy(val[i].Synonyms, result.Synonyms)
}
}
}
return &FTSynDumpCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// FTSynDump - Dumps the contents of a synonym group.
// The 'index' parameter specifies the index to dump.
// For more information, please refer to the Redis documentation:
// [FT.SYNDUMP]: (https://redis.io/commands/ft.syndump/)
func (c cmdable) FTSynDump(ctx context.Context, index string) *FTSynDumpCmd {
cmd := NewFTSynDumpCmd(ctx, "FT.SYNDUMP", index)
_ = c(ctx, cmd)
return cmd
}
// FTSynUpdate - Creates or updates a synonym group with additional terms.
// The 'index' parameter specifies the index to update, the 'synGroupId' parameter specifies the synonym group id, and the 'terms' parameter specifies the additional terms.
// For more information, please refer to the Redis documentation:
// [FT.SYNUPDATE]: (https://redis.io/commands/ft.synupdate/)
func (c cmdable) FTSynUpdate(ctx context.Context, index string, synGroupId interface{}, terms []interface{}) *StatusCmd {
args := []interface{}{"FT.SYNUPDATE", index, synGroupId}
args = append(args, terms...)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTSynUpdateWithArgs - Creates or updates a synonym group with additional terms and options.
// The 'index' parameter specifies the index to update, the 'synGroupId' parameter specifies the synonym group id, the 'options' parameter specifies additional options for the update, and the 'terms' parameter specifies the additional terms.
// For more information, please refer to the Redis documentation:
// [FT.SYNUPDATE]: (https://redis.io/commands/ft.synupdate/)
func (c cmdable) FTSynUpdateWithArgs(ctx context.Context, index string, synGroupId interface{}, options *FTSynUpdateOptions, terms []interface{}) *StatusCmd {
args := []interface{}{"FT.SYNUPDATE", index, synGroupId}
if options.SkipInitialScan {
args = append(args, "SKIPINITIALSCAN")
}
args = append(args, terms...)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// FTTagVals - Returns all distinct values indexed in a tag field.
// The 'index' parameter specifies the index to check, and the 'field' parameter specifies the tag field to retrieve values from.
// For more information, please refer to the Redis documentation:
// [FT.TAGVALS]: (https://redis.io/commands/ft.tagvals/)
func (c cmdable) FTTagVals(ctx context.Context, index string, field string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "FT.TAGVALS", index, field)
_ = c(ctx, cmd)
return cmd
}
// FTHybrid - Executes a hybrid search combining full-text search and vector similarity
// The 'index' parameter specifies the index to search, 'searchExpr' is the search query,
// 'vectorField' is the name of the vector field, and 'vectorData' is the vector to search with.
// FTHybrid is still experimental, the command behaviour and signature may change
func (c cmdable) FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd {
options := &FTHybridOptions{
CountExpressions: 2,
SearchExpressions: []FTHybridSearchExpression{
{Query: searchExpr},
},
VectorExpressions: []FTHybridVectorExpression{
{VectorField: vectorField, VectorData: vectorData},
},
}
return c.FTHybridWithArgs(ctx, index, options)
}
func hybridVectorBlob(v Vector) (interface{}, error) {
if v == nil {
return nil, fmt.Errorf("FT.HYBRID: vector data is required")
}
switch vector := v.(type) {
case *VectorFP32:
return hybridVectorBytes(vector.Val)
case *VectorFloat16:
return hybridVectorBytes(vector.Val)
case *VectorBFloat16:
return hybridVectorBytes(vector.Val)
case *VectorFloat64:
return hybridVectorBytes(vector.Val)
case *VectorInt8:
return hybridVectorBytes(vector.Val)
case *VectorUint8:
return hybridVectorBytes(vector.Val)
case *VectorValues, *VectorRef:
return nil, fmt.Errorf("FT.HYBRID: unsupported vector type %T", v)
default:
values := v.Value()
if len(values) < 2 {
return nil, fmt.Errorf("FT.HYBRID: vector Value must contain a blob at index 1")
}
return values[1], nil
}
}
func hybridVectorBytes(blob []byte) ([]byte, error) {
if len(blob) == 0 {
return nil, fmt.Errorf("FT.HYBRID: vector blob is required")
}
return blob, nil
}
// generateVectorParamName returns a parameter name that is not already present
// in params. It is used to pass vector data via the PARAMS mechanism when the
// caller does not provide a VectorParamName, since inline vector blobs are no
// longer supported by Redis.
func generateVectorParamName(params map[string]interface{}) string {
for i := 0; ; i++ {
name := fmt.Sprintf("__vector_param_%d", i)
if _, ok := params[name]; !ok {
return name
}
}
}
// FTHybridWithArgs - Executes a hybrid search with advanced options
// FTHybridWithArgs is still experimental, the command behaviour and signature may change
//
// Vector data is always sent through the PARAMS mechanism, because inline vector
// blobs are no longer supported by Redis. For every vector expression whose
// VectorParamName is empty, a unique name is generated (e.g. "__vector_param_0")
// and the corresponding blob is passed via PARAMS.
//
// options.Params is never mutated: the command is built from a local copy that
// combines the caller-provided params with any generated vector parameters. This
// makes it safe to reuse the same *FTHybridOptions across multiple calls. Generated
// names are also reserved against all explicit VectorParamName values, so they never
// collide with explicit names (even those following the "__vector_param_N" pattern).
func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd {
args := []interface{}{"FT.HYBRID", index}
if options != nil {
// Add search expressions
for _, searchExpr := range options.SearchExpressions {
args = append(args, "SEARCH", searchExpr.Query)
if searchExpr.Scorer != "" {
args = append(args, "SCORER", searchExpr.Scorer)
if len(searchExpr.ScorerParams) > 0 {
args = append(args, searchExpr.ScorerParams...)
}
}
if searchExpr.YieldScoreAs != "" {
args = append(args, "YIELD_SCORE_AS", searchExpr.YieldScoreAs)
}
}
// Vector data is always passed via the PARAMS mechanism (inline vector blobs
// are no longer supported by Redis). When vectors are present, build a local
// copy of the caller-provided params so options.Params is never mutated, and
// pre-reserve any explicit VectorParamName values so generated names never
// collide with them.
params := options.Params
if len(options.VectorExpressions) > 0 {
params = make(map[string]interface{}, len(options.Params)+len(options.VectorExpressions))
for k, v := range options.Params {
params[k] = v
}
for _, vectorExpr := range options.VectorExpressions {
if vectorExpr.VectorParamName != "" {
params[vectorExpr.VectorParamName] = nil
}
}
}
// Add vector expressions
for _, vectorExpr := range options.VectorExpressions {
args = append(args, "VSIM", "@"+vectorExpr.VectorField)
vectorBlob, err := hybridVectorBlob(vectorExpr.VectorData)
if err != nil {
cmd := newFTHybridCmd(ctx, options, args...)
cmd.SetErr(err)
return cmd
}
// When VectorParamName is not provided, generate a unique name. Generated
// names are tracked only in the local params map, never written back to
// options.Params.
paramName := vectorExpr.VectorParamName
if paramName == "" {
paramName = generateVectorParamName(params)
}
args = append(args, "$"+paramName)
params[paramName] = vectorBlob
if vectorExpr.Method != "" {
args = append(args, vectorExpr.Method)
if len(vectorExpr.MethodParams) > 0 {
// MethodParams should be key-value pairs, count them
args = append(args, len(vectorExpr.MethodParams))
args = append(args, vectorExpr.MethodParams...)
}
}
// SHARD_K_RATIO applies to the KNN method only (Redis 8.8+, cluster only).
// Zero means "unset" and falls back to the server default of 1.0.
if vectorExpr.ShardKRatio > 0 {
if vectorExpr.Method != "KNN" {
cmd := newFTHybridCmd(ctx, options, args...)
cmd.SetErr(fmt.Errorf("FT.HYBRID: SHARD_K_RATIO requires KNN method"))
return cmd
}
if vectorExpr.ShardKRatio < 0.1 || vectorExpr.ShardKRatio > 1.0 {
cmd := newFTHybridCmd(ctx, options, args...)
cmd.SetErr(fmt.Errorf("FT.HYBRID: SHARD_K_RATIO must be between 0.1 and 1.0"))
return cmd
}
args = append(args, "SHARD_K_RATIO", vectorExpr.ShardKRatio)
}
if vectorExpr.Filter != "" {
args = append(args, "FILTER", vectorExpr.Filter)
}
if vectorExpr.YieldScoreAs != "" {
args = append(args, "YIELD_SCORE_AS", vectorExpr.YieldScoreAs)
}
}
// Add combine/fusion options
if options.Combine != nil {
// Build combine parameters
combineParams := []interface{}{}
switch options.Combine.Method {
case FTHybridCombineRRF:
if options.Combine.Window > 0 {
combineParams = append(combineParams, "WINDOW", options.Combine.Window)
}
if options.Combine.Constant > 0 {
combineParams = append(combineParams, "CONSTANT", options.Combine.Constant)
}
case FTHybridCombineLinear:
if options.Combine.Alpha > 0 {
combineParams = append(combineParams, "ALPHA", options.Combine.Alpha)
}
if options.Combine.Beta > 0 {
combineParams = append(combineParams, "BETA", options.Combine.Beta)
}
}
if options.Combine.YieldScoreAs != "" {
combineParams = append(combineParams, "YIELD_SCORE_AS", options.Combine.YieldScoreAs)
}
// Add COMBINE with method and parameter count
args = append(args, "COMBINE", string(options.Combine.Method))
if len(combineParams) > 0 {
args = append(args, len(combineParams))
args = append(args, combineParams...)
}
}
// Add LOAD (projected fields)
if len(options.Load) > 0 {
args = append(args, "LOAD", len(options.Load))
for _, field := range options.Load {
args = append(args, field)
}
}
// Add GROUPBY
if options.GroupBy != nil {
args = append(args, "GROUPBY", options.GroupBy.Count)
for _, field := range options.GroupBy.Fields {
args = append(args, field)
}
if options.GroupBy.ReduceFunc != "" {
args = append(args, "REDUCE", options.GroupBy.ReduceFunc, options.GroupBy.ReduceCount)
args = append(args, options.GroupBy.ReduceParams...)
}
}
// Add APPLY transformations
for _, apply := range options.Apply {
args = append(args, "APPLY", apply.Expression, "AS", apply.AsField)
}
// Add SORTBY
if len(options.SortBy) > 0 {
sortByOptions := []interface{}{}
for _, sortBy := range options.SortBy {
sortByOptions = append(sortByOptions, sortBy.FieldName)
if sortBy.Asc && sortBy.Desc {
cmd := newFTHybridCmd(ctx, options, args...)
cmd.SetErr(fmt.Errorf("FT.HYBRID: ASC and DESC are mutually exclusive"))
return cmd
}
if sortBy.Asc {
sortByOptions = append(sortByOptions, "ASC")
}
if sortBy.Desc {
sortByOptions = append(sortByOptions, "DESC")
}
}
args = append(args, "SORTBY", len(sortByOptions))
args = append(args, sortByOptions...)
}
// Add FILTER (post-filter)
if options.Filter != "" {
args = append(args, "FILTER", options.Filter)
}
// Add LIMIT
if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 {
args = append(args, "LIMIT", options.LimitOffset, options.Limit)
}
// Add PARAMS
// Emit from the local params map, which contains the caller-provided params
// plus any generated vector parameter names. options.Params is left untouched.
if len(params) > 0 {
args = append(args, "PARAMS", len(params)*2)
for key, value := range params {
// PARAMS entries are passed without a '$' prefix; they are referenced in
// the query and clauses using "$<name>".
args = append(args, key, value)
}
}
// Add EXPLAINSCORE
if options.ExplainScore {
args = append(args, "EXPLAINSCORE")
}
// Add TIMEOUT
if options.Timeout > 0 {
args = append(args, "TIMEOUT", options.Timeout)
}
// Add WITHCURSOR support
if options.WithCursor {
args = append(args, "WITHCURSOR")
if options.WithCursorOptions != nil {
if options.WithCursorOptions.Count > 0 {
args = append(args, "COUNT", options.WithCursorOptions.Count)
}
if options.WithCursorOptions.MaxIdle > 0 {
args = append(args, "MAXIDLE", options.WithCursorOptions.MaxIdle)
}
}
}
}
cmd := newFTHybridCmd(ctx, options, args...)
_ = c(ctx, cmd)
return cmd
}
package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/auth"
"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/maintnotifications"
"github.com/redis/go-redis/v9/push"
)
//------------------------------------------------------------------------------
// FailoverOptions are used to configure a failover client and should
// be passed to NewFailoverClient.
type FailoverOptions struct {
// The master name.
MasterName string
// A seed list of host:port addresses of sentinel nodes.
SentinelAddrs []string
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
ClientName string
// If specified with SentinelPassword, enables ACL-based authentication (via
// AUTH <user> <pass>).
SentinelUsername string
// Sentinel password from "requirepass <password>" (if enabled) in Sentinel
// configuration, or, if SentinelUsername is also supplied, used for ACL-based
// authentication.
SentinelPassword string
// Allows routing read-only commands to the closest master or replica node.
// This option only works with NewFailoverClusterClient.
RouteByLatency bool
// RouteByLatencyTolerance is passed through to ClusterOptions; see its documentation.
RouteByLatencyTolerance time.Duration
// Allows routing read-only commands to the random master or replica node.
// This option only works with NewFailoverClusterClient.
RouteRandomly bool
// Route all commands to replica read-only nodes.
ReplicaOnly bool
// Use replicas disconnected with master when cannot get connected replicas
// Now, this option only works in RandomReplicaAddr function.
UseDisconnectedReplicas bool
// Following options are copied from Options struct.
Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
OnConnect func(ctx context.Context, cn *Conn) error
Protocol int
Username string
Password string
// Push notifications are always enabled for RESP3 connections
// 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 int
MaxRetries int
MinRetryBackoff time.Duration
MaxRetryBackoff time.Duration
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.
// See Options.DialerRetryBackoff for details.
DialerRetryBackoff func(attempt int) time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
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
// PipelineReadBufferSize, PipelineWriteBufferSize and PipelinePoolSize
// configure the separate connection pool used for pipelining, with its own
// (typically larger) buffers. See the same-named fields on Options for
// details. NewFailoverClient creates this pool by default; set
// PipelinePoolSize < 0 to opt out (pipelines then run on the main pool).
PipelineReadBufferSize int
PipelineWriteBufferSize int
PipelinePoolSize int
// AutoPipelineOptions is the default config for the client's autopipeliner
// faces. See Options.AutoPipelineOptions.
AutoPipelineOptions *AutoPipelineOptions
PoolFIFO bool
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 time.Duration
MinIdleConns int
MaxIdleConns int
MaxActiveConns int
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
ConnMaxLifetimeJitter time.Duration
TLSConfig *tls.Config
// 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
IdentitySuffix string
// 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.
// Only applies to failover cluster clients. Default is 15 seconds.
FailingTimeoutSeconds int
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.
// If nil, a default processor will be created for RESP3 connections.
PushNotificationProcessor push.NotificationProcessor
// MaintNotificationsConfig is not supported for FailoverClients at the moment
// MaintNotificationsConfig provides custom configuration for maintnotifications upgrades.
// When MaintNotificationsConfig.Mode is not "disabled", the client will handle
// upgrade notifications gracefully and manage connection/pool state transitions
// seamlessly. Requires Protocol: 3 (RESP3) for push notifications.
// If nil, maintnotifications upgrades are disabled.
// (however if Mode is nil, it defaults to "auto" - enable if server supports it)
// MaintNotificationsConfig *maintnotifications.Config
}
func (opt *FailoverOptions) clientOptions() *Options {
return &Options{
Addr: "FailoverClient",
ClientName: opt.ClientName,
Dialer: opt.Dialer,
OnConnect: opt.OnConnect,
DB: opt.DB,
Protocol: opt.Protocol,
Username: opt.Username,
Password: opt.Password,
CredentialsProvider: opt.CredentialsProvider,
CredentialsProviderContext: opt.CredentialsProviderContext,
StreamingCredentialsProvider: opt.StreamingCredentialsProvider,
MaxRetries: opt.MaxRetries,
MinRetryBackoff: opt.MinRetryBackoff,
MaxRetryBackoff: opt.MaxRetryBackoff,
ReadBufferSize: opt.ReadBufferSize,
WriteBufferSize: opt.WriteBufferSize,
PipelineReadBufferSize: opt.PipelineReadBufferSize,
PipelineWriteBufferSize: opt.PipelineWriteBufferSize,
PipelinePoolSize: opt.PipelinePoolSize,
AutoPipelineOptions: opt.AutoPipelineOptions,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
ContextTimeoutEnabled: opt.ContextTimeoutEnabled,
PoolFIFO: opt.PoolFIFO,
PoolSize: opt.PoolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
MinIdleConns: opt.MinIdleConns,
MaxIdleConns: opt.MaxIdleConns,
MaxActiveConns: opt.MaxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
TLSConfig: opt.TLSConfig,
DisableIdentity: opt.DisableIdentity,
DisableIndentity: opt.DisableIndentity,
IdentitySuffix: opt.IdentitySuffix,
UnstableResp3: opt.UnstableResp3,
PushNotificationProcessor: opt.PushNotificationProcessor,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
}
}
func (opt *FailoverOptions) sentinelOptions(addr string) *Options {
return &Options{
Addr: addr,
ClientName: opt.ClientName,
Dialer: opt.Dialer,
OnConnect: opt.OnConnect,
DB: 0,
Username: opt.SentinelUsername,
Password: opt.SentinelPassword,
MaxRetries: opt.MaxRetries,
MinRetryBackoff: opt.MinRetryBackoff,
MaxRetryBackoff: opt.MaxRetryBackoff,
// The sentinel client uses a 4KiB read/write buffer size.
ReadBufferSize: 4096,
WriteBufferSize: 4096,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
ContextTimeoutEnabled: opt.ContextTimeoutEnabled,
PoolFIFO: opt.PoolFIFO,
PoolSize: opt.PoolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
MinIdleConns: opt.MinIdleConns,
MaxIdleConns: opt.MaxIdleConns,
MaxActiveConns: opt.MaxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
ConnMaxLifetimeJitter: opt.ConnMaxLifetimeJitter,
TLSConfig: opt.TLSConfig,
DisableIdentity: opt.DisableIdentity,
DisableIndentity: opt.DisableIndentity,
IdentitySuffix: opt.IdentitySuffix,
UnstableResp3: opt.UnstableResp3,
PushNotificationProcessor: opt.PushNotificationProcessor,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
}
}
func (opt *FailoverOptions) clusterOptions() *ClusterOptions {
return &ClusterOptions{
ClientName: opt.ClientName,
Dialer: opt.Dialer,
OnConnect: opt.OnConnect,
Protocol: opt.Protocol,
Username: opt.Username,
Password: opt.Password,
CredentialsProvider: opt.CredentialsProvider,
CredentialsProviderContext: opt.CredentialsProviderContext,
StreamingCredentialsProvider: opt.StreamingCredentialsProvider,
MaxRedirects: opt.MaxRetries,
ReadOnly: opt.ReplicaOnly,
RouteByLatency: opt.RouteByLatency,
RouteByLatencyTolerance: opt.RouteByLatencyTolerance,
RouteRandomly: opt.RouteRandomly,
MinRetryBackoff: opt.MinRetryBackoff,
MaxRetryBackoff: opt.MaxRetryBackoff,
ReadBufferSize: opt.ReadBufferSize,
WriteBufferSize: opt.WriteBufferSize,
PipelineReadBufferSize: opt.PipelineReadBufferSize,
PipelineWriteBufferSize: opt.PipelineWriteBufferSize,
PipelinePoolSize: opt.PipelinePoolSize,
AutoPipelineOptions: opt.AutoPipelineOptions,
DialTimeout: opt.DialTimeout,
DialerRetries: opt.DialerRetries,
DialerRetryTimeout: opt.DialerRetryTimeout,
DialerRetryBackoff: opt.DialerRetryBackoff,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
ContextTimeoutEnabled: opt.ContextTimeoutEnabled,
PoolFIFO: opt.PoolFIFO,
PoolSize: opt.PoolSize,
MaxConcurrentDials: opt.MaxConcurrentDials,
PoolTimeout: opt.PoolTimeout,
MinIdleConns: opt.MinIdleConns,
MaxIdleConns: opt.MaxIdleConns,
MaxActiveConns: opt.MaxActiveConns,
ConnMaxIdleTime: opt.ConnMaxIdleTime,
ConnMaxLifetime: opt.ConnMaxLifetime,
TLSConfig: opt.TLSConfig,
DisableIdentity: opt.DisableIdentity,
DisableIndentity: opt.DisableIndentity,
IdentitySuffix: opt.IdentitySuffix,
FailingTimeoutSeconds: opt.FailingTimeoutSeconds,
PushNotificationProcessor: opt.PushNotificationProcessor,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
}
}
// ParseFailoverURL parses a URL into FailoverOptions that can be used to connect to Redis.
// The URL must be in the form:
//
// redis://<user>:<password>@<host>:<port>/<db_number>
// or
// rediss://<user>:<password>@<host>:<port>/<db_number>
//
// To add additional addresses, specify the query parameter, "addr" one or more times. e.g:
//
// redis://<user>:<password>@<host>:<port>/<db_number>?addr=<host2>:<port2>&addr=<host3>:<port3>
// or
// rediss://<user>:<password>@<host>:<port>/<db_number>?addr=<host2>:<port2>&addr=<host3>:<port3>
//
// 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", "sentinel_username" and "sentinel_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
//
// Example:
//
// redis://user:password@localhost:6789?master_name=mymaster&dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791
// is equivalent to:
// &FailoverOptions{
// MasterName: "mymaster",
// Addr: ["localhost:6789", "localhost:6790", "localhost:6791"]
// DialTimeout: 3 * time.Second, // no time unit = seconds
// ReadTimeout: 6 * time.Second,
// }
func ParseFailoverURL(redisURL string) (*FailoverOptions, error) {
u, err := url.Parse(redisURL)
if err != nil {
return nil, err
}
return setupFailoverConn(u)
}
func setupFailoverConn(u *url.URL) (*FailoverOptions, error) {
o := &FailoverOptions{}
o.SentinelUsername, o.SentinelPassword = getUserPassword(u)
h, p := getHostPortWithDefaults(u)
o.SentinelAddrs = append(o.SentinelAddrs, net.JoinHostPort(h, p))
switch u.Scheme {
case "rediss":
o.TLSConfig = &tls.Config{ServerName: h, MinVersion: tls.VersionTLS12}
case "redis":
o.TLSConfig = nil
default:
return nil, fmt.Errorf("redis: invalid URL scheme: %s", u.Scheme)
}
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)
}
return setupFailoverConnParams(u, o)
}
func setupFailoverConnParams(u *url.URL, o *FailoverOptions) (*FailoverOptions, error) {
q := queryOptions{q: u.Query()}
o.MasterName = q.string("master_name")
o.ClientName = q.string("client_name")
o.RouteByLatency = q.bool("route_by_latency")
o.RouteByLatencyTolerance = q.duration("route_by_latency_tolerance")
o.RouteRandomly = q.bool("route_randomly")
o.ReplicaOnly = q.bool("replica_only")
o.UseDisconnectedReplicas = q.bool("use_disconnected_replicas")
o.Protocol = q.int("protocol")
o.Username = q.string("username")
o.Password = q.string("password")
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.DialerRetries = q.int("dialer_retries")
o.DialerRetryTimeout = q.duration("dialer_retry_timeout")
o.ReadTimeout = q.duration("read_timeout")
o.WriteTimeout = q.duration("write_timeout")
o.ContextTimeoutEnabled = q.bool("context_timeout_enabled")
o.PoolFIFO = q.bool("pool_fifo")
o.PoolSize = q.int("pool_size")
o.MaxConcurrentDials = q.int("max_concurrent_dials")
o.MinIdleConns = q.int("min_idle_conns")
o.MaxIdleConns = q.int("max_idle_conns")
o.MaxActiveConns = q.int("max_active_conns")
// Pipeline pool (created by default): allow URL opt-out
// (pipeline_pool_size=-1) / tuning, else rejected as unexpected options.
o.PipelinePoolSize = q.int("pipeline_pool_size")
o.PipelineReadBufferSize = q.int("pipeline_read_buffer_size")
o.PipelineWriteBufferSize = q.int("pipeline_write_buffer_size")
o.ConnMaxLifetime = q.duration("conn_max_lifetime")
if q.has("conn_max_lifetime_jitter") {
o.ConnMaxLifetimeJitter = min(q.duration("conn_max_lifetime_jitter"), o.ConnMaxLifetime)
}
o.ConnMaxIdleTime = q.duration("conn_max_idle_time")
o.PoolTimeout = q.duration("pool_timeout")
o.DisableIdentity = q.bool("disableIdentity")
o.IdentitySuffix = q.string("identitySuffix")
o.UnstableResp3 = q.bool("unstable_resp3")
if q.err != nil {
return nil, q.err
}
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
}
addrs := q.strings("addr")
for _, addr := range addrs {
h, p, err := net.SplitHostPort(addr)
if err != nil || h == "" || p == "" {
return nil, fmt.Errorf("redis: unable to parse addr param: %s", addr)
}
o.SentinelAddrs = append(o.SentinelAddrs, net.JoinHostPort(h, p))
}
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
}
// NewFailoverClient returns a Redis client that uses Redis Sentinel
// for automatic failover. It's safe for concurrent use by multiple
// goroutines.
// Passing nil FailoverOptions will cause a panic.
func NewFailoverClient(failoverOpt *FailoverOptions) *Client {
if failoverOpt == nil {
panic("redis: NewFailoverClient nil options")
}
if failoverOpt.RouteByLatency {
panic("to route commands by latency, use NewFailoverClusterClient")
}
if failoverOpt.RouteRandomly {
panic("to route commands randomly, use NewFailoverClusterClient")
}
sentinelAddrs := make([]string, len(failoverOpt.SentinelAddrs))
copy(sentinelAddrs, failoverOpt.SentinelAddrs)
rand.Shuffle(len(sentinelAddrs), func(i, j int) {
sentinelAddrs[i], sentinelAddrs[j] = sentinelAddrs[j], sentinelAddrs[i]
})
failover := &sentinelFailover{
opt: failoverOpt,
sentinelAddrs: sentinelAddrs,
}
opt := failoverOpt.clientOptions()
opt.Dialer = masterReplicaDialer(failover)
opt.init()
rdb := &Client{
baseClient: &baseClient{
apClosed: &atomic.Bool{},
opt: opt,
onClose: &onCloseHooks{},
himport: newHImportRegistry(),
},
}
rdb.init()
// Registered first (at construction), so onClose.run's LIFO order invokes it LAST —
// after any lazily-registered autopipeliner drain hook. The drain needs MasterAddr
// (hence a live failover client) to dial a replacement conn for accepted-but-unsent
// work; tearing the failover client down here first would make MasterAddr return
// pool.ErrClosed and fail those replayable commands. See onCloseHooks.run.
//
// Registered BEFORE the pools exist, not after: with MinIdleConns > 0 the main
// pool starts dialing as soon as it is created, and masterReplicaDialer then
// builds the failover's Sentinel client and pubsub. If a later construction
// step panics, the panic-cleanup defer below closes rdb, whose onClose hooks
// must already include this one — otherwise those discovery resources outlive
// the client nobody will ever hold (Copilot on #4002).
rdb.onClose.register(onCloseHookIDSentinelFailover, failover.Close)
// Close a partially-built client if any construction step below panics before
// this constructor returns. Mirrors NewClient: the pools (and their MinIdleConns
// dialing goroutines) are created below, and a later step can panic — a pipeline
// pool whose PipelinePoolSize overflows int32, otel registration, or a push
// processor rejecting handler registration. The panic propagates to the caller
// (which may recover it), but rdb is never returned, so without this its pools
// would leak with no reference left to Close them. Close is nil-safe for a
// partially-built client and this defer does not recover, so the panic still
// surfaces.
built := false
defer func() {
if !built {
_ = rdb.Close()
}
}()
// Initialize push notification processor using shared helper
// Use void processor by default for RESP2 connections
rdb.pushProcessor = initializePushProcessor(opt)
// Generate unique pool names for metrics
uniqueID := generateUniqueID()
mainPoolName := opt.Addr + "_" + uniqueID
pubsubPoolName := opt.Addr + "_" + uniqueID + "_pubsub"
// Assign the pool fields only AFTER the error check, mirroring NewClient.
// newConnPool returns a nil *pool.ConnPool on error, and assigning that
// straight to the pool.Pooler interface field would leave a typed-nil
// interface that closeResources treats as present (its != nil check passes),
// so the panic-cleanup defer above would nil-deref inside ConnPool.Close and
// replace the intended "failed to create connection pool" panic. A local var
// keeps the field nil on failure. (pubSubPool is a concrete *pool.PubSubPool
// whose nil is caught correctly by the != nil check, but assign it the same
// way to keep this constructor identical to NewClient.)
connPool, err := newConnPool(opt, rdb.dialHook, mainPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create connection pool: %w", err))
}
rdb.connPool = connPool
pubSubPool, err := newPubSubPool(opt, rdb.dialHook, pubsubPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err))
}
rdb.pubSubPool = pubSubPool
// Create the dedicated pipeline pool unconditionally, mirroring NewClient
// via the shared buildPipelinePool helper. PipelinePoolSize < 0 opts out.
if opt.PipelinePoolSize >= 0 {
ref, err := rdb.buildPipelinePool(mainPoolName + "_pipeline")
if err != nil {
panic(fmt.Errorf("redis: failed to create pipeline connection pool: %w", err))
}
rdb.pipelinePool = ref
}
// Register pools for OTel async gauge metrics, matching NewClient (the
// failover client previously registered none, so pool gauges were silent
// for the identical standalone setup). The pipeline pool is nil when not
// configured.
otel.RegisterPools(rdb.connPool, rdb.pubSubPool, rdb.getPipelinePool(), opt.Addr)
failover.mu.Lock()
failover.onFailover = func(ctx context.Context, addr string) {
if connPool, ok := rdb.connPool.(*pool.ConnPool); ok {
_ = connPool.Filter(func(cn *pool.Conn) bool {
return cn.RemoteAddr().String() != addr
})
}
// Drop stale pipeline-pool connections dialed to the demoted master too;
// otherwise pipelined traffic keeps using the old address after failover.
// The pipeline pool is created at construction (before this callback can
// fire), so the ref is simply read here.
if ref := rdb.loadPipelinePool(); ref != nil {
_ = ref.pool.Filter(func(cn *pool.Conn) bool {
return cn.RemoteAddr().String() != addr
})
}
}
failover.mu.Unlock()
built = true
return rdb
}
func masterReplicaDialer(
failover *sentinelFailover,
) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, _ string) (net.Conn, error) {
var addr string
var err error
if failover.opt.ReplicaOnly {
addr, err = failover.RandomReplicaAddr(ctx)
} else {
addr, err = failover.MasterAddr(ctx)
if err == nil {
failover.trySwitchMaster(ctx, addr)
}
}
if err != nil {
return nil, err
}
if failover.opt.Dialer != nil {
return failover.opt.Dialer(ctx, network, addr)
}
netDialer := &net.Dialer{
Timeout: failover.opt.DialTimeout,
KeepAliveConfig: defaultKeepAliveConfig,
}
if failover.opt.TLSConfig == nil {
return netDialer.DialContext(ctx, network, addr)
}
return tls.DialWithDialer(netDialer, network, addr, failover.opt.TLSConfig)
}
}
//------------------------------------------------------------------------------
// SentinelClient is a client for a Redis Sentinel.
type SentinelClient struct {
*baseClient
}
// NewSentinelClient returns a Redis Sentinel client.
// Passing nil Options will cause a panic.
func NewSentinelClient(opt *Options) *SentinelClient {
if opt == nil {
panic("redis: NewSentinelClient nil options")
}
opt.init()
c := &SentinelClient{
baseClient: &baseClient{
apClosed: &atomic.Bool{},
opt: opt,
onClose: &onCloseHooks{},
},
}
// Initialize push notification processor using shared helper
// Use void processor for Sentinel clients
c.pushProcessor = NewVoidPushNotificationProcessor()
c.initHooks(hooks{
dial: c.baseClient.dial,
process: c.baseClient.process,
})
// Generate unique pool names for metrics
uniqueID := generateUniqueID()
mainPoolName := opt.Addr + "_" + uniqueID
pubsubPoolName := opt.Addr + "_" + uniqueID + "_pubsub"
var err error
c.connPool, err = newConnPool(opt, c.dialHook, mainPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create connection pool: %w", err))
}
c.pubSubPool, err = newPubSubPool(opt, c.dialHook, pubsubPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err))
}
return c
}
// GetPushNotificationHandler returns the handler for a specific push notification name.
// Returns nil if no handler is registered for the given name.
func (c *SentinelClient) GetPushNotificationHandler(pushNotificationName string) push.NotificationHandler {
return c.pushProcessor.GetHandler(pushNotificationName)
}
// RegisterPushNotificationHandler registers a handler for a specific push notification name.
// Returns an error if a handler is already registered for this push notification name.
// If protected is true, the handler cannot be unregistered.
func (c *SentinelClient) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error {
return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected)
}
func (c *SentinelClient) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
func (c *SentinelClient) pubSub() *PubSub {
pubsub := &PubSub{
opt: c.cloneOpt(),
newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) {
cn, err := c.pubSubPool.NewConn(ctx, c.opt.Network, addr, channels)
if err != nil {
return nil, err
}
// will return nil if already initialized
err = c.initConn(ctx, cn)
if err != nil {
_ = cn.Close()
return nil, err
}
// Track connection in PubSubPool
c.pubSubPool.TrackConn(cn)
return cn, nil
},
closeConn: func(cn *pool.Conn) error {
// Untrack connection from PubSubPool
c.pubSubPool.UntrackConn(cn)
_ = cn.Close()
return nil
},
pushProcessor: c.pushProcessor,
}
pubsub.init()
return pubsub
}
// Ping is used to test if a connection is still alive, or to
// measure latency.
func (c *SentinelClient) Ping(ctx context.Context) *StringCmd {
cmd := NewStringCmd(ctx, "ping")
_ = c.Process(ctx, cmd)
return cmd
}
// Subscribe subscribes the client to the specified channels.
// Channels can be omitted to create empty subscription.
func (c *SentinelClient) Subscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.Subscribe(ctx, channels...)
}
return pubsub
}
// PSubscribe subscribes the client to the given patterns.
// Patterns can be omitted to create empty subscription.
func (c *SentinelClient) PSubscribe(ctx context.Context, channels ...string) *PubSub {
pubsub := c.pubSub()
if len(channels) > 0 {
_ = pubsub.PSubscribe(ctx, channels...)
}
return pubsub
}
func (c *SentinelClient) GetMasterAddrByName(ctx context.Context, name string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "sentinel", "get-master-addr-by-name", name)
_ = c.Process(ctx, cmd)
return cmd
}
func (c *SentinelClient) Sentinels(ctx context.Context, name string) *MapStringStringSliceCmd {
cmd := NewMapStringStringSliceCmd(ctx, "sentinel", "sentinels", name)
_ = c.Process(ctx, cmd)
return cmd
}
// Failover forces a failover as if the master was not reachable, and without
// asking for agreement to other Sentinels.
func (c *SentinelClient) Failover(ctx context.Context, name string) *StatusCmd {
cmd := NewStatusCmd(ctx, "sentinel", "failover", name)
_ = c.Process(ctx, cmd)
return cmd
}
// Reset resets all the masters with matching name. The pattern argument is a
// glob-style pattern. The reset process clears any previous state in a master
// (including a failover in progress), and removes every replica and sentinel
// already discovered and associated with the master.
func (c *SentinelClient) Reset(ctx context.Context, pattern string) *IntCmd {
cmd := NewIntCmd(ctx, "sentinel", "reset", pattern)
_ = c.Process(ctx, cmd)
return cmd
}
// FlushConfig forces Sentinel to rewrite its configuration on disk, including
// the current Sentinel state.
func (c *SentinelClient) FlushConfig(ctx context.Context) *StatusCmd {
cmd := NewStatusCmd(ctx, "sentinel", "flushconfig")
_ = c.Process(ctx, cmd)
return cmd
}
// Master shows the state and info of the specified master.
func (c *SentinelClient) Master(ctx context.Context, name string) *MapStringStringCmd {
cmd := NewMapStringStringCmd(ctx, "sentinel", "master", name)
_ = c.Process(ctx, cmd)
return cmd
}
// Masters shows a list of monitored masters and their state.
func (c *SentinelClient) Masters(ctx context.Context) *SliceCmd {
cmd := NewSliceCmd(ctx, "sentinel", "masters")
_ = c.Process(ctx, cmd)
return cmd
}
// Replicas shows a list of replicas for the specified master and their state.
func (c *SentinelClient) Replicas(ctx context.Context, name string) *MapStringStringSliceCmd {
cmd := NewMapStringStringSliceCmd(ctx, "sentinel", "replicas", name)
_ = c.Process(ctx, cmd)
return cmd
}
// CkQuorum checks if the current Sentinel configuration is able to reach the
// quorum needed to failover a master, and the majority needed to authorize the
// failover. This command should be used in monitoring systems to check if a
// Sentinel deployment is ok.
func (c *SentinelClient) CkQuorum(ctx context.Context, name string) *StringCmd {
cmd := NewStringCmd(ctx, "sentinel", "ckquorum", name)
_ = c.Process(ctx, cmd)
return cmd
}
// Monitor tells the Sentinel to start monitoring a new master with the specified
// name, ip, port, and quorum.
func (c *SentinelClient) Monitor(ctx context.Context, name, ip, port, quorum string) *StringCmd {
cmd := NewStringCmd(ctx, "sentinel", "monitor", name, ip, port, quorum)
_ = c.Process(ctx, cmd)
return cmd
}
// Set is used in order to change configuration parameters of a specific master.
func (c *SentinelClient) Set(ctx context.Context, name, option, value string) *StringCmd {
cmd := NewStringCmd(ctx, "sentinel", "set", name, option, value)
_ = c.Process(ctx, cmd)
return cmd
}
// Remove is used in order to remove the specified master: the master will no
// longer be monitored, and will totally be removed from the internal state of
// the Sentinel.
func (c *SentinelClient) Remove(ctx context.Context, name string) *StringCmd {
cmd := NewStringCmd(ctx, "sentinel", "remove", name)
_ = c.Process(ctx, cmd)
return cmd
}
//------------------------------------------------------------------------------
type sentinelFailover struct {
opt *FailoverOptions
sentinelAddrs []string
onFailover func(ctx context.Context, addr string)
onUpdate func(ctx context.Context)
mu sync.RWMutex
masterAddr string
sentinel *SentinelClient
pubsub *PubSub
// closed is set by Close (under mu). Once set, MasterAddr and replicaAddrs
// refuse to create a new SentinelClient/pubsub and return pool.ErrClosed.
// Close runs as an onClose hook, which fires BEFORE a pool-sharing wrapper's
// autopipeliner drain hook (registration order); that drain may dial through
// masterReplicaDialer, and without this flag the dial would rebuild the
// sentinel client + pubsub after the only cleanup had already run, leaking
// them past the pool teardown.
closed bool
}
func (c *sentinelFailover) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
if c.sentinel != nil {
return c.closeSentinel()
}
return nil
}
func (c *sentinelFailover) closeSentinel() error {
firstErr := c.pubsub.Close()
c.pubsub = nil
err := c.sentinel.Close()
if err != nil && firstErr == nil {
firstErr = err
}
c.sentinel = nil
return firstErr
}
func (c *sentinelFailover) RandomReplicaAddr(ctx context.Context) (string, error) {
if c.opt == nil {
return "", errors.New("opt is nil")
}
addresses, err := c.replicaAddrs(ctx, false)
if err != nil {
return "", err
}
if len(addresses) == 0 && c.opt.UseDisconnectedReplicas {
addresses, err = c.replicaAddrs(ctx, true)
if err != nil {
return "", err
}
}
if len(addresses) == 0 {
return c.MasterAddr(ctx)
}
return addresses[rand.Intn(len(addresses))], nil
}
func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) {
c.mu.RLock()
sentinel := c.sentinel
c.mu.RUnlock()
if sentinel != nil {
addr, err := c.getMasterAddr(ctx, sentinel)
if err != nil {
if isContextError(ctx.Err()) {
return "", err
}
// Continue on other errors
internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName name=%q failed: %s",
c.opt.MasterName, err)
} else {
return addr, nil
}
}
c.mu.Lock()
defer c.mu.Unlock()
if c.sentinel != nil {
addr, err := c.getMasterAddr(ctx, c.sentinel)
if err != nil {
_ = c.closeSentinel()
if isContextError(ctx.Err()) {
return "", err
}
// Continue on other errors
internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName name=%q failed: %s",
c.opt.MasterName, err)
} else {
return addr, nil
}
}
// Closed: do not rebuild the sentinel client (see sentinelFailover.closed).
if c.closed {
return "", pool.ErrClosed
}
// short circuit if no sentinels configured
if len(c.sentinelAddrs) == 0 {
return "", errors.New("redis: no sentinels configured")
}
var (
masterAddr string
wg sync.WaitGroup
once sync.Once
errCh = make(chan error, len(c.sentinelAddrs))
)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for i, sentinelAddr := range c.sentinelAddrs {
wg.Add(1)
go func(i int, addr string) {
defer wg.Done()
sentinelCli := NewSentinelClient(c.opt.sentinelOptions(addr))
addrVal, err := sentinelCli.GetMasterAddrByName(ctx, c.opt.MasterName).Result()
if err != nil {
internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName addr=%s, master=%q failed: %s",
addr, c.opt.MasterName, err)
_ = sentinelCli.Close()
errCh <- err
return
}
once.Do(func() {
masterAddr = net.JoinHostPort(addrVal[0], addrVal[1])
// Push working sentinel to the top
c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0]
c.setSentinel(ctx, sentinelCli)
internal.Logger.Printf(ctx, "sentinel: selected addr=%s masterAddr=%s", addr, masterAddr)
cancel()
})
if sentinelCli != c.sentinel {
_ = sentinelCli.Close()
}
}(i, sentinelAddr)
}
wg.Wait()
close(errCh)
if masterAddr != "" {
return masterAddr, nil
}
errs := make([]error, 0, len(errCh))
for err := range errCh {
errs = append(errs, err)
}
return "", fmt.Errorf("redis: all sentinels specified in configuration are unreachable: %w", errors.Join(errs...))
}
func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected bool) ([]string, error) {
c.mu.RLock()
sentinel := c.sentinel
c.mu.RUnlock()
if sentinel != nil {
addrs, err := c.getReplicaAddrs(ctx, sentinel)
if err != nil {
if isContextError(ctx.Err()) {
return nil, err
}
// Continue on other errors
internal.Logger.Printf(ctx, "sentinel: Replicas name=%q failed: %s",
c.opt.MasterName, err)
} else if len(addrs) > 0 {
return addrs, nil
}
}
c.mu.Lock()
defer c.mu.Unlock()
if c.sentinel != nil {
addrs, err := c.getReplicaAddrs(ctx, c.sentinel)
if err != nil {
_ = c.closeSentinel()
if isContextError(ctx.Err()) {
return nil, err
}
// Continue on other errors
internal.Logger.Printf(ctx, "sentinel: Replicas name=%q failed: %s",
c.opt.MasterName, err)
} else if len(addrs) > 0 {
return addrs, nil
} else if !useDisconnected {
// No error and no replicas — valid steady state for master-only setups.
// Preserve the sentinel connection for master discovery and failover
// pub/sub monitoring. Only return early when useDisconnected is false;
// when true, fall through to the discovery loop which passes
// useDisconnected to parseReplicaAddrs (getReplicaAddrs hardcodes false).
return []string{}, nil
} else {
// useDisconnected=true: close sentinel so the discovery loop can call
// setSentinel if it finds disconnected replicas.
_ = c.closeSentinel()
}
}
// Closed: do not rebuild the sentinel client (see sentinelFailover.closed).
if c.closed {
return nil, pool.ErrClosed
}
var sentinelReachable bool
for i, sentinelAddr := range c.sentinelAddrs {
sentinel := NewSentinelClient(c.opt.sentinelOptions(sentinelAddr))
replicas, err := sentinel.Replicas(ctx, c.opt.MasterName).Result()
if err != nil {
_ = sentinel.Close()
if isContextError(ctx.Err()) {
return nil, err
}
internal.Logger.Printf(ctx, "sentinel: Replicas master=%q failed: %s",
c.opt.MasterName, err)
continue
}
sentinelReachable = true
addrs := parseReplicaAddrs(replicas, useDisconnected)
if len(addrs) == 0 {
continue
}
// Push working sentinel to the top.
c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0]
c.setSentinel(ctx, sentinel)
return addrs, nil
}
if sentinelReachable {
return nil, nil
}
return nil, errors.New("redis: all sentinels specified in configuration are unreachable")
}
func (c *sentinelFailover) getMasterAddr(ctx context.Context, sentinel *SentinelClient) (string, error) {
addr, err := sentinel.GetMasterAddrByName(ctx, c.opt.MasterName).Result()
if err != nil {
return "", err
}
return net.JoinHostPort(addr[0], addr[1]), nil
}
func (c *sentinelFailover) getReplicaAddrs(ctx context.Context, sentinel *SentinelClient) ([]string, error) {
addrs, err := sentinel.Replicas(ctx, c.opt.MasterName).Result()
if err != nil {
internal.Logger.Printf(ctx, "sentinel: Replicas name=%q failed: %s",
c.opt.MasterName, err)
return nil, err
}
return parseReplicaAddrs(addrs, false), nil
}
func parseReplicaAddrs(addrs []map[string]string, keepDisconnected bool) []string {
nodes := make([]string, 0, len(addrs))
for _, node := range addrs {
isDown := false
if flags, ok := node["flags"]; ok {
for _, flag := range strings.Split(flags, ",") {
switch flag {
case "s_down", "o_down":
isDown = true
case "disconnected":
if !keepDisconnected {
isDown = true
}
}
}
}
if !isDown && node["ip"] != "" && node["port"] != "" {
nodes = append(nodes, net.JoinHostPort(node["ip"], node["port"]))
}
}
return nodes
}
func (c *sentinelFailover) trySwitchMaster(ctx context.Context, addr string) {
c.mu.RLock()
currentAddr := c.masterAddr //nolint:ifshort
c.mu.RUnlock()
if addr == currentAddr {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if addr == c.masterAddr {
return
}
c.masterAddr = addr
internal.Logger.Printf(ctx, "sentinel: new master=%q addr=%q",
c.opt.MasterName, addr)
if c.onFailover != nil {
c.onFailover(ctx, addr)
}
}
func (c *sentinelFailover) setSentinel(ctx context.Context, sentinel *SentinelClient) {
if c.sentinel != nil {
panic("not reached")
}
c.sentinel = sentinel
c.discoverSentinels(ctx)
c.pubsub = sentinel.Subscribe(ctx, "+switch-master", "+replica-reconf-done")
go c.listen(c.pubsub)
}
func (c *sentinelFailover) discoverSentinels(ctx context.Context) {
sentinels, err := c.sentinel.Sentinels(ctx, c.opt.MasterName).Result()
if err != nil {
internal.Logger.Printf(ctx, "sentinel: Sentinels master=%q failed: %s", c.opt.MasterName, err)
return
}
for _, sentinel := range sentinels {
ip, ok := sentinel["ip"]
if !ok {
continue
}
port, ok := sentinel["port"]
if !ok {
continue
}
if ip != "" && port != "" {
sentinelAddr := net.JoinHostPort(ip, port)
if !slices.Contains(c.sentinelAddrs, sentinelAddr) {
internal.Logger.Printf(ctx, "sentinel: discovered new sentinel=%q for master=%q",
sentinelAddr, c.opt.MasterName)
c.sentinelAddrs = append(c.sentinelAddrs, sentinelAddr)
}
}
}
}
func (c *sentinelFailover) listen(pubsub *PubSub) {
ctx := context.TODO()
if c.onUpdate != nil {
c.onUpdate(ctx)
}
ch := pubsub.Channel()
for msg := range ch {
if msg.Channel == "+switch-master" {
parts := strings.Split(msg.Payload, " ")
if parts[0] != c.opt.MasterName {
internal.Logger.Printf(pubsub.getContext(), "sentinel: ignore addr for master=%q", parts[0])
continue
}
addr := net.JoinHostPort(parts[3], parts[4])
c.trySwitchMaster(pubsub.getContext(), addr)
}
if c.onUpdate != nil {
c.onUpdate(ctx)
}
}
}
//------------------------------------------------------------------------------
// NewFailoverClusterClient returns a client that supports routing read-only commands
// to a replica node.
// Passing nil FailoverOptions will cause a panic.
func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient {
if failoverOpt == nil {
panic("redis: NewFailoverClusterClient nil options")
}
sentinelAddrs := make([]string, len(failoverOpt.SentinelAddrs))
copy(sentinelAddrs, failoverOpt.SentinelAddrs)
failover := &sentinelFailover{
opt: failoverOpt,
sentinelAddrs: sentinelAddrs,
}
opt := failoverOpt.clusterOptions()
if failoverOpt.DB != 0 {
onConnect := opt.OnConnect
opt.OnConnect = func(ctx context.Context, cn *Conn) error {
if err := cn.Select(ctx, failoverOpt.DB).Err(); err != nil {
return err
}
if onConnect != nil {
return onConnect(ctx, cn)
}
return nil
}
}
opt.ClusterSlots = func(ctx context.Context) ([]ClusterSlot, error) {
masterAddr, err := failover.MasterAddr(ctx)
if err != nil {
return nil, err
}
nodes := []ClusterNode{{
Addr: masterAddr,
}}
replicaAddrs, err := failover.replicaAddrs(ctx, false)
if err != nil {
return nil, err
}
for _, replicaAddr := range replicaAddrs {
nodes = append(nodes, ClusterNode{
Addr: replicaAddr,
})
}
slots := []ClusterSlot{
{
Start: 0,
End: 16383,
Nodes: nodes,
},
}
return slots, nil
}
c := NewClusterClient(opt)
failover.mu.Lock()
failover.onUpdate = func(ctx context.Context) {
c.ReloadState(ctx)
}
failover.mu.Unlock()
return c
}
package redis
import (
"context"
"github.com/redis/go-redis/v9/internal/hashtag"
)
// SetCmdable is an interface for Redis set commands.
// Sets are unordered collections of unique strings.
type SetCmdable interface {
SAdd(ctx context.Context, key string, members ...interface{}) *IntCmd
SCard(ctx context.Context, key string) *IntCmd
SDiff(ctx context.Context, keys ...string) *StringSliceCmd
SDiffCard(ctx context.Context, opts *SDiffCardOptions, keys ...string) *IntCmd
SDiffStore(ctx context.Context, destination string, keys ...string) *IntCmd
SInter(ctx context.Context, keys ...string) *StringSliceCmd
SInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd
SInterStore(ctx context.Context, destination string, keys ...string) *IntCmd
SIsMember(ctx context.Context, key string, member interface{}) *BoolCmd
SMIsMember(ctx context.Context, key string, members ...interface{}) *BoolSliceCmd
SMembers(ctx context.Context, key string) *StringSliceCmd
SMembersMap(ctx context.Context, key string) *StringStructMapCmd
SMove(ctx context.Context, source, destination string, member interface{}) *BoolCmd
SPop(ctx context.Context, key string) *StringCmd
SPopN(ctx context.Context, key string, count int64) *StringSliceCmd
SRandMember(ctx context.Context, key string) *StringCmd
SRandMemberN(ctx context.Context, key string, count int64) *StringSliceCmd
SRem(ctx context.Context, key string, members ...interface{}) *IntCmd
SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd
SUnion(ctx context.Context, keys ...string) *StringSliceCmd
SUnionCard(ctx context.Context, opts *SUnionCardOptions, keys ...string) *IntCmd
SUnionStore(ctx context.Context, destination string, keys ...string) *IntCmd
}
// SUnionCardOptions are the options for SUnionCard.
type SUnionCardOptions struct {
Approx bool // use an approximate (HyperLogLog) count.
Limit int64 // cap the result; 0 means no limit.
}
// SDiffCardOptions are the options for SDiffCard.
type SDiffCardOptions struct {
Limit int64 // cap the result; 0 means no limit.
}
// Returns the number of elements that were added to the set, not including all
// the elements already present in the set.
//
// For more information about the command please refer to [SADD].
//
// [SADD]: (https://redis.io/docs/latest/commands/sadd/)
func (c cmdable) SAdd(ctx context.Context, key string, members ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(members))
args[0] = "sadd"
args[1] = key
args = appendArgs(args, members)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns the set cardinality (number of elements) of the set stored at key.
// Returns 0 if key does not exist.
//
// For more information about the command please refer to [SCARD].
//
// [SCARD]: (https://redis.io/docs/latest/commands/scard/)
func (c cmdable) SCard(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "scard", key)
_ = c(ctx, cmd)
return cmd
}
// Returns the members of the set resulting from the difference between the first set
// and all the successive sets.
// Keys that do not exist are considered to be empty sets.
//
// For more information about the command please refer to [SDIFF].
//
// [SDIFF]: (https://redis.io/docs/latest/commands/sdiff/)
func (c cmdable) SDiff(ctx context.Context, keys ...string) *StringSliceCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "sdiff"
for i, key := range keys {
args[1+i] = key
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Stores the members of the set resulting from the difference between the first set
// and all the successive sets into destination.
// If destination already exists, it is overwritten.
//
// For more information about the command please refer to [SDIFFSTORE].
//
// [SDIFFSTORE]: (https://redis.io/docs/latest/commands/sdiffstore/)
func (c cmdable) SDiffStore(ctx context.Context, destination string, keys ...string) *IntCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "sdiffstore"
args[1] = destination
for i, key := range keys {
args[2+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns the cardinality of the difference of the first set and the rest.
// Missing keys are treated as empty sets.
//
// For more information about the command please refer to [SDIFFCARD].
//
// [SDIFFCARD]: (https://redis.io/docs/latest/commands/sdiffcard/)
func (c cmdable) SDiffCard(ctx context.Context, opts *SDiffCardOptions, keys ...string) *IntCmd {
if opts == nil {
opts = &SDiffCardOptions{}
}
numKeys := len(keys)
args := make([]interface{}, 0, 4+numKeys)
args = append(args, "sdiffcard", numKeys)
for _, key := range keys {
args = append(args, key)
}
args = append(args, "limit", opts.Limit)
cmd := NewIntCmd(ctx, args...)
// Keys start after the numkeys arg: ["sdiffcard", numKeys, key1, ...].
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// Returns the members of the set resulting from the intersection of all the given sets.
// Keys that do not exist are considered to be empty sets.
// With one of the keys being an empty set, the resulting set is also empty.
//
// For more information about the command please refer to [SINTER].
//
// [SINTER]: (https://redis.io/docs/latest/commands/sinter/)
func (c cmdable) SInter(ctx context.Context, keys ...string) *StringSliceCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "sinter"
for i, key := range keys {
args[1+i] = key
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns the cardinality of the set resulting from the intersection of all the given sets.
// Keys that do not exist are considered to be empty sets.
// With one of the keys being an empty set, the resulting set is also empty.
//
// The limit parameter sets an upper bound on the number of results returned.
// If limit is 0, no limit is applied.
//
// For more information about the command please refer to [SINTERCARD].
//
// [SINTERCARD]: (https://redis.io/docs/latest/commands/sintercard/)
func (c cmdable) SInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd {
numKeys := len(keys)
args := make([]interface{}, 4+numKeys)
args[0] = "sintercard"
args[1] = numKeys
for i, key := range keys {
args[2+i] = key
}
args[2+numKeys] = "limit"
args[3+numKeys] = limit
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Stores the members of the set resulting from the intersection of all the given sets
// into destination.
// If destination already exists, it is overwritten.
//
// For more information about the command please refer to [SINTERSTORE].
//
// [SINTERSTORE]: (https://redis.io/docs/latest/commands/sinterstore/)
func (c cmdable) SInterStore(ctx context.Context, destination string, keys ...string) *IntCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "sinterstore"
args[1] = destination
for i, key := range keys {
args[2+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns if member is a member of the set stored at key.
// Returns true if the element is a member of the set, false if it is not a member
// or if key does not exist.
//
// For more information about the command please refer to [SISMEMBER].
//
// [SISMEMBER]: (https://redis.io/docs/latest/commands/sismember/)
func (c cmdable) SIsMember(ctx context.Context, key string, member interface{}) *BoolCmd {
cmd := NewBoolCmd(ctx, "sismember", key, member)
_ = c(ctx, cmd)
return cmd
}
// Returns whether each member is a member of the set stored at key.
// For each member, returns true if the element is a member of the set, false if it is not
// a member or if key does not exist.
//
// For more information about the command please refer to [SMISMEMBER].
//
// [SMISMEMBER]: (https://redis.io/docs/latest/commands/smismember/)
func (c cmdable) SMIsMember(ctx context.Context, key string, members ...interface{}) *BoolSliceCmd {
args := make([]interface{}, 2, 2+len(members))
args[0] = "smismember"
args[1] = key
args = appendArgs(args, members)
cmd := NewBoolSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns all the members of the set value stored at key.
// Returns an empty slice if key does not exist.
//
// For more information about the command please refer to [SMEMBERS].
//
// [SMEMBERS]: (https://redis.io/docs/latest/commands/smembers/)
func (c cmdable) SMembers(ctx context.Context, key string) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "smembers", key)
_ = c(ctx, cmd)
return cmd
}
// Returns all the members of the set value stored at key as a map.
// Returns an empty map if key does not exist.
//
// For more information about the command please refer to [SMEMBERS].
//
// [SMEMBERS]: (https://redis.io/docs/latest/commands/smembers/)
func (c cmdable) SMembersMap(ctx context.Context, key string) *StringStructMapCmd {
cmd := NewStringStructMapCmd(ctx, "smembers", key)
_ = c(ctx, cmd)
return cmd
}
// Moves member from the set at source to the set at destination.
// This operation is atomic. In every given moment the element will appear to be a member
// of source or destination for other clients.
//
// For more information about the command please refer to [SMOVE].
//
// [SMOVE]: (https://redis.io/docs/latest/commands/smove/)
func (c cmdable) SMove(ctx context.Context, source, destination string, member interface{}) *BoolCmd {
cmd := NewBoolCmd(ctx, "smove", source, destination, member)
_ = c(ctx, cmd)
return cmd
}
// Removes and returns one or more random members from the set value stored at key.
// This version returns a single random member.
//
// For more information about the command please refer to [SPOP].
//
// [SPOP]: (https://redis.io/docs/latest/commands/spop/)
func (c cmdable) SPop(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "spop", key)
_ = c(ctx, cmd)
return cmd
}
// Removes and returns one or more random members from the set value stored at key.
// This version returns up to count random members.
//
// For more information about the command please refer to [SPOP].
//
// [SPOP]: (https://redis.io/docs/latest/commands/spop/)
func (c cmdable) SPopN(ctx context.Context, key string, count int64) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "spop", key, count)
_ = c(ctx, cmd)
return cmd
}
// Returns a random member from the set value stored at key.
// This version returns a single random member without removing it.
//
// For more information about the command please refer to [SRANDMEMBER].
//
// [SRANDMEMBER]: (https://redis.io/docs/latest/commands/srandmember/)
func (c cmdable) SRandMember(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "srandmember", key)
_ = c(ctx, cmd)
return cmd
}
// Returns an array of random members from the set value stored at key.
// This version returns up to count random members without removing them.
// When called with a positive count, returns distinct elements.
// When called with a negative count, allows for repeated elements.
//
// For more information about the command please refer to [SRANDMEMBER].
//
// [SRANDMEMBER]: (https://redis.io/docs/latest/commands/srandmember/)
func (c cmdable) SRandMemberN(ctx context.Context, key string, count int64) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "srandmember", key, count)
_ = c(ctx, cmd)
return cmd
}
// Removes the specified members from the set stored at key.
// Specified members that are not a member of this set are ignored.
// If key does not exist, it is treated as an empty set and this command returns 0.
//
// For more information about the command please refer to [SREM].
//
// [SREM]: (https://redis.io/docs/latest/commands/srem/)
func (c cmdable) SRem(ctx context.Context, key string, members ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(members))
args[0] = "srem"
args[1] = key
args = appendArgs(args, members)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns the members of the set resulting from the union of all the given sets.
// Keys that do not exist are considered to be empty sets.
//
// For more information about the command please refer to [SUNION].
//
// [SUNION]: (https://redis.io/docs/latest/commands/sunion/)
func (c cmdable) SUnion(ctx context.Context, keys ...string) *StringSliceCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "sunion"
for i, key := range keys {
args[1+i] = key
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Stores the members of the set resulting from the union of all the given sets
// into destination.
// If destination already exists, it is overwritten.
//
// For more information about the command please refer to [SUNIONSTORE].
//
// [SUNIONSTORE]: (https://redis.io/docs/latest/commands/sunionstore/)
func (c cmdable) SUnionStore(ctx context.Context, destination string, keys ...string) *IntCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "sunionstore"
args[1] = destination
for i, key := range keys {
args[2+i] = key
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Returns the cardinality of the union of all the given sets.
// Missing keys are treated as empty sets.
//
// For more information about the command please refer to [SUNIONCARD].
//
// [SUNIONCARD]: (https://redis.io/docs/latest/commands/sunioncard/)
func (c cmdable) SUnionCard(ctx context.Context, opts *SUnionCardOptions, keys ...string) *IntCmd {
if opts == nil {
opts = &SUnionCardOptions{}
}
numKeys := len(keys)
args := make([]interface{}, 0, 4+numKeys+1)
args = append(args, "sunioncard", numKeys)
for _, key := range keys {
args = append(args, key)
}
if opts.Approx {
args = append(args, "approx")
}
args = append(args, "limit", opts.Limit)
cmd := NewIntCmd(ctx, args...)
// Keys start after the numkeys arg: ["sunioncard", numKeys, key1, ...].
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// Incrementally iterates the set elements stored at key.
// This is a cursor-based iterator that allows scanning large sets efficiently.
//
// Parameters:
// - cursor: The cursor value for the iteration (use 0 to start a new scan)
// - match: Optional pattern to match elements (empty string means no pattern)
// - count: Optional hint about how many elements to return per iteration
//
// For more information about the command please refer to [SSCAN].
//
// [SSCAN]: (https://redis.io/docs/latest/commands/sscan/)
func (c cmdable) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd {
args := []interface{}{"sscan", 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
}
package redis
import (
"context"
"errors"
"strings"
"time"
"github.com/redis/go-redis/v9/internal/hashtag"
)
type SortedSetCmdable interface {
BZPopMax(ctx context.Context, timeout time.Duration, keys ...string) *ZWithKeyCmd
BZPopMin(ctx context.Context, timeout time.Duration, keys ...string) *ZWithKeyCmd
BZMPop(ctx context.Context, timeout time.Duration, order string, count int64, keys ...string) *ZSliceWithKeyCmd
ZAdd(ctx context.Context, key string, members ...Z) *IntCmd
ZAddLT(ctx context.Context, key string, members ...Z) *IntCmd
ZAddGT(ctx context.Context, key string, members ...Z) *IntCmd
ZAddNX(ctx context.Context, key string, members ...Z) *IntCmd
ZAddXX(ctx context.Context, key string, members ...Z) *IntCmd
ZAddArgs(ctx context.Context, key string, args ZAddArgs) *IntCmd
ZAddArgsIncr(ctx context.Context, key string, args ZAddArgs) *FloatCmd
ZCard(ctx context.Context, key string) *IntCmd
ZCount(ctx context.Context, key, min, max string) *IntCmd
ZLexCount(ctx context.Context, key, min, max string) *IntCmd
ZIncrBy(ctx context.Context, key string, increment float64, member string) *FloatCmd
ZInter(ctx context.Context, store *ZStore) *StringSliceCmd
ZInterWithScores(ctx context.Context, store *ZStore) *ZSliceCmd
ZInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd
ZInterStore(ctx context.Context, destination string, store *ZStore) *IntCmd
ZMPop(ctx context.Context, order string, count int64, keys ...string) *ZSliceWithKeyCmd
ZMScore(ctx context.Context, key string, members ...string) *FloatSliceCmd
ZPopMax(ctx context.Context, key string, count ...int64) *ZSliceCmd
ZPopMin(ctx context.Context, key string, count ...int64) *ZSliceCmd
ZRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd
ZRangeWithScores(ctx context.Context, key string, start, stop int64) *ZSliceCmd
ZRangeByScore(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd
ZRangeByLex(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd
ZRangeByScoreWithScores(ctx context.Context, key string, opt *ZRangeBy) *ZSliceCmd
ZRangeArgs(ctx context.Context, z ZRangeArgs) *StringSliceCmd
ZRangeArgsWithScores(ctx context.Context, z ZRangeArgs) *ZSliceCmd
ZRangeStore(ctx context.Context, dst string, z ZRangeArgs) *IntCmd
ZRank(ctx context.Context, key, member string) *IntCmd
ZRankWithScore(ctx context.Context, key, member string) *RankWithScoreCmd
ZRem(ctx context.Context, key string, members ...interface{}) *IntCmd
ZRemRangeByRank(ctx context.Context, key string, start, stop int64) *IntCmd
ZRemRangeByScore(ctx context.Context, key, min, max string) *IntCmd
ZRemRangeByLex(ctx context.Context, key, min, max string) *IntCmd
ZRevRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd
ZRevRangeWithScores(ctx context.Context, key string, start, stop int64) *ZSliceCmd
ZRevRangeByScore(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd
ZRevRangeByLex(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd
ZRevRangeByScoreWithScores(ctx context.Context, key string, opt *ZRangeBy) *ZSliceCmd
ZRevRank(ctx context.Context, key, member string) *IntCmd
ZRevRankWithScore(ctx context.Context, key, member string) *RankWithScoreCmd
ZScore(ctx context.Context, key, member string) *FloatCmd
ZUnionStore(ctx context.Context, dest string, store *ZStore) *IntCmd
ZRandMember(ctx context.Context, key string, count int) *StringSliceCmd
ZRandMemberWithScores(ctx context.Context, key string, count int) *ZSliceCmd
ZUnion(ctx context.Context, store ZStore) *StringSliceCmd
ZUnionWithScores(ctx context.Context, store ZStore) *ZSliceCmd
ZDiff(ctx context.Context, keys ...string) *StringSliceCmd
ZDiffWithScores(ctx context.Context, keys ...string) *ZSliceCmd
ZDiffStore(ctx context.Context, destination string, keys ...string) *IntCmd
ZScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd
}
// BZPopMax Redis `BZPOPMAX key [key ...] timeout` command.
func (c cmdable) BZPopMax(ctx context.Context, timeout time.Duration, keys ...string) *ZWithKeyCmd {
args := make([]interface{}, 1+len(keys)+1)
args[0] = "bzpopmax"
for i, key := range keys {
args[1+i] = key
}
args[len(args)-1] = formatSec(ctx, timeout)
cmd := NewZWithKeyCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
// BZPopMin Redis `BZPOPMIN key [key ...] timeout` command.
func (c cmdable) BZPopMin(ctx context.Context, timeout time.Duration, keys ...string) *ZWithKeyCmd {
args := make([]interface{}, 1+len(keys)+1)
args[0] = "bzpopmin"
for i, key := range keys {
args[1+i] = key
}
args[len(args)-1] = formatSec(ctx, timeout)
cmd := NewZWithKeyCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
// BZMPop is the blocking variant of ZMPOP.
// When any of the sorted sets contains elements, this command behaves exactly like ZMPOP.
// When all sorted sets are empty, Redis will block the connection until another client adds members to one of the keys or until the timeout elapses.
// A timeout of zero can be used to block indefinitely.
// example: client.BZMPop(ctx, 0,"max", 1, "set")
func (c cmdable) BZMPop(ctx context.Context, timeout time.Duration, order string, count int64, keys ...string) *ZSliceWithKeyCmd {
args := make([]interface{}, 3+len(keys), 6+len(keys))
args[0] = "bzmpop"
args[1] = formatSec(ctx, timeout)
args[2] = len(keys)
for i, key := range keys {
args[3+i] = key
}
args = append(args, strings.ToLower(order), "count", count)
cmd := NewZSliceWithKeyCmd(ctx, args...)
cmd.setReadTimeout(timeout)
_ = c(ctx, cmd)
return cmd
}
// ZAddArgs WARN: The GT, LT and NX options are mutually exclusive.
type ZAddArgs struct {
NX bool
XX bool
LT bool
GT bool
Ch bool
Members []Z
}
func (c cmdable) zAddArgs(key string, args ZAddArgs, incr bool) []interface{} {
a := make([]interface{}, 0, 6+2*len(args.Members))
a = append(a, "zadd", key)
// The GT, LT and NX options are mutually exclusive.
if args.NX {
a = append(a, "nx")
} else {
if args.XX {
a = append(a, "xx")
}
if args.GT {
a = append(a, "gt")
} else if args.LT {
a = append(a, "lt")
}
}
if args.Ch {
a = append(a, "ch")
}
if incr {
a = append(a, "incr")
}
for _, m := range args.Members {
a = append(a, m.Score)
a = append(a, m.Member)
}
return a
}
func (c cmdable) ZAddArgs(ctx context.Context, key string, args ZAddArgs) *IntCmd {
cmd := NewIntCmd(ctx, c.zAddArgs(key, args, false)...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZAddArgsIncr(ctx context.Context, key string, args ZAddArgs) *FloatCmd {
cmd := NewFloatCmd(ctx, c.zAddArgs(key, args, true)...)
_ = c(ctx, cmd)
return cmd
}
// ZAdd Redis `ZADD key score member [score member ...]` command.
func (c cmdable) ZAdd(ctx context.Context, key string, members ...Z) *IntCmd {
return c.ZAddArgs(ctx, key, ZAddArgs{
Members: members,
})
}
// ZAddLT Redis `ZADD key LT score member [score member ...]` command.
func (c cmdable) ZAddLT(ctx context.Context, key string, members ...Z) *IntCmd {
return c.ZAddArgs(ctx, key, ZAddArgs{
LT: true,
Members: members,
})
}
// ZAddGT Redis `ZADD key GT score member [score member ...]` command.
func (c cmdable) ZAddGT(ctx context.Context, key string, members ...Z) *IntCmd {
return c.ZAddArgs(ctx, key, ZAddArgs{
GT: true,
Members: members,
})
}
// ZAddNX Redis `ZADD key NX score member [score member ...]` command.
func (c cmdable) ZAddNX(ctx context.Context, key string, members ...Z) *IntCmd {
return c.ZAddArgs(ctx, key, ZAddArgs{
NX: true,
Members: members,
})
}
// ZAddXX Redis `ZADD key XX score member [score member ...]` command.
func (c cmdable) ZAddXX(ctx context.Context, key string, members ...Z) *IntCmd {
return c.ZAddArgs(ctx, key, ZAddArgs{
XX: true,
Members: members,
})
}
func (c cmdable) ZCard(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "zcard", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZCount(ctx context.Context, key, min, max string) *IntCmd {
cmd := NewIntCmd(ctx, "zcount", key, min, max)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZLexCount(ctx context.Context, key, min, max string) *IntCmd {
cmd := NewIntCmd(ctx, "zlexcount", key, min, max)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZIncrBy(ctx context.Context, key string, increment float64, member string) *FloatCmd {
cmd := NewFloatCmd(ctx, "zincrby", key, increment, member)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZInterStore(ctx context.Context, destination string, store *ZStore) *IntCmd {
args := make([]interface{}, 0, 3+store.len())
args = append(args, "zinterstore", destination, len(store.Keys))
args = store.appendArgs(args)
cmd := NewIntCmd(ctx, args...)
cmd.SetFirstKeyPos(3)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZInter(ctx context.Context, store *ZStore) *StringSliceCmd {
args := make([]interface{}, 0, 2+store.len())
args = append(args, "zinter", len(store.Keys))
args = store.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZInterWithScores(ctx context.Context, store *ZStore) *ZSliceCmd {
args := make([]interface{}, 0, 3+store.len())
args = append(args, "zinter", len(store.Keys))
args = store.appendArgs(args)
args = append(args, "withscores")
cmd := NewZSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd {
numKeys := len(keys)
args := make([]interface{}, 4+numKeys)
args[0] = "zintercard"
args[1] = numKeys
for i, key := range keys {
args[2+i] = key
}
args[2+numKeys] = "limit"
args[3+numKeys] = limit
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ZMPop Pops one or more elements with the highest or lowest score from the first non-empty sorted set key from the list of provided key names.
// direction: "max" (highest score) or "min" (lowest score), count: > 0
// example: client.ZMPop(ctx, "max", 5, "set1", "set2")
func (c cmdable) ZMPop(ctx context.Context, order string, count int64, keys ...string) *ZSliceWithKeyCmd {
args := make([]interface{}, 2+len(keys), 5+len(keys))
args[0] = "zmpop"
args[1] = len(keys)
for i, key := range keys {
args[2+i] = key
}
args = append(args, strings.ToLower(order), "count", count)
cmd := NewZSliceWithKeyCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZMScore(ctx context.Context, key string, members ...string) *FloatSliceCmd {
args := make([]interface{}, 2+len(members))
args[0] = "zmscore"
args[1] = key
for i, member := range members {
args[2+i] = member
}
cmd := NewFloatSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZPopMax(ctx context.Context, key string, count ...int64) *ZSliceCmd {
args := []interface{}{
"zpopmax",
key,
}
switch len(count) {
case 0:
break
case 1:
args = append(args, count[0])
default:
cmd := NewZSliceCmd(ctx)
cmd.SetErr(errors.New("too many arguments"))
return cmd
}
cmd := NewZSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZPopMin(ctx context.Context, key string, count ...int64) *ZSliceCmd {
args := []interface{}{
"zpopmin",
key,
}
switch len(count) {
case 0:
break
case 1:
args = append(args, count[0])
default:
cmd := NewZSliceCmd(ctx)
cmd.SetErr(errors.New("too many arguments"))
return cmd
}
cmd := NewZSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ZRangeArgs is all the options of the ZRange command.
// In version> 6.2.0, you can replace the(cmd):
//
// ZREVRANGE,
// ZRANGEBYSCORE,
// ZREVRANGEBYSCORE,
// ZRANGEBYLEX,
// ZREVRANGEBYLEX.
//
// Please pay attention to your redis-server version.
//
// Rev, ByScore, ByLex and Offset+Count options require redis-server 6.2.0 and higher.
type ZRangeArgs struct {
Key string
// When the ByScore option is provided, the open interval(exclusive) can be set.
// By default, the score intervals specified by <Start> and <Stop> are closed (inclusive).
// It is similar to the deprecated(6.2.0+) ZRangeByScore command.
// For example:
// ZRangeArgs{
// Key: "example-key",
// Start: "(3",
// Stop: 8,
// ByScore: true,
// }
// cmd: "ZRange example-key (3 8 ByScore" (3 < score <= 8).
//
// When the Rev option is also provided, <Start> should be the higher score value and
// <Stop> should be the lower score value (i.e. reversed order):
// ZRangeArgs{
// Key: "example-key",
// Start: 8,
// Stop: "(3",
// ByScore: true,
// Rev: true,
// }
// cmd: "ZRange example-key 8 (3 ByScore Rev" (8 >= score > 3, in reverse order).
//
// For the ByLex option, it is similar to the deprecated(6.2.0+) ZRangeByLex command.
// You can set the <Start> and <Stop> options as follows:
// ZRangeArgs{
// Key: "example-key",
// Start: "[abc",
// Stop: "(def",
// ByLex: true,
// }
// cmd: "ZRange example-key [abc (def ByLex"
//
// When the Rev option is also provided, <Start> should be the lexicographically higher
// value and <Stop> should be the lower value:
// ZRangeArgs{
// Key: "example-key",
// Start: "(def",
// Stop: "[abc",
// ByLex: true,
// Rev: true,
// }
// cmd: "ZRange example-key (def [abc ByLex Rev"
//
// For normal cases (ByScore==false && ByLex==false), <Start> and <Stop> should be set to the index range (int).
// You can read the documentation for more information: https://redis.io/commands/zrange
Start interface{}
Stop interface{}
// The ByScore and ByLex options are mutually exclusive.
ByScore bool
ByLex bool
Rev bool
// limit offset count.
Offset int64
Count int64
}
func (z ZRangeArgs) appendArgs(args []interface{}) []interface{} {
args = append(args, z.Key, z.Start, z.Stop)
if z.ByScore {
args = append(args, "byscore")
} else if z.ByLex {
args = append(args, "bylex")
}
if z.Rev {
args = append(args, "rev")
}
if z.Offset != 0 || z.Count != 0 {
args = append(args, "limit", z.Offset, z.Count)
}
return args
}
func (c cmdable) ZRangeArgs(ctx context.Context, z ZRangeArgs) *StringSliceCmd {
args := make([]interface{}, 0, 9)
args = append(args, "zrange")
args = z.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRangeArgsWithScores(ctx context.Context, z ZRangeArgs) *ZSliceCmd {
args := make([]interface{}, 0, 10)
args = append(args, "zrange")
args = z.appendArgs(args)
args = append(args, "withscores")
cmd := NewZSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd {
return c.ZRangeArgs(ctx, ZRangeArgs{
Key: key,
Start: start,
Stop: stop,
})
}
func (c cmdable) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *ZSliceCmd {
return c.ZRangeArgsWithScores(ctx, ZRangeArgs{
Key: key,
Start: start,
Stop: stop,
})
}
type ZRangeBy struct {
Min, Max string
Offset, Count int64
}
func (c cmdable) zRangeBy(ctx context.Context, zcmd, key string, opt *ZRangeBy, withScores bool) *StringSliceCmd {
args := []interface{}{zcmd, key, opt.Min, opt.Max}
if withScores {
args = append(args, "withscores")
}
if opt.Offset != 0 || opt.Count != 0 {
args = append(
args,
"limit",
opt.Offset,
opt.Count,
)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ZRangeByScore returns members in a sorted set within a range of scores.
//
// Deprecated: Use ZRangeArgs with ByScore option instead as of Redis 6.2.0.
func (c cmdable) ZRangeByScore(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd {
return c.zRangeBy(ctx, "zrangebyscore", key, opt, false)
}
// ZRangeByLex returns members in a sorted set within a lexicographical range.
//
// Deprecated: Use ZRangeArgs with ByLex option instead as of Redis 6.2.0.
func (c cmdable) ZRangeByLex(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd {
return c.zRangeBy(ctx, "zrangebylex", key, opt, false)
}
func (c cmdable) ZRangeByScoreWithScores(ctx context.Context, key string, opt *ZRangeBy) *ZSliceCmd {
args := []interface{}{"zrangebyscore", key, opt.Min, opt.Max, "withscores"}
if opt.Offset != 0 || opt.Count != 0 {
args = append(
args,
"limit",
opt.Offset,
opt.Count,
)
}
cmd := NewZSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRangeStore(ctx context.Context, dst string, z ZRangeArgs) *IntCmd {
args := make([]interface{}, 0, 10)
args = append(args, "zrangestore", dst)
args = z.appendArgs(args)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRank(ctx context.Context, key, member string) *IntCmd {
cmd := NewIntCmd(ctx, "zrank", key, member)
_ = c(ctx, cmd)
return cmd
}
// ZRankWithScore according to the Redis documentation, if member does not exist
// in the sorted set or key does not exist, it will return a redis.Nil error.
func (c cmdable) ZRankWithScore(ctx context.Context, key, member string) *RankWithScoreCmd {
cmd := NewRankWithScoreCmd(ctx, "zrank", key, member, "withscore")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRem(ctx context.Context, key string, members ...interface{}) *IntCmd {
args := make([]interface{}, 2, 2+len(members))
args[0] = "zrem"
args[1] = key
args = appendArgs(args, members)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRemRangeByRank(ctx context.Context, key string, start, stop int64) *IntCmd {
cmd := NewIntCmd(
ctx,
"zremrangebyrank",
key,
start,
stop,
)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRemRangeByScore(ctx context.Context, key, min, max string) *IntCmd {
cmd := NewIntCmd(ctx, "zremrangebyscore", key, min, max)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRemRangeByLex(ctx context.Context, key, min, max string) *IntCmd {
cmd := NewIntCmd(ctx, "zremrangebylex", key, min, max)
_ = c(ctx, cmd)
return cmd
}
// ZRevRange returns members in a sorted set within a range of indexes in reverse order.
//
// Deprecated: Use ZRangeArgs with Rev option instead as of Redis 6.2.0.
func (c cmdable) ZRevRange(ctx context.Context, key string, start, stop int64) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "zrevrange", key, start, stop)
_ = c(ctx, cmd)
return cmd
}
// ZRevRangeWithScores according to the Redis documentation, if member does not exist
// in the sorted set or key does not exist, it will return a redis.Nil error.
func (c cmdable) ZRevRangeWithScores(ctx context.Context, key string, start, stop int64) *ZSliceCmd {
cmd := NewZSliceCmd(ctx, "zrevrange", key, start, stop, "withscores")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) zRevRangeBy(ctx context.Context, zcmd, key string, opt *ZRangeBy) *StringSliceCmd {
args := []interface{}{zcmd, key, opt.Max, opt.Min}
if opt.Offset != 0 || opt.Count != 0 {
args = append(
args,
"limit",
opt.Offset,
opt.Count,
)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// ZRevRangeByScore returns members in a sorted set within a range of scores in reverse order.
//
// Deprecated: Use ZRangeArgs with Rev and ByScore options instead as of Redis 6.2.0.
func (c cmdable) ZRevRangeByScore(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd {
return c.zRevRangeBy(ctx, "zrevrangebyscore", key, opt)
}
// ZRevRangeByLex returns members in a sorted set within a lexicographical range in reverse order.
//
// Deprecated: Use ZRangeArgs with Rev and ByLex options instead as of Redis 6.2.0.
func (c cmdable) ZRevRangeByLex(ctx context.Context, key string, opt *ZRangeBy) *StringSliceCmd {
return c.zRevRangeBy(ctx, "zrevrangebylex", key, opt)
}
func (c cmdable) ZRevRangeByScoreWithScores(ctx context.Context, key string, opt *ZRangeBy) *ZSliceCmd {
args := []interface{}{"zrevrangebyscore", key, opt.Max, opt.Min, "withscores"}
if opt.Offset != 0 || opt.Count != 0 {
args = append(
args,
"limit",
opt.Offset,
opt.Count,
)
}
cmd := NewZSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRevRank(ctx context.Context, key, member string) *IntCmd {
cmd := NewIntCmd(ctx, "zrevrank", key, member)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZRevRankWithScore(ctx context.Context, key, member string) *RankWithScoreCmd {
cmd := NewRankWithScoreCmd(ctx, "zrevrank", key, member, "withscore")
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZScore(ctx context.Context, key, member string) *FloatCmd {
cmd := NewFloatCmd(ctx, "zscore", key, member)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZUnion(ctx context.Context, store ZStore) *StringSliceCmd {
args := make([]interface{}, 0, 2+store.len())
args = append(args, "zunion", len(store.Keys))
args = store.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZUnionWithScores(ctx context.Context, store ZStore) *ZSliceCmd {
args := make([]interface{}, 0, 3+store.len())
args = append(args, "zunion", len(store.Keys))
args = store.appendArgs(args)
args = append(args, "withscores")
cmd := NewZSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZUnionStore(ctx context.Context, dest string, store *ZStore) *IntCmd {
args := make([]interface{}, 0, 3+store.len())
args = append(args, "zunionstore", dest, len(store.Keys))
args = store.appendArgs(args)
cmd := NewIntCmd(ctx, args...)
cmd.SetFirstKeyPos(3)
_ = c(ctx, cmd)
return cmd
}
// ZRandMember redis-server version >= 6.2.0.
func (c cmdable) ZRandMember(ctx context.Context, key string, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "zrandmember", key, count)
_ = c(ctx, cmd)
return cmd
}
// ZRandMemberWithScores redis-server version >= 6.2.0.
func (c cmdable) ZRandMemberWithScores(ctx context.Context, key string, count int) *ZSliceCmd {
cmd := NewZSliceCmd(ctx, "zrandmember", key, count, "withscores")
_ = c(ctx, cmd)
return cmd
}
// ZDiff redis-server version >= 6.2.0.
func (c cmdable) ZDiff(ctx context.Context, keys ...string) *StringSliceCmd {
args := make([]interface{}, 2+len(keys))
args[0] = "zdiff"
args[1] = len(keys)
for i, key := range keys {
args[i+2] = key
}
cmd := NewStringSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// ZDiffWithScores redis-server version >= 6.2.0.
func (c cmdable) ZDiffWithScores(ctx context.Context, keys ...string) *ZSliceCmd {
args := make([]interface{}, 3+len(keys))
args[0] = "zdiff"
args[1] = len(keys)
for i, key := range keys {
args[i+2] = key
}
args[len(keys)+2] = "withscores"
cmd := NewZSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// ZDiffStore redis-server version >=6.2.0.
func (c cmdable) ZDiffStore(ctx context.Context, destination string, keys ...string) *IntCmd {
args := make([]interface{}, 0, 3+len(keys))
args = append(args, "zdiffstore", destination, len(keys))
for _, key := range keys {
args = append(args, key)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) ZScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd {
args := []interface{}{"zscan", 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
}
// Z represents sorted set member.
type Z struct {
Score float64
Member interface{}
}
// ZWithKey represents sorted set member including the name of the key where it was popped.
type ZWithKey struct {
Z
Key string
}
// ZStore is used as an arg to ZInter/ZInterStore and ZUnion/ZUnionStore.
type ZStore struct {
Keys []string
Weights []float64
// Can be SUM, MIN, MAX or COUNT.
Aggregate string
}
func (z ZStore) len() (n int) {
n = len(z.Keys)
if len(z.Weights) > 0 {
n += 1 + len(z.Weights)
}
if z.Aggregate != "" {
n += 2
}
return n
}
func (z ZStore) appendArgs(args []interface{}) []interface{} {
for _, key := range z.Keys {
args = append(args, key)
}
if len(z.Weights) > 0 {
args = append(args, "weights")
for _, weights := range z.Weights {
args = append(args, weights)
}
}
if z.Aggregate != "" {
args = append(args, "aggregate", z.Aggregate)
}
return args
}
package redis
import (
"context"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9/internal/otel"
)
// XTrimLimitDisabled is a sentinel value for the LIMIT argument of stream
// trimming (XAddArgs.Limit and the XTrim*Approx* commands). Passing it emits
// an explicit "LIMIT 0", which tells Redis to disable the trimming effort cap
// entirely. This differs from passing 0, which keeps the historical behavior:
// no LIMIT clause is sent and Redis applies its implicit default
// (100 * stream-node-max-entries examined entries).
//
// LIMIT is only valid together with the "~" (approximate) trimming flag;
// Redis rejects LIMIT used with exact ("=") trimming.
const XTrimLimitDisabled = -1
// appendXTrimLimit appends the LIMIT clause used by XADD and XTRIM trimming:
// - limit > 0 emits "LIMIT <limit>";
// - limit < 0 (see XTrimLimitDisabled) emits "LIMIT 0", disabling the
// trimming effort cap;
// - limit == 0 omits the clause, so Redis applies its implicit default.
func appendXTrimLimit(args []interface{}, limit int64) []interface{} {
switch {
case limit > 0:
return append(args, "limit", limit)
case limit < 0:
return append(args, "limit", int64(0))
default:
return args
}
}
type StreamCmdable interface {
XAdd(ctx context.Context, a *XAddArgs) *StringCmd
XAckDel(ctx context.Context, stream string, group string, mode string, ids ...string) *SliceCmd
XDel(ctx context.Context, stream string, ids ...string) *IntCmd
XDelEx(ctx context.Context, stream string, mode string, ids ...string) *SliceCmd
XLen(ctx context.Context, stream string) *IntCmd
XRange(ctx context.Context, stream, start, stop string) *XMessageSliceCmd
XRangeN(ctx context.Context, stream, start, stop string, count int64) *XMessageSliceCmd
XRevRange(ctx context.Context, stream string, start, stop string) *XMessageSliceCmd
XRevRangeN(ctx context.Context, stream string, start, stop string, count int64) *XMessageSliceCmd
XRead(ctx context.Context, a *XReadArgs) *XStreamSliceCmd
XReadStreams(ctx context.Context, streams ...string) *XStreamSliceCmd
XGroupCreate(ctx context.Context, stream, group, start string) *StatusCmd
XGroupCreateMkStream(ctx context.Context, stream, group, start string) *StatusCmd
XGroupSetID(ctx context.Context, stream, group, start string) *StatusCmd
XGroupDestroy(ctx context.Context, stream, group string) *IntCmd
XGroupCreateConsumer(ctx context.Context, stream, group, consumer string) *IntCmd
XGroupDelConsumer(ctx context.Context, stream, group, consumer string) *IntCmd
XReadGroup(ctx context.Context, a *XReadGroupArgs) *XStreamSliceCmd
XAck(ctx context.Context, stream, group string, ids ...string) *IntCmd
XNack(ctx context.Context, a *XNackArgs) *IntCmd
XPending(ctx context.Context, stream, group string) *XPendingCmd
XPendingExt(ctx context.Context, a *XPendingExtArgs) *XPendingExtCmd
XClaim(ctx context.Context, a *XClaimArgs) *XMessageSliceCmd
XClaimJustID(ctx context.Context, a *XClaimArgs) *StringSliceCmd
XAutoClaim(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimCmd
XAutoClaimWithDeleted(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimWithDeletedCmd
XAutoClaimJustID(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimJustIDCmd
XTrimMaxLen(ctx context.Context, key string, maxLen int64) *IntCmd
XTrimMaxLenApprox(ctx context.Context, key string, maxLen, limit int64) *IntCmd
XTrimMaxLenMode(ctx context.Context, key string, maxLen int64, mode string) *IntCmd
XTrimMaxLenApproxMode(ctx context.Context, key string, maxLen, limit int64, mode string) *IntCmd
XTrimMinID(ctx context.Context, key string, minID string) *IntCmd
XTrimMinIDApprox(ctx context.Context, key string, minID string, limit int64) *IntCmd
XTrimMinIDMode(ctx context.Context, key string, minID string, mode string) *IntCmd
XTrimMinIDApproxMode(ctx context.Context, key string, minID string, limit int64, mode string) *IntCmd
XInfoGroups(ctx context.Context, key string) *XInfoGroupsCmd
XInfoStream(ctx context.Context, key string) *XInfoStreamCmd
XInfoStreamFull(ctx context.Context, key string, count int) *XInfoStreamFullCmd
XInfoConsumers(ctx context.Context, key string, group string) *XInfoConsumersCmd
XCfgSet(ctx context.Context, a *XCfgSetArgs) *StatusCmd
}
// XAddArgs accepts values in the following formats:
// - XAddArgs.Values = []interface{}{"key1", "value1", "key2", "value2"}
// - XAddArgs.Values = []string("key1", "value1", "key2", "value2")
// - XAddArgs.Values = map[string]interface{}{"key1": "value1", "key2": "value2"}
//
// Note that map will not preserve the order of key-value pairs.
// MaxLen/MaxLenApprox and MinID are in conflict, only one of them can be used.
//
// For idempotent production (at-most-once production):
// - ProducerID: A unique identifier for the producer (required for both IDMP and IDMPAUTO)
// - IdempotentID: A unique identifier for the message (used with IDMP)
// - IdempotentAuto: If true, Redis will auto-generate an idempotent ID based on message content (IDMPAUTO)
//
// ProducerID and IdempotentID are mutually exclusive with IdempotentAuto.
// When using idempotent production, ID must be "*" or empty.
type XAddArgs struct {
Stream string
NoMkStream bool
MaxLen int64 // MAXLEN N
MinID string
// Approx causes MaxLen and MinID to use "~" matcher (instead of "=").
Approx bool
// Limit caps the trimming effort:
// - 0 omits the LIMIT clause (Redis applies its implicit default);
// - a positive value emits "LIMIT <n>";
// - a negative value (see XTrimLimitDisabled) emits "LIMIT 0",
// disabling the effort cap entirely.
// LIMIT requires Approx to be true; Redis rejects LIMIT together with
// exact ("=") trimming.
Limit int64
Mode string
ID string
Values interface{}
ProducerID string // Producer ID for idempotent production (IDMP or IDMPAUTO)
IdempotentID string // Idempotent ID for IDMP
IdempotentAuto bool // Use IDMPAUTO to auto-generate idempotent ID based on content
}
func (c cmdable) XAdd(ctx context.Context, a *XAddArgs) *StringCmd {
args := make([]interface{}, 0, 15)
args = append(args, "xadd", a.Stream)
if a.NoMkStream {
args = append(args, "nomkstream")
}
if a.Mode != "" {
args = append(args, a.Mode)
}
if a.ProducerID != "" {
if a.IdempotentAuto {
// IDMPAUTO pid
args = append(args, "idmpauto", a.ProducerID)
} else if a.IdempotentID != "" {
// IDMP pid iid
args = append(args, "idmp", a.ProducerID, a.IdempotentID)
}
}
switch {
case a.MaxLen > 0:
if a.Approx {
args = append(args, "maxlen", "~", a.MaxLen)
} else {
args = append(args, "maxlen", "=", a.MaxLen)
}
case a.MinID != "":
if a.Approx {
args = append(args, "minid", "~", a.MinID)
} else {
args = append(args, "minid", "=", a.MinID)
}
}
args = appendXTrimLimit(args, a.Limit)
if a.ID != "" {
args = append(args, a.ID)
} else {
args = append(args, "*")
}
args = appendArg(args, a.Values)
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XAckDel(ctx context.Context, stream string, group string, mode string, ids ...string) *SliceCmd {
args := []interface{}{"xackdel", stream, group, mode, "ids", len(ids)}
for _, id := range ids {
args = append(args, id)
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XDel(ctx context.Context, stream string, ids ...string) *IntCmd {
args := []interface{}{"xdel", stream}
for _, id := range ids {
args = append(args, id)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XDelEx(ctx context.Context, stream string, mode string, ids ...string) *SliceCmd {
args := []interface{}{"xdelex", stream, mode, "ids", len(ids)}
for _, id := range ids {
args = append(args, id)
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XLen(ctx context.Context, stream string) *IntCmd {
cmd := NewIntCmd(ctx, "xlen", stream)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XRange(ctx context.Context, stream, start, stop string) *XMessageSliceCmd {
cmd := NewXMessageSliceCmd(ctx, "xrange", stream, start, stop)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XRangeN(ctx context.Context, stream, start, stop string, count int64) *XMessageSliceCmd {
cmd := NewXMessageSliceCmd(ctx, "xrange", stream, start, stop, "count", count)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XRevRange(ctx context.Context, stream, start, stop string) *XMessageSliceCmd {
cmd := NewXMessageSliceCmd(ctx, "xrevrange", stream, start, stop)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XRevRangeN(ctx context.Context, stream, start, stop string, count int64) *XMessageSliceCmd {
cmd := NewXMessageSliceCmd(ctx, "xrevrange", stream, start, stop, "count", count)
_ = c(ctx, cmd)
return cmd
}
type XReadArgs struct {
Streams []string // list of streams and ids, e.g. stream1 stream2 id1 id2
Count int64
MaxCount int64 // cumulative cap on total entries across all streams (Redis >= 8.10)
MaxSize int64 // soft cumulative cap on total reply size in bytes across all streams (Redis >= 8.10)
Block time.Duration
ID string
}
func (c cmdable) XRead(ctx context.Context, a *XReadArgs) *XStreamSliceCmd {
args := make([]interface{}, 0, 2*len(a.Streams)+10)
args = append(args, "xread")
keyPos := int8(1)
if a.Count > 0 {
args = append(args, "count")
args = append(args, a.Count)
keyPos += 2
}
if a.MaxCount > 0 {
args = append(args, "maxcount", a.MaxCount)
keyPos += 2
}
if a.MaxSize > 0 {
args = append(args, "maxsize", a.MaxSize)
keyPos += 2
}
if a.Block >= 0 {
args = append(args, "block")
args = append(args, int64(a.Block/time.Millisecond))
keyPos += 2
}
args = append(args, "streams")
keyPos++
for _, s := range a.Streams {
args = append(args, s)
}
if a.ID != "" {
for range a.Streams {
args = append(args, a.ID)
}
}
cmd := NewXStreamSliceCmd(ctx, args...)
if a.Block >= 0 {
cmd.setReadTimeout(a.Block)
}
cmd.SetFirstKeyPos(keyPos)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XReadStreams(ctx context.Context, streams ...string) *XStreamSliceCmd {
return c.XRead(ctx, &XReadArgs{
Streams: streams,
Block: -1,
})
}
func (c cmdable) XGroupCreate(ctx context.Context, stream, group, start string) *StatusCmd {
cmd := NewStatusCmd(ctx, "xgroup", "create", stream, group, start)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XGroupCreateMkStream(ctx context.Context, stream, group, start string) *StatusCmd {
cmd := NewStatusCmd(ctx, "xgroup", "create", stream, group, start, "mkstream")
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XGroupSetID(ctx context.Context, stream, group, start string) *StatusCmd {
cmd := NewStatusCmd(ctx, "xgroup", "setid", stream, group, start)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XGroupDestroy(ctx context.Context, stream, group string) *IntCmd {
cmd := NewIntCmd(ctx, "xgroup", "destroy", stream, group)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XGroupCreateConsumer(ctx context.Context, stream, group, consumer string) *IntCmd {
cmd := NewIntCmd(ctx, "xgroup", "createconsumer", stream, group, consumer)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XGroupDelConsumer(ctx context.Context, stream, group, consumer string) *IntCmd {
cmd := NewIntCmd(ctx, "xgroup", "delconsumer", stream, group, consumer)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
type XReadGroupArgs struct {
Group string
Consumer string
Streams []string // list of streams and ids, e.g. stream1 stream2 id1 id2
Count int64
MaxCount int64 // cumulative cap on total entries across all streams (Redis >= 8.10)
MaxSize int64 // soft cumulative cap on total reply size in bytes across all streams (Redis >= 8.10)
Block time.Duration
NoAck bool
Claim time.Duration // Claim idle pending entries older than this duration
}
func (c cmdable) XReadGroup(ctx context.Context, a *XReadGroupArgs) *XStreamSliceCmd {
args := make([]interface{}, 0, 14+len(a.Streams))
args = append(args, "xreadgroup", "group", a.Group, a.Consumer)
keyPos := int8(4)
if a.Count > 0 {
args = append(args, "count", a.Count)
keyPos += 2
}
if a.MaxCount > 0 {
args = append(args, "maxcount", a.MaxCount)
keyPos += 2
}
if a.MaxSize > 0 {
args = append(args, "maxsize", a.MaxSize)
keyPos += 2
}
if a.Block >= 0 {
args = append(args, "block", int64(a.Block/time.Millisecond))
keyPos += 2
}
if a.NoAck {
args = append(args, "noack")
keyPos++
}
if a.Claim > 0 {
args = append(args, "claim", int64(a.Claim/time.Millisecond))
keyPos += 2
}
args = append(args, "streams")
keyPos++
for _, s := range a.Streams {
args = append(args, s)
}
cmd := NewXStreamSliceCmd(ctx, args...)
if a.Block >= 0 {
cmd.setReadTimeout(a.Block)
}
cmd.SetFirstKeyPos(keyPos)
_ = c(ctx, cmd)
// Record stream lag for each message (if command succeeded). Gated on the
// result being readable WITHOUT blocking: this command carries a
// read-timeout marker, so on the deferred autopipeline face it is diverted
// and still running when we get here — and the default Block: 0 form can
// wait indefinitely for messages, so reading the outcome would block the
// submit call instead of returning a future (review finding by codex on
// #3942). Skipped for a submission that has not executed yet; emitting
// this from the execution path is a follow-up in the OTel wiring.
if otel.Enabled() && cmd.resultReady() && cmd.rawErr() == nil {
streams := cmd.Val()
for _, stream := range streams {
for _, msg := range stream.Messages {
// Parse message ID to extract timestamp (format: "millisecondsTime-sequenceNumber")
if parts := strings.SplitN(msg.ID, "-", 2); len(parts) == 2 {
if timestampMs, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
// Calculate lag (time since message was created)
messageTime := time.Unix(0, timestampMs*int64(time.Millisecond))
lag := time.Since(messageTime)
// Record lag metric
otel.RecordStreamLag(ctx, lag, nil, stream.Stream, a.Group, a.Consumer)
}
}
}
}
}
return cmd
}
func (c cmdable) XAck(ctx context.Context, stream, group string, ids ...string) *IntCmd {
args := []interface{}{"xack", stream, group}
for _, id := range ids {
args = append(args, id)
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// XNACK modes. See [XNackArgs.Mode].
const (
XNackModeSilent = "SILENT"
XNackModeFail = "FAIL"
XNackModeFatal = "FATAL"
)
// XNackArgs represents the arguments for the XNACK command (Redis >= 8.8).
//
// XNACK negatively acknowledges one or more messages in a consumer group's
// Pending Entries List (PEL), releasing them back to the group so they can be
// redelivered to another consumer via XREADGROUP.
type XNackArgs struct {
Stream string
Group string
// Mode controls how the delivery counter is adjusted for each NACKed entry.
// Must be one of [XNackModeSilent], [XNackModeFail], or [XNackModeFatal]:
// - SILENT: the consumer is shutting down or experiencing internal errors
// unrelated to the message. The delivery counter is decremented by 1,
// undoing the increment that happened when the message was delivered.
// - FAIL: the consumer could not process the message (e.g. insufficient
// memory), but another consumer might succeed. The delivery counter is
// left unchanged.
// - FATAL: the message is invalid or suspected malicious. The delivery
// counter is set to MAXINT, which will immediately move the message to
// the Dead Letter Queue (DLQ) if one is configured for the group.
Mode string
// IDs is the list of message IDs to NACK. All IDs must already be in the
// group's PEL (i.e. previously delivered via XREADGROUP), unless Force is set.
IDs []string
// RetryCount sets the delivery counter to an explicit value, overriding the
// counter adjustment that would otherwise be applied by Mode.
// Leave nil to let Mode control the counter (the common case).
RetryCount *uint64
// Force allows NACKing message IDs that are not yet in the group's PEL,
// creating new unowned NACKed PEL entries for them directly.
// This is analogous to the FORCE flag in XCLAIM.
// Primarily used internally by Redis during AOF rewrite to reconstruct
// NACKed entries, but can also be used to manually inject entries.
Force bool
}
// XNack executes the XNACK command. See [XNackArgs] for the full argument documentation.
// Requires Redis >= 8.8.
func (c cmdable) XNack(ctx context.Context, a *XNackArgs) *IntCmd {
args := make([]interface{}, 0, 9+len(a.IDs))
args = append(args, "xnack", a.Stream, a.Group, a.Mode, "ids", len(a.IDs))
for _, id := range a.IDs {
args = append(args, id)
}
if a.RetryCount != nil {
args = append(args, "retrycount", *a.RetryCount)
}
if a.Force {
args = append(args, "force")
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XPending(ctx context.Context, stream, group string) *XPendingCmd {
cmd := NewXPendingCmd(ctx, "xpending", stream, group)
_ = c(ctx, cmd)
return cmd
}
type XPendingExtArgs struct {
Stream string
Group string
Idle time.Duration
Start string
End string
Count int64
Consumer string
}
func (c cmdable) XPendingExt(ctx context.Context, a *XPendingExtArgs) *XPendingExtCmd {
args := make([]interface{}, 0, 9)
args = append(args, "xpending", a.Stream, a.Group)
if a.Idle != 0 {
args = append(args, "idle", formatMs(ctx, a.Idle))
}
args = append(args, a.Start, a.End, a.Count)
if a.Consumer != "" {
args = append(args, a.Consumer)
}
cmd := NewXPendingExtCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type XAutoClaimArgs struct {
Stream string
Group string
MinIdle time.Duration
Start string
Count int64
Consumer string
}
func (c cmdable) XAutoClaim(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimCmd {
args := xAutoClaimArgs(ctx, a)
cmd := NewXAutoClaimCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XAutoClaimWithDeleted(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimWithDeletedCmd {
args := xAutoClaimArgs(ctx, a)
cmd := NewXAutoClaimWithDeletedCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XAutoClaimJustID(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimJustIDCmd {
args := xAutoClaimArgs(ctx, a)
args = append(args, "justid")
cmd := NewXAutoClaimJustIDCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func xAutoClaimArgs(ctx context.Context, a *XAutoClaimArgs) []interface{} {
args := make([]interface{}, 0, 8)
args = append(args, "xautoclaim", a.Stream, a.Group, a.Consumer, formatMs(ctx, a.MinIdle), a.Start)
if a.Count > 0 {
args = append(args, "count", a.Count)
}
return args
}
type XClaimArgs struct {
Stream string
Group string
Consumer string
MinIdle time.Duration
Messages []string
}
func (c cmdable) XClaim(ctx context.Context, a *XClaimArgs) *XMessageSliceCmd {
args := xClaimArgs(a)
cmd := NewXMessageSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XClaimJustID(ctx context.Context, a *XClaimArgs) *StringSliceCmd {
args := xClaimArgs(a)
args = append(args, "justid")
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func xClaimArgs(a *XClaimArgs) []interface{} {
args := make([]interface{}, 0, 5+len(a.Messages))
args = append(args,
"xclaim",
a.Stream,
a.Group, a.Consumer,
int64(a.MinIdle/time.Millisecond))
for _, id := range a.Messages {
args = append(args, id)
}
return args
}
// TODO: refactor xTrim, xTrimMode and the wrappers over the functions
// xTrim If approx is true, add the "~" parameter, otherwise it is the default "=" (redis default).
// example:
//
// XTRIM key MAXLEN/MINID threshold LIMIT limit.
// XTRIM key MAXLEN/MINID ~ threshold LIMIT limit.
//
// The redis-server version is lower than 6.2, please set limit to 0.
//
// limit == 0 omits the LIMIT clause, a positive limit emits "LIMIT <limit>",
// and a negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable
// the trimming effort cap. LIMIT requires approx; Redis rejects it otherwise.
func (c cmdable) xTrim(
ctx context.Context, key, strategy string,
approx bool, threshold interface{}, limit int64,
) *IntCmd {
args := make([]interface{}, 0, 7)
args = append(args, "xtrim", key, strategy)
if approx {
args = append(args, "~")
} else {
args = append(args, "=")
}
args = append(args, threshold)
args = appendXTrimLimit(args, limit)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// XTrimMaxLen No `~` rules are used, `limit` cannot be used.
// cmd: XTRIM key MAXLEN maxLen
func (c cmdable) XTrimMaxLen(ctx context.Context, key string, maxLen int64) *IntCmd {
return c.xTrim(ctx, key, "maxlen", false, maxLen, 0)
}
// XTrimMaxLenApprox trims the stream using the `~` rule.
// cmd: XTRIM key MAXLEN ~ maxLen [LIMIT limit]
//
// limit == 0 omits the LIMIT clause, limit > 0 emits "LIMIT <limit>", and a
// negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable the
// trimming effort cap.
func (c cmdable) XTrimMaxLenApprox(ctx context.Context, key string, maxLen, limit int64) *IntCmd {
return c.xTrim(ctx, key, "maxlen", true, maxLen, limit)
}
func (c cmdable) XTrimMinID(ctx context.Context, key string, minID string) *IntCmd {
return c.xTrim(ctx, key, "minid", false, minID, 0)
}
// XTrimMinIDApprox trims the stream using the `~` rule.
// cmd: XTRIM key MINID ~ minID [LIMIT limit]
//
// limit == 0 omits the LIMIT clause, limit > 0 emits "LIMIT <limit>", and a
// negative limit (see XTrimLimitDisabled) emits "LIMIT 0" to disable the
// trimming effort cap.
func (c cmdable) XTrimMinIDApprox(ctx context.Context, key string, minID string, limit int64) *IntCmd {
return c.xTrim(ctx, key, "minid", true, minID, limit)
}
// xTrimMode is xTrim with a trailing trimming mode argument (e.g. KEEPREF).
// The limit semantics are the same as xTrim's.
func (c cmdable) xTrimMode(
ctx context.Context, key, strategy string,
approx bool, threshold interface{}, limit int64,
mode string,
) *IntCmd {
args := make([]interface{}, 0, 7)
args = append(args, "xtrim", key, strategy)
if approx {
args = append(args, "~")
} else {
args = append(args, "=")
}
args = append(args, threshold)
args = appendXTrimLimit(args, limit)
args = append(args, mode)
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XTrimMaxLenMode(ctx context.Context, key string, maxLen int64, mode string) *IntCmd {
return c.xTrimMode(ctx, key, "maxlen", false, maxLen, 0, mode)
}
func (c cmdable) XTrimMaxLenApproxMode(ctx context.Context, key string, maxLen, limit int64, mode string) *IntCmd {
return c.xTrimMode(ctx, key, "maxlen", true, maxLen, limit, mode)
}
func (c cmdable) XTrimMinIDMode(ctx context.Context, key string, minID string, mode string) *IntCmd {
return c.xTrimMode(ctx, key, "minid", false, minID, 0, mode)
}
func (c cmdable) XTrimMinIDApproxMode(ctx context.Context, key string, minID string, limit int64, mode string) *IntCmd {
return c.xTrimMode(ctx, key, "minid", true, minID, limit, mode)
}
func (c cmdable) XInfoConsumers(ctx context.Context, key string, group string) *XInfoConsumersCmd {
cmd := NewXInfoConsumersCmd(ctx, key, group)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XInfoGroups(ctx context.Context, key string) *XInfoGroupsCmd {
cmd := NewXInfoGroupsCmd(ctx, key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) XInfoStream(ctx context.Context, key string) *XInfoStreamCmd {
cmd := NewXInfoStreamCmd(ctx, key)
_ = c(ctx, cmd)
return cmd
}
// XInfoStreamFull XINFO STREAM FULL [COUNT count]
// redis-server >= 6.0.
func (c cmdable) XInfoStreamFull(ctx context.Context, key string, count int) *XInfoStreamFullCmd {
args := make([]interface{}, 0, 6)
args = append(args, "xinfo", "stream", key, "full")
if count > 0 {
args = append(args, "count", count)
}
cmd := NewXInfoStreamFullCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// XCfgSetArgs represents the arguments for the XCFGSET command.
// Duration is the duration, in seconds, that Redis keeps each idempotent ID.
// MaxSize is the maximum number of most recent idempotent IDs that Redis keeps for each producer ID.
type XCfgSetArgs struct {
Stream string
Duration int64
MaxSize int64
}
// XCfgSet sets the idempotent production configuration for a stream.
// XCFGSET key [IDMP-DURATION duration] [IDMP-MAXSIZE maxsize]
func (c cmdable) XCfgSet(ctx context.Context, a *XCfgSetArgs) *StatusCmd {
args := make([]interface{}, 0, 6)
args = append(args, "xcfgset", a.Stream)
if a.Duration > 0 {
args = append(args, "idmp-duration", a.Duration)
}
if a.MaxSize > 0 {
args = append(args, "idmp-maxsize", a.MaxSize)
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
package redis
import (
"context"
"fmt"
"strings"
"time"
)
type StringCmdable interface {
Append(ctx context.Context, key, value string) *IntCmd
Decr(ctx context.Context, key string) *IntCmd
DecrBy(ctx context.Context, key string, decrement int64) *IntCmd
DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd
Digest(ctx context.Context, key string) *DigestCmd
Get(ctx context.Context, key string) *StringCmd
GetRange(ctx context.Context, key string, start, end int64) *StringCmd
GetSet(ctx context.Context, key string, value interface{}) *StringCmd
GetEx(ctx context.Context, key string, expiration time.Duration) *StringCmd
GetDel(ctx context.Context, key string) *StringCmd
GetToBuffer(ctx context.Context, key string, buf []byte) *ZeroCopyStringCmd
Incr(ctx context.Context, key string) *IntCmd
IncrBy(ctx context.Context, key string, value int64) *IntCmd
IncrByFloat(ctx context.Context, key string, value float64) *FloatCmd
IncrEXInt(ctx context.Context, key string, args IncrEXIntArgs) *IncrEXIntCmd
IncrEXFloat(ctx context.Context, key string, args IncrEXFloatArgs) *IncrEXFloatCmd
LCS(ctx context.Context, q *LCSQuery) *LCSCmd
MGet(ctx context.Context, keys ...string) *SliceCmd
MSet(ctx context.Context, values ...interface{}) *StatusCmd
MSetNX(ctx context.Context, values ...interface{}) *BoolCmd
MSetEX(ctx context.Context, args MSetEXArgs, values ...interface{}) *IntCmd
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd
SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd
SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd
SetFromBuffer(ctx context.Context, key string, buf []byte) *StatusCmd
SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd
SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd
SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd
SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd
SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd
SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd
SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd
SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd
SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd
SetXX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd
SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd
StrLen(ctx context.Context, key string) *IntCmd
}
func (c cmdable) Append(ctx context.Context, key, value string) *IntCmd {
cmd := NewIntCmd(ctx, "append", key, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Decr(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "decr", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) DecrBy(ctx context.Context, key string, decrement int64) *IntCmd {
cmd := NewIntCmd(ctx, "decrby", key, decrement)
_ = c(ctx, cmd)
return cmd
}
// DelExArgs provides arguments for the DelExArgs function.
type DelExArgs struct {
// Mode can be `IFEQ`, `IFNE`, `IFDEQ`, or `IFDNE`.
Mode string
// MatchValue is used with IFEQ/IFNE modes for compare-and-delete operations.
// - IFEQ: only delete if current value equals MatchValue
// - IFNE: only delete if current value does not equal MatchValue
MatchValue interface{}
// MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-delete.
// - IFDEQ: only delete if current value's digest equals MatchDigest
// - IFDNE: only delete if current value's digest does not equal MatchDigest
//
// The digest is a uint64 xxh3 hash value.
//
// For examples of client-side digest generation, see:
// example/digest-optimistic-locking/
MatchDigest uint64
}
// DelExArgs Redis `DELEX key [IFEQ|IFNE|IFDEQ|IFDNE] match-value` command.
// Compare-and-delete with flexible conditions.
//
// Returns the number of keys that were removed (0 or 1).
//
// NOTE DelExArgs is still experimental
// it's signature and behaviour may change
func (c cmdable) DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd {
args := []interface{}{"delex", key}
if a.Mode != "" {
args = append(args, a.Mode)
// Add match value/digest based on mode
switch a.Mode {
case "ifeq", "IFEQ", "ifne", "IFNE":
if a.MatchValue != nil {
args = append(args, a.MatchValue)
}
case "ifdeq", "IFDEQ", "ifdne", "IFDNE":
if a.MatchDigest != 0 {
args = append(args, fmt.Sprintf("%016x", a.MatchDigest))
}
}
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// Digest returns the xxh3 hash (uint64) of the specified key's value.
//
// The digest is a 64-bit xxh3 hash that can be used for optimistic locking
// with SetIFDEQ, SetIFDNE, and DelExArgs commands.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/digest/
//
// NOTE Digest is still experimental
// it's signature and behaviour may change
func (c cmdable) Digest(ctx context.Context, key string) *DigestCmd {
cmd := NewDigestCmd(ctx, "digest", key)
_ = c(ctx, cmd)
return cmd
}
// Get Redis `GET key` command. It returns redis.Nil error when key does not exist.
func (c cmdable) Get(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "get", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) GetRange(ctx context.Context, key string, start, end int64) *StringCmd {
cmd := NewStringCmd(ctx, "getrange", key, start, end)
_ = c(ctx, cmd)
return cmd
}
// GetSet returns the old value stored at key and sets it to the new value.
//
// Deprecated: Use SetArgs with Get option instead as of Redis 6.2.0.
func (c cmdable) GetSet(ctx context.Context, key string, value interface{}) *StringCmd {
cmd := NewStringCmd(ctx, "getset", key, value)
_ = c(ctx, cmd)
return cmd
}
// GetEx An expiration of zero removes the TTL associated with the key (i.e. GETEX key persist).
// Requires Redis >= 6.2.0.
func (c cmdable) GetEx(ctx context.Context, key string, expiration time.Duration) *StringCmd {
args := make([]interface{}, 0, 4)
args = append(args, "getex", key)
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == 0 {
args = append(args, "persist")
}
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// GetDel redis-server version >= 6.2.0.
func (c cmdable) GetDel(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "getdel", key)
_ = c(ctx, cmd)
return cmd
}
// GetToBuffer executes GET and reads the reply directly into buf, avoiding
// the intermediate string allocation that *StringCmd would produce. For values
// larger than the connection's read buffer, the payload is read straight from
// the socket into buf — effectively zero-copy on the receive path.
//
// The returned *ZeroCopyStringCmd reports the number of bytes read via Val(),
// the populated slice via Bytes() (which is buf[:Val()]), and any error
// (including redis.Nil when the key does not exist) via Err(). If buf is too
// small to hold the value, Err() returns a "buffer too small" error.
//
// Nothing is ever written past len(buf). When len(buf) >= value length + 2,
// the read takes a fast path that pulls the payload and the protocol's
// trailing CRLF in a single socket read, using the two bytes after the
// payload as scratch — size buffers with 2 spare bytes to opt in (see
// example/zerocopy-buffer).
//
// This command opts out of automatic retries because partial data from a
// failed attempt would already be sitting in the caller's buffer.
func (c cmdable) GetToBuffer(ctx context.Context, key string, buf []byte) *ZeroCopyStringCmd {
cmd := NewZeroCopyStringCmd(ctx, buf, "get", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) Incr(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "incr", key)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) IncrBy(ctx context.Context, key string, value int64) *IntCmd {
cmd := NewIntCmd(ctx, "incrby", key, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) IncrByFloat(ctx context.Context, key string, value float64) *FloatCmd {
cmd := NewFloatCmd(ctx, "incrbyfloat", key, value)
_ = c(ctx, cmd)
return cmd
}
// IncrEXIntArgs are the arguments to IncrEXInt (the BYINT variant of INCREX).
//
// If By is zero and HasBy is false, the server increments by 1.
// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is a
// valid bound. Expiration is shared with the SET command via ExpirationOption.
type IncrEXIntArgs struct {
By int64
HasBy bool
LBound, UBound int64
HasLBound, HasUBound bool
// Saturate clamps the result to LBOUND/UBOUND (or LLONG_MAX/MIN when no
// explicit bound is given) when the increment would exceed it. Without
// this flag, out-of-bounds operations are rejected: the key and TTL are
// left unchanged and the reply is [current_value, 0].
Saturate bool
// Expiration sets the TTL semantics: EX, PX, EXAT, PXAT, or PERSIST.
Expiration *ExpirationOption
// ENX applies the expiration only when the key does not already have an
// expiration. Requires Expiration to set one of EX/PX/EXAT/PXAT.
ENX bool
}
// IncrEXFloatArgs are the arguments to IncrEXFloat (the BYFLOAT variant of
// INCREX). BYFLOAT is always sent — even when By is zero — to keep the
// operation in float mode on the server side; omitting BYFLOAT would cause
// the server to treat the call as an integer increment by 1.
// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is
// a valid bound.
type IncrEXFloatArgs struct {
By float64
LBound, UBound float64
HasLBound, HasUBound bool
// Saturate clamps the result to LBOUND/UBOUND (or ±LDBL_MAX when no
// explicit bound is given) when the increment would exceed it. Without
// this flag, out-of-bounds operations are rejected: the key and TTL are
// left unchanged and the reply is [current_value, 0].
Saturate bool
Expiration *ExpirationOption
ENX bool
}
// IncrEXInt Redis `INCREX key [BYINT amount] [LBOUND value] [UBOUND value]
// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]`
// command.
//
// Atomically increments the integer value stored at key, optionally
// constraining the result to a range and applying expiration semantics.
// Returns the new value and the increment that was actually applied. When
// the increment would exceed LBOUND/UBOUND and SATURATE is not set, the key
// and TTL are left unchanged and the reply is [current_value, 0].
//
// Available since Redis 8.8.
// For more information, see https://redis.io/commands/increx
func (c cmdable) IncrEXInt(ctx context.Context, key string, a IncrEXIntArgs) *IncrEXIntCmd {
args := make([]interface{}, 0, 14)
args = append(args, "increx", key)
if a.HasBy {
args = append(args, "byint", a.By)
}
if a.HasLBound {
args = append(args, "lbound", a.LBound)
}
if a.HasUBound {
args = append(args, "ubound", a.UBound)
}
if a.Saturate {
args = append(args, "saturate")
}
args = appendIncrEXTail(args, a.Expiration, a.ENX)
cmd := NewIncrEXIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// IncrEXFloat Redis `INCREX key [BYFLOAT amount] [LBOUND value] [UBOUND value]
// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]`
// command.
//
// Available since Redis 8.8.
// For more information, see https://redis.io/commands/increx
func (c cmdable) IncrEXFloat(ctx context.Context, key string, a IncrEXFloatArgs) *IncrEXFloatCmd {
args := make([]interface{}, 0, 14)
args = append(args, "increx", key, "byfloat", a.By)
if a.HasLBound {
args = append(args, "lbound", a.LBound)
}
if a.HasUBound {
args = append(args, "ubound", a.UBound)
}
if a.Saturate {
args = append(args, "saturate")
}
args = appendIncrEXTail(args, a.Expiration, a.ENX)
cmd := NewIncrEXFloatCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func appendIncrEXTail(args []interface{}, exp *ExpirationOption, enx bool) []interface{} {
if exp != nil {
switch exp.Mode {
case EX, PX, EXAT, PXAT:
args = append(args, strings.ToLower(string(exp.Mode)), exp.Value)
case PERSIST:
args = append(args, "persist")
}
}
if enx {
args = append(args, "enx")
}
return args
}
type SetCondition string
const (
// NX only set the keys and their expiration if none exist
NX SetCondition = "NX"
// XX only set the keys and their expiration if all already exist
XX SetCondition = "XX"
)
type ExpirationMode string
const (
// EX sets expiration in seconds
EX ExpirationMode = "EX"
// PX sets expiration in milliseconds
PX ExpirationMode = "PX"
// EXAT sets expiration as Unix timestamp in seconds
EXAT ExpirationMode = "EXAT"
// PXAT sets expiration as Unix timestamp in milliseconds
PXAT ExpirationMode = "PXAT"
// KEEPTTL keeps the existing TTL
KEEPTTL ExpirationMode = "KEEPTTL"
// PERSIST removes the existing TTL. Used by INCREX.
PERSIST ExpirationMode = "PERSIST"
)
type ExpirationOption struct {
Mode ExpirationMode
Value int64
}
func (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd {
cmd := NewLCSCmd(ctx, q)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) MGet(ctx context.Context, keys ...string) *SliceCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "mget"
for i, key := range keys {
args[1+i] = key
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// MSet is like Set but accepts multiple values:
// - MSet("key1", "value1", "key2", "value2")
// - MSet([]string{"key1", "value1", "key2", "value2"})
// - MSet(map[string]interface{}{"key1": "value1", "key2": "value2"})
// - MSet(struct), For struct types, see HSet description.
func (c cmdable) MSet(ctx context.Context, values ...interface{}) *StatusCmd {
args := make([]interface{}, 1, 1+len(values))
args[0] = "mset"
args = appendArgs(args, values)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// MSetNX is like SetNX but accepts multiple values:
// - MSetNX("key1", "value1", "key2", "value2")
// - MSetNX([]string{"key1", "value1", "key2", "value2"})
// - MSetNX(map[string]interface{}{"key1": "value1", "key2": "value2"})
// - MSetNX(struct), For struct types, see HSet description.
func (c cmdable) MSetNX(ctx context.Context, values ...interface{}) *BoolCmd {
args := make([]interface{}, 1, 1+len(values))
args[0] = "msetnx"
args = appendArgs(args, values)
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type MSetEXArgs struct {
Condition SetCondition
Expiration *ExpirationOption
}
// MSetEX sets the given keys to their respective values.
// This command is an extension of the MSETNX that adds expiration and XX options.
// Available since Redis 8.4
// Important: When this method is used with Cluster clients, all keys
// must be in the same hash slot, otherwise CROSSSLOT error will be returned.
// For more information, see https://redis.io/commands/msetex
func (c cmdable) MSetEX(ctx context.Context, args MSetEXArgs, values ...interface{}) *IntCmd {
expandedArgs := appendArgs([]interface{}{}, values)
numkeys := len(expandedArgs) / 2
cmdArgs := make([]interface{}, 0, 2+len(expandedArgs)+3)
cmdArgs = append(cmdArgs, "msetex", numkeys)
cmdArgs = append(cmdArgs, expandedArgs...)
if args.Condition != "" {
cmdArgs = append(cmdArgs, string(args.Condition))
}
if args.Expiration != nil {
switch args.Expiration.Mode {
case EX:
cmdArgs = append(cmdArgs, "ex", args.Expiration.Value)
case PX:
cmdArgs = append(cmdArgs, "px", args.Expiration.Value)
case EXAT:
cmdArgs = append(cmdArgs, "exat", args.Expiration.Value)
case PXAT:
cmdArgs = append(cmdArgs, "pxat", args.Expiration.Value)
case KEEPTTL:
cmdArgs = append(cmdArgs, "keepttl")
}
}
cmd := NewIntCmd(ctx, cmdArgs...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// Set Redis `SET key value [expiration]` command.
// Use expiration for `SETEx`-like behavior.
//
// Zero expiration means the key has no expiration time.
// KeepTTL is a Redis KEEPTTL option to keep existing TTL, it requires your redis-server version >= 6.0,
// otherwise you will receive an error: (error) ERR syntax error.
func (c cmdable) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd {
args := make([]interface{}, 3, 5)
args[0] = "set"
args[1] = key
args[2] = value
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetArgs provides arguments for the SetArgs function.
type SetArgs struct {
// Mode can be `NX`, `XX`, `IFEQ`, `IFNE`, `IFDEQ`, `IFDNE` or empty.
Mode string
// MatchValue is used with IFEQ/IFNE modes for compare-and-set operations.
// - IFEQ: only set if current value equals MatchValue
// - IFNE: only set if current value does not equal MatchValue
MatchValue interface{}
// MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-set.
// - IFDEQ: only set if current value's digest equals MatchDigest
// - IFDNE: only set if current value's digest does not equal MatchDigest
//
// The digest is a uint64 xxh3 hash value.
//
// For examples of client-side digest generation, see:
// example/digest-optimistic-locking/
MatchDigest uint64
// Zero `TTL` or `Expiration` means that the key has no expiration time.
TTL time.Duration
ExpireAt time.Time
// When Get is true, the command returns the old value stored at key, or nil when key did not exist.
Get bool
// 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.
KeepTTL bool
}
// SetArgs supports all the options that the SET command supports.
// It is the alternative to the Set function when you want
// to have more control over the options.
func (c cmdable) SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd {
args := []interface{}{"set", key, value}
if a.KeepTTL {
args = append(args, "keepttl")
}
if !a.ExpireAt.IsZero() {
args = append(args, "exat", a.ExpireAt.Unix())
}
if a.TTL > 0 {
if usePrecise(a.TTL) {
args = append(args, "px", formatMs(ctx, a.TTL))
} else {
args = append(args, "ex", formatSec(ctx, a.TTL))
}
}
if a.Mode != "" {
args = append(args, a.Mode)
// Add match value/digest for CAS modes
switch a.Mode {
case "ifeq", "IFEQ", "ifne", "IFNE":
if a.MatchValue != nil {
args = append(args, a.MatchValue)
}
case "ifdeq", "IFDEQ", "ifdne", "IFDNE":
if a.MatchDigest != 0 {
args = append(args, fmt.Sprintf("%016x", a.MatchDigest))
}
}
}
if a.Get {
args = append(args, "get")
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetEx sets the value and expiration of a key.
//
// Deprecated: Use Set with expiration instead as of Redis 2.6.12.
func (c cmdable) SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd {
cmd := NewStatusCmd(ctx, "setex", key, formatSec(ctx, expiration), value)
_ = c(ctx, cmd)
return cmd
}
// SetFromBuffer executes SET writing the value directly from buf. For values
// larger than the connection's write buffer, bufio.Writer.Write flushes its
// internal buffer (containing the RESP header) and then writes buf straight
// to the socket — effectively zero-copy on the send path.
//
// Expiration is not supported; use Expire separately if a TTL is required.
//
// Note: SetFromBuffer is exposed for API symmetry with GetToBuffer and is
// functionally equivalent to Set(ctx, key, buf, 0) — both dispatch to the
// same []byte case in the RESP writer and produce identical bytes on the
// wire. The zero-copy property on the send path comes from
// bufio.Writer.Write bypassing its internal buffer for large payloads,
// which Set([]byte) gets automatically. Prefer SetFromBuffer in code that
// also uses GetToBuffer so the buffer-based pattern reads coherently;
// otherwise Set(ctx, key, buf, 0) is equally efficient.
func (c cmdable) SetFromBuffer(ctx context.Context, key string, buf []byte) *StatusCmd {
cmd := NewStatusCmd(ctx, "set", key, buf)
_ = c(ctx, cmd)
return cmd
}
// SetNX sets the value of a key only if the key does not exist.
//
// Zero expiration means the key has no expiration time.
// 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.
func (c cmdable) SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd {
var cmd *BoolCmd
switch expiration {
case 0:
cmd = NewBoolCmd(ctx, "set", key, value, "nx")
case KeepTTL:
cmd = NewBoolCmd(ctx, "set", key, value, "keepttl", "nx")
default:
if usePrecise(expiration) {
cmd = NewBoolCmd(ctx, "set", key, value, "px", formatMs(ctx, expiration), "nx")
} else {
cmd = NewBoolCmd(ctx, "set", key, value, "ex", formatSec(ctx, expiration), "nx")
}
}
_ = c(ctx, cmd)
return cmd
}
// SetXX Redis `SET key value [expiration] XX` command.
//
// Zero expiration means the key has no expiration time.
// KeepTTL is a Redis KEEPTTL option to keep existing TTL, it requires your redis-server version >= 6.0,
// otherwise you will receive an error: (error) ERR syntax error.
func (c cmdable) SetXX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd {
var cmd *BoolCmd
switch expiration {
case 0:
cmd = NewBoolCmd(ctx, "set", key, value, "xx")
case KeepTTL:
cmd = NewBoolCmd(ctx, "set", key, value, "keepttl", "xx")
default:
if usePrecise(expiration) {
cmd = NewBoolCmd(ctx, "set", key, value, "px", formatMs(ctx, expiration), "xx")
} else {
cmd = NewBoolCmd(ctx, "set", key, value, "ex", formatSec(ctx, expiration), "xx")
}
}
_ = c(ctx, cmd)
return cmd
}
// SetIFEQ Redis `SET key value [expiration] IFEQ match-value` command.
// Compare-and-set: only sets the value if the current value equals matchValue.
//
// Returns "OK" on success.
// Returns nil if the operation was aborted due to condition not matching.
// Zero expiration means the key has no expiration time.
//
// NOTE SetIFEQ is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifeq", matchValue)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFEQGet Redis `SET key value [expiration] IFEQ match-value GET` command.
// Compare-and-set with GET: only sets the value if the current value equals matchValue,
// and returns the previous value.
//
// Returns the previous value on success.
// Returns nil if the operation was aborted due to condition not matching.
// Zero expiration means the key has no expiration time.
//
// NOTE SetIFEQGet is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifeq", matchValue, "get")
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFNE Redis `SET key value [expiration] IFNE match-value` command.
// Compare-and-set: only sets the value if the current value does not equal matchValue.
//
// Returns "OK" on success.
// Returns nil if the operation was aborted due to condition not matching.
// Zero expiration means the key has no expiration time.
//
// NOTE SetIFNE is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifne", matchValue)
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFNEGet Redis `SET key value [expiration] IFNE match-value GET` command.
// Compare-and-set with GET: only sets the value if the current value does not equal matchValue,
// and returns the previous value.
//
// Returns the previous value on success.
// Returns nil if the operation was aborted due to condition not matching.
// Zero expiration means the key has no expiration time.
//
// NOTE SetIFNEGet is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifne", matchValue, "get")
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFDEQ sets the value only if the current value's digest equals matchDigest.
//
// This is a compare-and-set operation using xxh3 digest for optimistic locking.
// The matchDigest parameter is a uint64 xxh3 hash value.
//
// Returns "OK" on success.
// Returns redis.Nil if the digest doesn't match (value was modified).
// Zero expiration means the key has no expiration time.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/set/
//
// NOTE SetIFNEQ is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifdeq", fmt.Sprintf("%016x", matchDigest))
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFDEQGet sets the value only if the current value's digest equals matchDigest,
// and returns the previous value.
//
// This is a compare-and-set operation using xxh3 digest for optimistic locking.
// The matchDigest parameter is a uint64 xxh3 hash value.
//
// Returns the previous value on success.
// Returns redis.Nil if the digest doesn't match (value was modified).
// Zero expiration means the key has no expiration time.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/set/
//
// NOTE SetIFNEQGet is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifdeq", fmt.Sprintf("%016x", matchDigest), "get")
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFDNE sets the value only if the current value's digest does NOT equal matchDigest.
//
// This is a compare-and-set operation using xxh3 digest for optimistic locking.
// The matchDigest parameter is a uint64 xxh3 hash value.
//
// Returns "OK" on success (digest didn't match, value was set).
// Returns redis.Nil if the digest matches (value was not modified).
// Zero expiration means the key has no expiration time.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/set/
//
// NOTE SetIFDNE is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifdne", fmt.Sprintf("%016x", matchDigest))
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// SetIFDNEGet sets the value only if the current value's digest does NOT equal matchDigest,
// and returns the previous value.
//
// This is a compare-and-set operation using xxh3 digest for optimistic locking.
// The matchDigest parameter is a uint64 xxh3 hash value.
//
// Returns the previous value on success (digest didn't match, value was set).
// Returns redis.Nil if the digest matches (value was not modified).
// Zero expiration means the key has no expiration time.
//
// For examples of client-side digest generation and usage patterns, see:
// example/digest-optimistic-locking/
//
// Redis 8.4+. See https://redis.io/commands/set/
//
// NOTE SetIFDNEGet is still experimental
// it's signature and behaviour may change
func (c cmdable) SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd {
args := []interface{}{"set", key, value}
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(ctx, expiration))
} else {
args = append(args, "ex", formatSec(ctx, expiration))
}
} else if expiration == KeepTTL {
args = append(args, "keepttl")
}
args = append(args, "ifdne", fmt.Sprintf("%016x", matchDigest), "get")
cmd := NewStringCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd {
cmd := NewIntCmd(ctx, "setrange", key, offset, value)
_ = c(ctx, cmd)
return cmd
}
func (c cmdable) StrLen(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "strlen", key)
_ = c(ctx, cmd)
return cmd
}
package redis
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/internal/util"
)
type TimeseriesCmdable interface {
TSAdd(ctx context.Context, key string, timestamp interface{}, value float64) *IntCmd
TSAddWithArgs(ctx context.Context, key string, timestamp interface{}, value float64, options *TSOptions) *IntCmd
TSCreate(ctx context.Context, key string) *StatusCmd
TSCreateWithArgs(ctx context.Context, key string, options *TSOptions) *StatusCmd
TSAlter(ctx context.Context, key string, options *TSAlterOptions) *StatusCmd
TSCreateRule(ctx context.Context, sourceKey string, destKey string, aggregator Aggregator, bucketDuration int) *StatusCmd
TSCreateRuleWithArgs(ctx context.Context, sourceKey string, destKey string, aggregator Aggregator, bucketDuration int, options *TSCreateRuleOptions) *StatusCmd
TSIncrBy(ctx context.Context, Key string, timestamp float64) *IntCmd
TSIncrByWithArgs(ctx context.Context, key string, timestamp float64, options *TSIncrDecrOptions) *IntCmd
TSDecrBy(ctx context.Context, Key string, timestamp float64) *IntCmd
TSDecrByWithArgs(ctx context.Context, key string, timestamp float64, options *TSIncrDecrOptions) *IntCmd
TSDel(ctx context.Context, Key string, fromTimestamp int, toTimestamp int) *IntCmd
TSDeleteRule(ctx context.Context, sourceKey string, destKey string) *StatusCmd
TSGet(ctx context.Context, key string) *TSTimestampValueCmd
TSGetWithArgs(ctx context.Context, key string, options *TSGetOptions) *TSTimestampValueCmd
TSInfo(ctx context.Context, key string) *MapStringInterfaceCmd
TSInfoWithArgs(ctx context.Context, key string, options *TSInfoOptions) *MapStringInterfaceCmd
TSMAdd(ctx context.Context, ktvSlices [][]interface{}) *IntSliceCmd
TSQueryIndex(ctx context.Context, filterExpr []string) *StringSliceCmd
TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd
TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd
TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
TSRevRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRevRangeOptions) *TSTimestampValueSliceCmd
TSRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
TSRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRangeOptions) *TSTimestampValueSliceCmd
TSMRange(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string) *MapStringSliceInterfaceCmd
TSMRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRangeOptions) *MapStringSliceInterfaceCmd
TSMRevRange(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string) *MapStringSliceInterfaceCmd
TSMRevRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRevRangeOptions) *MapStringSliceInterfaceCmd
TSMGet(ctx context.Context, filters []string) *MapStringSliceInterfaceCmd
TSMGetWithArgs(ctx context.Context, filters []string, options *TSMGetOptions) *MapStringSliceInterfaceCmd
TSNRange(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}) *TSNRangePivotRowSliceCmd
TSNRangeWithArgs(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}, options *TSNRangeOptions) *TSNRangePivotRowSliceCmd
TSNRevRange(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}) *TSNRangePivotRowSliceCmd
TSNRevRangeWithArgs(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}, options *TSNRevRangeOptions) *TSNRangePivotRowSliceCmd
TSRead(ctx context.Context, key string, timestamp interface{}) *TSTimestampValueSliceCmd
TSReadWithArgs(ctx context.Context, key string, timestamp interface{}, options *TSReadOptions) *TSTimestampValueSliceCmd
}
// TS.READ timestamp cursor sentinels.
const (
TSReadEarliest = "-" // read from the earliest sample
TSReadLatest = "+" // latest sample, inclusive
TSReadNew = "$" // only samples added after the call
)
// TSReadOptions holds the optional TS.READ arguments.
type TSReadOptions struct {
Block bool // wait for samples (emits the BLOCK group)
Timeout time.Duration // max wait; 0 blocks indefinitely
MinCount int // unblock threshold; defaults to 1
MaxCount int // reply cap; 0 is unlimited
}
type TSOptions struct {
Retention int
ChunkSize int
Encoding string
DuplicatePolicy string
Labels map[string]string
IgnoreMaxTimeDiff int64
IgnoreMaxValDiff float64
}
type TSIncrDecrOptions struct {
Timestamp int64
Retention int
ChunkSize int
Uncompressed bool
DuplicatePolicy string
Labels map[string]string
IgnoreMaxTimeDiff int64
IgnoreMaxValDiff float64
}
type TSAlterOptions struct {
Retention int
ChunkSize int
DuplicatePolicy string
Labels map[string]string
IgnoreMaxTimeDiff int64
IgnoreMaxValDiff float64
}
type TSCreateRuleOptions struct {
alignTimestamp int64
}
type TSGetOptions struct {
Latest bool
}
type TSInfoOptions struct {
Debug bool
}
type Aggregator int
const (
Invalid = Aggregator(iota)
Avg
Sum
Min
Max
Range
Count
First
Last
StdP
StdS
VarP
VarS
Twa
CountNaN
CountAll
)
func (a Aggregator) String() string {
switch a {
case Invalid:
return ""
case Avg:
return "AVG"
case Sum:
return "SUM"
case Min:
return "MIN"
case Max:
return "MAX"
case Range:
return "RANGE"
case Count:
return "COUNT"
case First:
return "FIRST"
case Last:
return "LAST"
case StdP:
return "STD.P"
case StdS:
return "STD.S"
case VarP:
return "VAR.P"
case VarS:
return "VAR.S"
case Twa:
return "TWA"
case CountNaN:
return "COUNTNAN"
case CountAll:
return "COUNTALL"
default:
return ""
}
}
var (
errTSMultiAggregationGroupBy = errors.New("redis: GROUPBY is not allowed when multiple aggregators are specified")
errTSAggregationConflict = errors.New("redis: setting both Aggregator and Aggregators is not allowed; use Aggregators instead because Aggregator is deprecated")
errTSExcludeEmptyGroupBy = errors.New("redis: EXCLUDEEMPTY is not allowed with GROUPBY")
)
func formatAggregationArgs(aggregator Aggregator, aggregators []Aggregator) (string, int, error) {
if aggregator != Invalid && len(aggregators) > 0 {
return "", 0, errTSAggregationConflict
}
if len(aggregators) == 0 {
if aggregator == Invalid {
return "", 0, nil
}
aggregationArg, err := formatAggregatorArg(aggregator)
if err != nil {
return "", 0, err
}
return aggregationArg, 1, nil
}
parts := make([]string, len(aggregators))
for i, agg := range aggregators {
if agg == Invalid {
return "", 0, fmt.Errorf("redis: invalid timeseries aggregator at index %d: Invalid (%d)", i, agg)
}
aggregationArg, err := formatAggregatorArg(agg)
if err != nil {
return "", 0, fmt.Errorf("redis: invalid timeseries aggregator at index %d: %d", i, agg)
}
parts[i] = aggregationArg
}
return strings.Join(parts, ","), len(parts), nil
}
func formatAggregatorArg(aggregator Aggregator) (string, error) {
aggregationArg := aggregator.String()
if aggregationArg == "" {
return "", fmt.Errorf("redis: invalid timeseries aggregator: %d", aggregator)
}
return aggregationArg, nil
}
type TSRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []int
Count int
Align interface{}
// Deprecated: use Aggregators instead.
Aggregator Aggregator
Aggregators []Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
}
type TSRevRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []int
Count int
Align interface{}
// Deprecated: use Aggregators instead.
Aggregator Aggregator
Aggregators []Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
}
type TSMRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []int
WithLabels bool
SelectedLabels []interface{}
Count int
Align interface{}
// Deprecated: use Aggregators instead.
Aggregator Aggregator
Aggregators []Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
// ExcludeEmpty omits matching series that have no samples. Not allowed with GroupByLabel/Reducer. Redis 8.10+.
ExcludeEmpty bool
GroupByLabel interface{}
Reducer interface{}
}
type TSMRevRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []int
WithLabels bool
SelectedLabels []interface{}
Count int
Align interface{}
// Deprecated: use Aggregators instead.
Aggregator Aggregator
Aggregators []Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
// ExcludeEmpty omits matching series that have no samples. Not allowed with GroupByLabel/Reducer. Redis 8.10+.
ExcludeEmpty bool
GroupByLabel interface{}
Reducer interface{}
}
type TSMGetOptions struct {
Latest bool
WithLabels bool
SelectedLabels []interface{}
}
type TSNRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []float64 // exactly two elements: [min, max]
Count int
Align interface{}
// Aggregators holds exactly one aggregator spec per key. Each spec lists one or
// more aggregators applied to that key and is sent as a single comma-joined token
// (e.g. {{Min, Max}, {Sum}} -> AGGREGATION MIN,MAX SUM <bucketDuration>).
Aggregators [][]Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
}
type TSNRevRangeOptions struct {
Latest bool
FilterByTS []int
FilterByValue []float64 // exactly two elements: [min, max]
Count int
Align interface{}
// Aggregators holds exactly one aggregator spec per key. Each spec lists one or
// more aggregators applied to that key and is sent as a single comma-joined token
// (e.g. {{Min, Max}, {Sum}} -> AGGREGATION MIN,MAX SUM <bucketDuration>).
Aggregators [][]Aggregator
BucketDuration int
BucketTimestamp interface{}
Empty bool
}
// TSAdd - Adds one or more observations to a t-digest sketch.
// For more information - https://redis.io/commands/ts.add/
func (c cmdable) TSAdd(ctx context.Context, key string, timestamp interface{}, value float64) *IntCmd {
args := []interface{}{"TS.ADD", key, timestamp, value}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSAddWithArgs - Adds one or more observations to a t-digest sketch.
// This function also allows for specifying additional options such as:
// Retention, ChunkSize, Encoding, DuplicatePolicy and Labels.
// For more information - https://redis.io/commands/ts.add/
func (c cmdable) TSAddWithArgs(ctx context.Context, key string, timestamp interface{}, value float64, options *TSOptions) *IntCmd {
args := []interface{}{"TS.ADD", key, timestamp, value}
if options != nil {
if options.Retention != 0 {
args = append(args, "RETENTION", options.Retention)
}
if options.ChunkSize != 0 {
args = append(args, "CHUNK_SIZE", options.ChunkSize)
}
if options.Encoding != "" {
args = append(args, "ENCODING", options.Encoding)
}
if options.DuplicatePolicy != "" {
args = append(args, "DUPLICATE_POLICY", options.DuplicatePolicy)
}
if options.Labels != nil {
args = append(args, "LABELS")
for label, value := range options.Labels {
args = append(args, label, value)
}
}
if options.IgnoreMaxTimeDiff != 0 || options.IgnoreMaxValDiff != 0 {
args = append(args, "IGNORE", options.IgnoreMaxTimeDiff, options.IgnoreMaxValDiff)
}
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSCreate - Creates a new time-series key.
// For more information - https://redis.io/commands/ts.create/
func (c cmdable) TSCreate(ctx context.Context, key string) *StatusCmd {
args := []interface{}{"TS.CREATE", key}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSCreateWithArgs - Creates a new time-series key with additional options.
// This function allows for specifying additional options such as:
// Retention, ChunkSize, Encoding, DuplicatePolicy and Labels.
// For more information - https://redis.io/commands/ts.create/
func (c cmdable) TSCreateWithArgs(ctx context.Context, key string, options *TSOptions) *StatusCmd {
args := []interface{}{"TS.CREATE", key}
if options != nil {
if options.Retention != 0 {
args = append(args, "RETENTION", options.Retention)
}
if options.ChunkSize != 0 {
args = append(args, "CHUNK_SIZE", options.ChunkSize)
}
if options.Encoding != "" {
args = append(args, "ENCODING", options.Encoding)
}
if options.DuplicatePolicy != "" {
args = append(args, "DUPLICATE_POLICY", options.DuplicatePolicy)
}
if options.Labels != nil {
args = append(args, "LABELS")
for label, value := range options.Labels {
args = append(args, label, value)
}
}
if options.IgnoreMaxTimeDiff != 0 || options.IgnoreMaxValDiff != 0 {
args = append(args, "IGNORE", options.IgnoreMaxTimeDiff, options.IgnoreMaxValDiff)
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSAlter - Alters an existing time-series key with additional options.
// This function allows for specifying additional options such as:
// Retention, ChunkSize and DuplicatePolicy.
// For more information - https://redis.io/commands/ts.alter/
func (c cmdable) TSAlter(ctx context.Context, key string, options *TSAlterOptions) *StatusCmd {
args := []interface{}{"TS.ALTER", key}
if options != nil {
if options.Retention != 0 {
args = append(args, "RETENTION", options.Retention)
}
if options.ChunkSize != 0 {
args = append(args, "CHUNK_SIZE", options.ChunkSize)
}
if options.DuplicatePolicy != "" {
args = append(args, "DUPLICATE_POLICY", options.DuplicatePolicy)
}
if options.Labels != nil {
args = append(args, "LABELS")
for label, value := range options.Labels {
args = append(args, label, value)
}
}
if options.IgnoreMaxTimeDiff != 0 || options.IgnoreMaxValDiff != 0 {
args = append(args, "IGNORE", options.IgnoreMaxTimeDiff, options.IgnoreMaxValDiff)
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSCreateRule - Creates a compaction rule from sourceKey to destKey.
// For more information - https://redis.io/commands/ts.createrule/
func (c cmdable) TSCreateRule(ctx context.Context, sourceKey string, destKey string, aggregator Aggregator, bucketDuration int) *StatusCmd {
args := []interface{}{"TS.CREATERULE", sourceKey, destKey, "AGGREGATION", aggregator.String(), bucketDuration}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSCreateRuleWithArgs - Creates a compaction rule from sourceKey to destKey with additional option.
// This function allows for specifying additional option such as:
// alignTimestamp.
// For more information - https://redis.io/commands/ts.createrule/
func (c cmdable) TSCreateRuleWithArgs(ctx context.Context, sourceKey string, destKey string, aggregator Aggregator, bucketDuration int, options *TSCreateRuleOptions) *StatusCmd {
args := []interface{}{"TS.CREATERULE", sourceKey, destKey, "AGGREGATION", aggregator.String(), bucketDuration}
if options != nil {
if options.alignTimestamp != 0 {
args = append(args, options.alignTimestamp)
}
}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSIncrBy - Increments the value of a time-series key by the specified timestamp.
// For more information - https://redis.io/commands/ts.incrby/
func (c cmdable) TSIncrBy(ctx context.Context, Key string, timestamp float64) *IntCmd {
args := []interface{}{"TS.INCRBY", Key, timestamp}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSIncrByWithArgs - Increments the value of a time-series key by the specified timestamp with additional options.
// This function allows for specifying additional options such as:
// Timestamp, Retention, ChunkSize, Uncompressed and Labels.
// For more information - https://redis.io/commands/ts.incrby/
func (c cmdable) TSIncrByWithArgs(ctx context.Context, key string, timestamp float64, options *TSIncrDecrOptions) *IntCmd {
args := []interface{}{"TS.INCRBY", key, timestamp}
if options != nil {
if options.Timestamp != 0 {
args = append(args, "TIMESTAMP", options.Timestamp)
}
if options.Retention != 0 {
args = append(args, "RETENTION", options.Retention)
}
if options.ChunkSize != 0 {
args = append(args, "CHUNK_SIZE", options.ChunkSize)
}
if options.Uncompressed {
args = append(args, "UNCOMPRESSED")
}
if options.DuplicatePolicy != "" {
args = append(args, "DUPLICATE_POLICY", options.DuplicatePolicy)
}
if options.Labels != nil {
args = append(args, "LABELS")
for label, value := range options.Labels {
args = append(args, label, value)
}
}
if options.IgnoreMaxTimeDiff != 0 || options.IgnoreMaxValDiff != 0 {
args = append(args, "IGNORE", options.IgnoreMaxTimeDiff, options.IgnoreMaxValDiff)
}
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSDecrBy - Decrements the value of a time-series key by the specified timestamp.
// For more information - https://redis.io/commands/ts.decrby/
func (c cmdable) TSDecrBy(ctx context.Context, Key string, timestamp float64) *IntCmd {
args := []interface{}{"TS.DECRBY", Key, timestamp}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSDecrByWithArgs - Decrements the value of a time-series key by the specified timestamp with additional options.
// This function allows for specifying additional options such as:
// Timestamp, Retention, ChunkSize, Uncompressed and Labels.
// For more information - https://redis.io/commands/ts.decrby/
func (c cmdable) TSDecrByWithArgs(ctx context.Context, key string, timestamp float64, options *TSIncrDecrOptions) *IntCmd {
args := []interface{}{"TS.DECRBY", key, timestamp}
if options != nil {
if options.Timestamp != 0 {
args = append(args, "TIMESTAMP", options.Timestamp)
}
if options.Retention != 0 {
args = append(args, "RETENTION", options.Retention)
}
if options.ChunkSize != 0 {
args = append(args, "CHUNK_SIZE", options.ChunkSize)
}
if options.Uncompressed {
args = append(args, "UNCOMPRESSED")
}
if options.DuplicatePolicy != "" {
args = append(args, "DUPLICATE_POLICY", options.DuplicatePolicy)
}
if options.Labels != nil {
args = append(args, "LABELS")
for label, value := range options.Labels {
args = append(args, label, value)
}
}
if options.IgnoreMaxTimeDiff != 0 || options.IgnoreMaxValDiff != 0 {
args = append(args, "IGNORE", options.IgnoreMaxTimeDiff, options.IgnoreMaxValDiff)
}
}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSDel - Deletes a range of samples from a time-series key.
// For more information - https://redis.io/commands/ts.del/
func (c cmdable) TSDel(ctx context.Context, Key string, fromTimestamp int, toTimestamp int) *IntCmd {
args := []interface{}{"TS.DEL", Key, fromTimestamp, toTimestamp}
cmd := NewIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSDeleteRule - Deletes a compaction rule from sourceKey to destKey.
// For more information - https://redis.io/commands/ts.deleterule/
func (c cmdable) TSDeleteRule(ctx context.Context, sourceKey string, destKey string) *StatusCmd {
args := []interface{}{"TS.DELETERULE", sourceKey, destKey}
cmd := NewStatusCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSGetWithArgs - Gets the last sample of a time-series key with additional option.
// This function allows for specifying additional option such as:
// Latest.
// For more information - https://redis.io/commands/ts.get/
func (c cmdable) TSGetWithArgs(ctx context.Context, key string, options *TSGetOptions) *TSTimestampValueCmd {
args := []interface{}{"TS.GET", key}
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
}
cmd := newTSTimestampValueCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSGet - Gets the last sample of a time-series key.
// For more information - https://redis.io/commands/ts.get/
func (c cmdable) TSGet(ctx context.Context, key string) *TSTimestampValueCmd {
args := []interface{}{"TS.GET", key}
cmd := newTSTimestampValueCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
type TSTimestampValue struct {
Timestamp int64
Value float64
Values []float64
}
func (tv TSTimestampValue) String() string {
if len(tv.Values) > 0 {
return fmt.Sprintf("{%d %v}", tv.Timestamp, tv.Values)
}
return fmt.Sprintf("{%d %v}", tv.Timestamp, tv.Value)
}
type TSTimestampValueCmd struct {
baseCmd
val TSTimestampValue
}
func newTSTimestampValueCmd(ctx context.Context, args ...interface{}) *TSTimestampValueCmd {
return &TSTimestampValueCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTSTimestampValue,
},
}
}
func (cmd *TSTimestampValueCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TSTimestampValueCmd) SetVal(val TSTimestampValue) {
cmd.val = val
}
func (cmd *TSTimestampValueCmd) Result() (TSTimestampValue, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TSTimestampValueCmd) Val() TSTimestampValue {
cmd.await()
return cmd.val
}
func (cmd *TSTimestampValueCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadMapLen()
if err != nil {
return err
}
cmd.val = TSTimestampValue{}
for i := 0; i < n; i++ {
timestamp, err := rd.ReadInt()
if err != nil {
return err
}
value, err := rd.ReadString()
if err != nil {
return err
}
cmd.val.Timestamp = timestamp
cmd.val.Value, err = util.ParseStringToFloat(value)
if err != nil {
return err
}
}
return nil
}
func (cmd *TSTimestampValueCmd) Clone() Cmder {
val := cmd.val
if cmd.val.Values != nil {
val.Values = make([]float64, len(cmd.val.Values))
copy(val.Values, cmd.val.Values)
}
return &TSTimestampValueCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// TSInfo - Returns information about a time-series key.
// For more information - https://redis.io/commands/ts.info/
func (c cmdable) TSInfo(ctx context.Context, key string) *MapStringInterfaceCmd {
args := []interface{}{"TS.INFO", key}
cmd := NewMapStringInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSInfoWithArgs - Returns information about a time-series key with additional option.
// This function allows for specifying additional option such as:
// Debug.
// For more information - https://redis.io/commands/ts.info/
func (c cmdable) TSInfoWithArgs(ctx context.Context, key string, options *TSInfoOptions) *MapStringInterfaceCmd {
args := []interface{}{"TS.INFO", key}
if options != nil {
if options.Debug {
args = append(args, "DEBUG")
}
}
cmd := NewMapStringInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMAdd - Adds multiple samples to multiple time-series keys.
// It accepts a slice of 'ktv' slices, each containing exactly three elements: key, timestamp, and value.
// This struct must be provided for this command to work.
// For more information - https://redis.io/commands/ts.madd/
func (c cmdable) TSMAdd(ctx context.Context, ktvSlices [][]interface{}) *IntSliceCmd {
args := []interface{}{"TS.MADD"}
for _, ktv := range ktvSlices {
args = append(args, ktv...)
}
cmd := NewIntSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSQueryIndex - Returns all the keys matching the filter expression.
// For more information - https://redis.io/commands/ts.queryindex/
func (c cmdable) TSQueryIndex(ctx context.Context, filterExpr []string) *StringSliceCmd {
args := []interface{}{"TS.QUERYINDEX"}
for _, f := range filterExpr {
args = append(args, f)
}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSQueryLabels - Returns the set of label names present on the time series
// matching the filter expressions. Passing no filter expressions queries all
// indexed series. The reply is unordered and already deduplicated by the
// server; it includes the label names used in the filter itself, and an
// empty reply is a valid result, not an error.
// filterExpr uses the same filter language as TSQueryIndex and is passed to
// the server verbatim. Available since Redis 8.10.
// For more information - https://redis.io/commands/ts.querylabels/
func (c cmdable) TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd {
args := []interface{}{"TS.QUERYLABELS", "LABELS"}
args = appendTSFilter(args, filterExpr)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSQueryLabelValues - Returns the set of values assigned to the given label
// name across the time series matching the filter expressions. Passing no
// filter expressions queries all indexed series. The label name is matched
// byte-exactly; a label present on no matching series yields an empty reply,
// not an error. The reply is unordered and already deduplicated by the
// server.
// filterExpr uses the same filter language as TSQueryIndex and is passed to
// the server verbatim. Available since Redis 8.10.
// For more information - https://redis.io/commands/ts.querylabels/
func (c cmdable) TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd {
args := []interface{}{"TS.QUERYLABELS", "VALUES", label}
args = appendTSFilter(args, filterExpr)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// appendTSFilter appends the FILTER token followed by the filter expressions,
// or nothing when no expressions are given: the server rejects a bare FILTER
// token, and omitting it is the documented way to query all indexed series.
func appendTSFilter(args []interface{}, filterExpr []string) []interface{} {
if len(filterExpr) == 0 {
return args
}
args = append(args, "FILTER")
for _, f := range filterExpr {
args = append(args, f)
}
return args
}
// TSRevRange - Returns a range of samples from a time-series key in reverse order.
// For more information - https://redis.io/commands/ts.revrange/
func (c cmdable) TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.REVRANGE", key, fromTimestamp, toTimestamp}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSRevRangeWithArgs - Returns a range of samples from a time-series key in reverse order with additional options.
// This function allows for specifying additional options such as:
// Latest, FilterByTS, FilterByValue, Count, Align, Aggregator,
// BucketDuration, BucketTimestamp and Empty.
// For more information - https://redis.io/commands/ts.revrange/
func (c cmdable) TSRevRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRevRangeOptions) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.REVRANGE", key, fromTimestamp, toTimestamp}
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
if options.FilterByTS != nil {
args = append(args, "FILTER_BY_TS")
for _, f := range options.FilterByTS {
args = append(args, f)
}
}
if options.FilterByValue != nil {
args = append(args, "FILTER_BY_VALUE")
for _, f := range options.FilterByValue {
args = append(args, f)
}
}
if options.Count != 0 {
args = append(args, "COUNT", options.Count)
}
if options.Align != nil {
args = append(args, "ALIGN", options.Align)
}
aggregationArg, _, err := formatAggregationArgs(options.Aggregator, options.Aggregators)
if err != nil {
cmd := newTSTimestampValueSliceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
if aggregationArg != "" {
args = append(args, "AGGREGATION", aggregationArg)
}
if options.BucketDuration != 0 {
args = append(args, options.BucketDuration)
}
if options.BucketTimestamp != nil {
args = append(args, "BUCKETTIMESTAMP", options.BucketTimestamp)
}
if options.Empty {
args = append(args, "EMPTY")
}
}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSRange - Returns a range of samples from a time-series key.
// For more information - https://redis.io/commands/ts.range/
func (c cmdable) TSRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.RANGE", key, fromTimestamp, toTimestamp}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSRangeWithArgs - Returns a range of samples from a time-series key with additional options.
// This function allows for specifying additional options such as:
// Latest, FilterByTS, FilterByValue, Count, Align, Aggregator,
// BucketDuration, BucketTimestamp and Empty.
// For more information - https://redis.io/commands/ts.range/
func (c cmdable) TSRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRangeOptions) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.RANGE", key, fromTimestamp, toTimestamp}
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
if options.FilterByTS != nil {
args = append(args, "FILTER_BY_TS")
for _, f := range options.FilterByTS {
args = append(args, f)
}
}
if options.FilterByValue != nil {
args = append(args, "FILTER_BY_VALUE")
for _, f := range options.FilterByValue {
args = append(args, f)
}
}
if options.Count != 0 {
args = append(args, "COUNT", options.Count)
}
if options.Align != nil {
args = append(args, "ALIGN", options.Align)
}
aggregationArg, _, err := formatAggregationArgs(options.Aggregator, options.Aggregators)
if err != nil {
cmd := newTSTimestampValueSliceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
if aggregationArg != "" {
args = append(args, "AGGREGATION", aggregationArg)
}
if options.BucketDuration != 0 {
args = append(args, options.BucketDuration)
}
if options.BucketTimestamp != nil {
args = append(args, "BUCKETTIMESTAMP", options.BucketTimestamp)
}
if options.Empty {
args = append(args, "EMPTY")
}
}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSRead - Returns samples at or after timestamp, in ascending order.
// timestamp is a non-negative Unix-ms integer or a sentinel (TSReadEarliest,
// TSReadLatest, TSReadNew).
// For more information - https://redis.io/commands/ts.read/
func (c cmdable) TSRead(ctx context.Context, key string, timestamp interface{}) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.READ", key, timestamp}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSReadWithArgs - TS.READ with the optional BLOCK and MAX_COUNT groups.
// When options.Block is set it waits for options.MinCount samples or until
// options.Timeout elapses. Blocking calls must not be used in a pipeline or MULTI.
// For more information - https://redis.io/commands/ts.read/
func (c cmdable) TSReadWithArgs(ctx context.Context, key string, timestamp interface{}, options *TSReadOptions) *TSTimestampValueSliceCmd {
args := []interface{}{"TS.READ", key, timestamp}
blocking := false
var blockTimeout time.Duration
if options != nil {
if options.Block {
blocking = true
blockTimeout = options.Timeout
minCount := options.MinCount
if minCount <= 0 {
minCount = 1
}
args = append(args, "BLOCK", formatMs(ctx, options.Timeout), minCount)
}
if options.MaxCount != 0 {
args = append(args, "MAX_COUNT", options.MaxCount)
}
}
cmd := newTSTimestampValueSliceCmd(ctx, args...)
if blocking {
cmd.setReadTimeout(blockTimeout)
}
_ = c(ctx, cmd)
return cmd
}
type TSTimestampValueSliceCmd struct {
baseCmd
val []TSTimestampValue
}
func newTSTimestampValueSliceCmd(ctx context.Context, args ...interface{}) *TSTimestampValueSliceCmd {
return &TSTimestampValueSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTSTimestampValueSlice,
},
}
}
func (cmd *TSTimestampValueSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TSTimestampValueSliceCmd) SetVal(val []TSTimestampValue) {
cmd.val = val
}
func (cmd *TSTimestampValueSliceCmd) Result() ([]TSTimestampValue, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TSTimestampValueSliceCmd) Val() []TSTimestampValue {
cmd.await()
return cmd.val
}
func (cmd *TSTimestampValueSliceCmd) readReply(rd *proto.Reader) (err error) {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]TSTimestampValue, n)
for i := 0; i < n; i++ {
itemLen, err := rd.ReadArrayLen()
if err != nil {
return err
}
if itemLen < 1 {
return fmt.Errorf("redis: got %d elements in timeseries sample, expected at least 1", itemLen)
}
timestamp, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Timestamp = timestamp
if itemLen == 2 {
value, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[i].Value, err = util.ParseStringToFloat(value)
if err != nil {
return err
}
continue
}
cmd.val[i].Values = make([]float64, itemLen-1)
for j := 0; j < itemLen-1; j++ {
value, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[i].Values[j], err = util.ParseStringToFloat(value)
if err != nil {
return err
}
}
}
return nil
}
func (cmd *TSTimestampValueSliceCmd) Clone() Cmder {
var val []TSTimestampValue
if cmd.val != nil {
val = make([]TSTimestampValue, len(cmd.val))
copy(val, cmd.val)
for i := range cmd.val {
if cmd.val[i].Values != nil {
val[i].Values = make([]float64, len(cmd.val[i].Values))
copy(val[i].Values, cmd.val[i].Values)
}
}
}
return &TSTimestampValueSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// TSMRange - Returns a range of samples from multiple time-series keys.
// For more information - https://redis.io/commands/ts.mrange/
func (c cmdable) TSMRange(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MRANGE", fromTimestamp, toTimestamp, "FILTER"}
for _, f := range filterExpr {
args = append(args, f)
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMRangeWithArgs - Returns a range of samples from multiple time-series keys.
// Options are set via TSMRangeOptions.
// For more information - https://redis.io/commands/ts.mrange/
func (c cmdable) TSMRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRangeOptions) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MRANGE", fromTimestamp, toTimestamp}
multiAggregationCount := 0
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
if options.FilterByTS != nil {
args = append(args, "FILTER_BY_TS")
for _, f := range options.FilterByTS {
args = append(args, f)
}
}
if options.FilterByValue != nil {
args = append(args, "FILTER_BY_VALUE")
for _, f := range options.FilterByValue {
args = append(args, f)
}
}
if options.WithLabels {
args = append(args, "WITHLABELS")
}
if options.SelectedLabels != nil {
args = append(args, "SELECTED_LABELS")
args = append(args, options.SelectedLabels...)
}
if options.Count != 0 {
args = append(args, "COUNT", options.Count)
}
if options.Align != nil {
args = append(args, "ALIGN", options.Align)
}
aggregationArg, count, err := formatAggregationArgs(options.Aggregator, options.Aggregators)
if err != nil {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
multiAggregationCount = count
if aggregationArg != "" {
args = append(args, "AGGREGATION", aggregationArg)
}
if options.BucketDuration != 0 {
args = append(args, options.BucketDuration)
}
if options.BucketTimestamp != nil {
args = append(args, "BUCKETTIMESTAMP", options.BucketTimestamp)
}
if options.Empty {
args = append(args, "EMPTY")
}
if options.ExcludeEmpty {
args = append(args, "EXCLUDEEMPTY")
}
}
args = append(args, "FILTER")
for _, f := range filterExpr {
args = append(args, f)
}
if options != nil {
if options.ExcludeEmpty && (options.GroupByLabel != nil || options.Reducer != nil) {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(errTSExcludeEmptyGroupBy)
return cmd
}
if multiAggregationCount > 1 && (options.GroupByLabel != nil || options.Reducer != nil) {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(errTSMultiAggregationGroupBy)
return cmd
}
if options.GroupByLabel != nil {
args = append(args, "GROUPBY", options.GroupByLabel)
}
if options.Reducer != nil {
args = append(args, "REDUCE", options.Reducer)
}
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMRevRange - Returns a range of samples from multiple time-series keys in reverse order.
// For more information - https://redis.io/commands/ts.mrevrange/
func (c cmdable) TSMRevRange(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MREVRANGE", fromTimestamp, toTimestamp, "FILTER"}
for _, f := range filterExpr {
args = append(args, f)
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMRevRangeWithArgs - Returns a range of samples from multiple time-series keys in reverse order.
// Options are set via TSMRevRangeOptions.
// For more information - https://redis.io/commands/ts.mrevrange/
func (c cmdable) TSMRevRangeWithArgs(ctx context.Context, fromTimestamp int, toTimestamp int, filterExpr []string, options *TSMRevRangeOptions) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MREVRANGE", fromTimestamp, toTimestamp}
multiAggregationCount := 0
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
if options.FilterByTS != nil {
args = append(args, "FILTER_BY_TS")
for _, f := range options.FilterByTS {
args = append(args, f)
}
}
if options.FilterByValue != nil {
args = append(args, "FILTER_BY_VALUE")
for _, f := range options.FilterByValue {
args = append(args, f)
}
}
if options.WithLabels {
args = append(args, "WITHLABELS")
}
if options.SelectedLabels != nil {
args = append(args, "SELECTED_LABELS")
args = append(args, options.SelectedLabels...)
}
if options.Count != 0 {
args = append(args, "COUNT", options.Count)
}
if options.Align != nil {
args = append(args, "ALIGN", options.Align)
}
aggregationArg, count, err := formatAggregationArgs(options.Aggregator, options.Aggregators)
if err != nil {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
multiAggregationCount = count
if aggregationArg != "" {
args = append(args, "AGGREGATION", aggregationArg)
}
if options.BucketDuration != 0 {
args = append(args, options.BucketDuration)
}
if options.BucketTimestamp != nil {
args = append(args, "BUCKETTIMESTAMP", options.BucketTimestamp)
}
if options.Empty {
args = append(args, "EMPTY")
}
if options.ExcludeEmpty {
args = append(args, "EXCLUDEEMPTY")
}
}
args = append(args, "FILTER")
for _, f := range filterExpr {
args = append(args, f)
}
if options != nil {
if options.ExcludeEmpty && (options.GroupByLabel != nil || options.Reducer != nil) {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(errTSExcludeEmptyGroupBy)
return cmd
}
if multiAggregationCount > 1 && (options.GroupByLabel != nil || options.Reducer != nil) {
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
cmd.SetErr(errTSMultiAggregationGroupBy)
return cmd
}
if options.GroupByLabel != nil {
args = append(args, "GROUPBY", options.GroupByLabel)
}
if options.Reducer != nil {
args = append(args, "REDUCE", options.Reducer)
}
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMGet - Returns the last sample of multiple time-series keys.
// For more information - https://redis.io/commands/ts.mget/
func (c cmdable) TSMGet(ctx context.Context, filters []string) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MGET", "FILTER"}
for _, f := range filters {
args = append(args, f)
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSMGetWithArgs - Returns the last sample of multiple time-series keys with additional options.
// This function allows for specifying additional options such as:
// Latest, WithLabels and SelectedLabels.
// For more information - https://redis.io/commands/ts.mget/
func (c cmdable) TSMGetWithArgs(ctx context.Context, filters []string, options *TSMGetOptions) *MapStringSliceInterfaceCmd {
args := []interface{}{"TS.MGET"}
if options != nil {
if options.Latest {
args = append(args, "LATEST")
}
if options.WithLabels {
args = append(args, "WITHLABELS")
}
if options.SelectedLabels != nil {
args = append(args, "SELECTED_LABELS")
args = append(args, options.SelectedLabels...)
}
}
args = append(args, "FILTER")
for _, f := range filters {
args = append(args, f)
}
cmd := NewMapStringSliceInterfaceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSNRangePivotRow represents a single row in the pivot response from TS.NRANGE / TS.NREVRANGE.
// Timestamp is the row's timestamp. Without aggregation, Values holds one float64 per input key
// in input-key order. With aggregation, Values holds one float64 per requested (key, aggregator)
// pair, flattened in input-key order with each key's aggregators in spec order.
// Missing samples and missing aggregation buckets are represented as NaN.
type TSNRangePivotRow struct {
Timestamp int64
Values []float64
}
type TSNRangePivotRowSliceCmd struct {
baseCmd
val []TSNRangePivotRow
}
func newTSNRangePivotRowSliceCmd(ctx context.Context, args ...interface{}) *TSNRangePivotRowSliceCmd {
return &TSNRangePivotRowSliceCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeTSNRangePivotRowSlice,
},
}
}
func (cmd *TSNRangePivotRowSliceCmd) String() string {
cmd.await()
return cmdString(cmd, cmd.val)
}
func (cmd *TSNRangePivotRowSliceCmd) SetVal(val []TSNRangePivotRow) {
cmd.val = val
}
func (cmd *TSNRangePivotRowSliceCmd) Result() ([]TSNRangePivotRow, error) {
cmd.await()
return cmd.val, cmd.err
}
func (cmd *TSNRangePivotRowSliceCmd) Val() []TSNRangePivotRow {
cmd.await()
return cmd.val
}
func (cmd *TSNRangePivotRowSliceCmd) readReply(rd *proto.Reader) error {
n, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val = make([]TSNRangePivotRow, n)
for i := 0; i < n; i++ {
// Each row is a 2-element array: [timestamp, [value_0, value_1, ...]]
if _, err = rd.ReadArrayLen(); err != nil {
return err
}
timestamp, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val[i].Timestamp = timestamp
valCount, err := rd.ReadArrayLen()
if err != nil {
return err
}
cmd.val[i].Values = make([]float64, valCount)
for j := 0; j < valCount; j++ {
s, err := rd.ReadString()
if err != nil {
return err
}
cmd.val[i].Values[j], err = util.ParseStringToFloat(s)
if err != nil {
return err
}
}
}
return nil
}
func (cmd *TSNRangePivotRowSliceCmd) Clone() Cmder {
var val []TSNRangePivotRow
if cmd.val != nil {
val = make([]TSNRangePivotRow, len(cmd.val))
copy(val, cmd.val)
for i := range cmd.val {
if cmd.val[i].Values != nil {
val[i].Values = make([]float64, len(cmd.val[i].Values))
copy(val[i].Values, cmd.val[i].Values)
}
}
}
return &TSNRangePivotRowSliceCmd{
baseCmd: cmd.cloneBaseCmd(),
val: val,
}
}
// buildNRangeAggregationArgs validates and returns one aggregator spec string per key for
// TS.NRANGE / TS.NREVRANGE. The number of specs must equal the number of keys. Each spec
// lists one or more aggregators for its key and is emitted as a single comma-joined wire
// token; specs for different keys are separate wire tokens.
func buildNRangeAggregationArgs(keys []string, aggregators [][]Aggregator) ([]string, error) {
if len(aggregators) != len(keys) {
return nil, fmt.Errorf("redis: TS.NRANGE/TS.NREVRANGE requires exactly %d aggregator spec(s), got %d", len(keys), len(aggregators))
}
parts := make([]string, len(aggregators))
for i, spec := range aggregators {
if len(spec) == 0 {
return nil, fmt.Errorf("redis: empty timeseries aggregator spec at index %d", i)
}
names := make([]string, len(spec))
for j, agg := range spec {
if agg == Invalid {
return nil, fmt.Errorf("redis: invalid timeseries aggregator at index %d[%d]: Invalid (%d)", i, j, agg)
}
s := agg.String()
if s == "" {
return nil, fmt.Errorf("redis: invalid timeseries aggregator at index %d[%d]: %d", i, j, agg)
}
names[j] = s
}
parts[i] = strings.Join(names, ",")
}
return parts, nil
}
// appendNRangeOptions appends optional TS.NRANGE / TS.NREVRANGE arguments to args.
func appendNRangeOptions(
args []interface{},
keys []string,
latest bool,
filterByTS []int,
filterByValue []float64,
count int,
align interface{},
aggregators [][]Aggregator,
bucketDuration int,
bucketTimestamp interface{},
empty bool,
) ([]interface{}, error) {
if latest {
args = append(args, "LATEST")
}
if len(filterByTS) > 0 {
args = append(args, "FILTER_BY_TS")
for _, ts := range filterByTS {
args = append(args, ts)
}
}
if len(filterByValue) > 0 {
if len(filterByValue) != 2 {
return args, fmt.Errorf("redis: FILTER_BY_VALUE requires exactly 2 elements [min, max], got %d", len(filterByValue))
}
args = append(args, "FILTER_BY_VALUE", filterByValue[0], filterByValue[1])
}
if count != 0 {
args = append(args, "COUNT", count)
}
if align != nil {
args = append(args, "ALIGN", align)
}
if len(aggregators) > 0 {
aggParts, err := buildNRangeAggregationArgs(keys, aggregators)
if err != nil {
return args, err
}
args = append(args, "AGGREGATION")
for _, a := range aggParts {
args = append(args, a)
}
if bucketDuration != 0 {
args = append(args, bucketDuration)
}
if bucketTimestamp != nil {
args = append(args, "BUCKETTIMESTAMP", bucketTimestamp)
}
if empty {
args = append(args, "EMPTY")
}
}
return args, nil
}
// TSNRange - Queries multiple time-series keys and returns a pivot response in forward (ascending) order.
// For more information - https://redis.io/commands/ts.nrange/
func (c cmdable) TSNRange(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}) *TSNRangePivotRowSliceCmd {
args := make([]interface{}, 0, 3+len(keys))
args = append(args, "TS.NRANGE", len(keys))
for _, k := range keys {
args = append(args, k)
}
args = append(args, fromTimestamp, toTimestamp)
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// TSNRangeWithArgs - Queries multiple time-series keys and returns a pivot response in forward (ascending) order with additional options.
// This function allows for specifying additional options such as:
// Latest, FilterByTS, FilterByValue, Count, Align, Aggregators, BucketDuration, BucketTimestamp and Empty.
// Aggregators must contain exactly one spec per key; each spec lists one or more aggregators
// for its key and is emitted as a single comma-joined wire token.
// For more information - https://redis.io/commands/ts.nrange/
func (c cmdable) TSNRangeWithArgs(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}, options *TSNRangeOptions) *TSNRangePivotRowSliceCmd {
args := make([]interface{}, 0, 3+len(keys))
args = append(args, "TS.NRANGE", len(keys))
for _, k := range keys {
args = append(args, k)
}
args = append(args, fromTimestamp, toTimestamp)
if options != nil {
var err error
args, err = appendNRangeOptions(args, keys,
options.Latest, options.FilterByTS, options.FilterByValue,
options.Count, options.Align, options.Aggregators,
options.BucketDuration, options.BucketTimestamp, options.Empty)
if err != nil {
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
}
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// TSNRevRange - Queries multiple time-series keys and returns a pivot response in reverse (descending) order.
// For more information - https://redis.io/commands/ts.nrevrange/
func (c cmdable) TSNRevRange(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}) *TSNRangePivotRowSliceCmd {
args := make([]interface{}, 0, 3+len(keys))
args = append(args, "TS.NREVRANGE", len(keys))
for _, k := range keys {
args = append(args, k)
}
args = append(args, fromTimestamp, toTimestamp)
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
// TSNRevRangeWithArgs - Queries multiple time-series keys and returns a pivot response in reverse (descending) order with additional options.
// This function allows for specifying additional options such as:
// Latest, FilterByTS, FilterByValue, Count, Align, Aggregators, BucketDuration, BucketTimestamp and Empty.
// Aggregators must contain exactly one spec per key; each spec lists one or more aggregators
// for its key and is emitted as a single comma-joined wire token.
// For more information - https://redis.io/commands/ts.nrevrange/
func (c cmdable) TSNRevRangeWithArgs(ctx context.Context, keys []string, fromTimestamp interface{}, toTimestamp interface{}, options *TSNRevRangeOptions) *TSNRangePivotRowSliceCmd {
args := make([]interface{}, 0, 3+len(keys))
args = append(args, "TS.NREVRANGE", len(keys))
for _, k := range keys {
args = append(args, k)
}
args = append(args, fromTimestamp, toTimestamp)
if options != nil {
var err error
args, err = appendNRangeOptions(args, keys,
options.Latest, options.FilterByTS, options.FilterByValue,
options.Count, options.Align, options.Aggregators,
options.BucketDuration, options.BucketTimestamp, options.Empty)
if err != nil {
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetErr(err)
return cmd
}
}
cmd := newTSNRangePivotRowSliceCmd(ctx, args...)
cmd.SetFirstKeyPos(2)
_ = c(ctx, cmd)
return cmd
}
package redis
import (
"context"
"errors"
"github.com/redis/go-redis/v9/internal/proto"
)
// TxFailedErr transaction redis failed.
const TxFailedErr = proto.RedisError("redis: transaction failed")
// Tx implements Redis transactions as described in
// https://redis.io/docs/latest/develop/using-commands/transactions. It's NOT safe for concurrent use
// by multiple goroutines, because Exec resets list of watched keys.
//
// If you don't need WATCH, use Pipeline instead.
type Tx struct {
baseClient
cmdable
statefulCmdable
// watchArmed reports whether a WATCH issued through Tx.Watch may still be
// active on the connection. It is set on a successful WATCH and cleared once
// the watched keys are discarded server-side: on UNWATCH (Tx.Unwatch) and on
// EXEC, including an aborted EXEC (the TxPipeline closure). Close uses it to
// skip an otherwise redundant UNWATCH round trip.
//
// Only Tx.Watch, Tx.Unwatch and the TxPipeline EXEC closure maintain this
// flag. go-redis never issues a standalone DISCARD (Pipeline.Discard is a
// client-side buffer reset, not a server command). Issuing a WATCH directly
// via Process bypasses tracking, so Close would not release it and the watch
// would leak onto the pooled connection; that is the one case where skipping
// UNWATCH is unsafe, and using raw Process for WATCH/EXEC/UNWATCH is therefore
// unsupported.
//
// Tx is not safe for concurrent use (see above), so watchArmed is accessed
// only from the goroutine that owns the Tx and needs no synchronization.
watchArmed bool
}
func (c *Client) newTx() *Tx {
tx := Tx{
baseClient: baseClient{
opt: c.cloneOpt(), // Clone options under optLock to avoid race with initConn
connPool: c.baseClient.newStickyConnPool(),
hooksMixin: c.hooksMixin.clone(),
pushProcessor: c.pushProcessor, // Copy push processor from parent client
onClose: &onCloseHooks{},
// Share the HIMPORT fieldset registry: the sticky pool borrows
// connections from the parent client's pool, so fieldsets
// prepared on them stay valid after the connections are
// returned.
himport: c.himport,
// Carry the shared eviction hook (not csc: a sticky Tx must not serve
// cached reads) so close/reinit hooks on a Watch-initialized conn still
// evict from the parent cache.
cscPoolHook: c.cscPoolHook,
cscActive: c.cscActive,
},
}
tx.init()
return &tx
}
func (c *Tx) init() {
c.cmdable = c.Process
c.statefulCmdable = c.Process
c.initHooks(hooks{
dial: c.baseClient.dial,
process: c.baseClient.process,
pipeline: c.baseClient.processPipeline,
txPipeline: c.baseClient.processTxPipeline,
})
}
func (c *Tx) Process(ctx context.Context, cmd Cmder) error {
err := c.processHook(ctx, cmd)
cmd.SetErr(err)
return err
}
// Watch prepares a transaction and marks the keys to be watched
// for conditional execution if there are any keys.
//
// The transaction is automatically closed when fn exits.
func (c *Client) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error {
tx := c.newTx()
defer tx.Close(ctx)
if len(keys) > 0 {
if err := tx.Watch(ctx, keys...).Err(); err != nil {
return err
}
}
return fn(tx)
}
// Close closes the transaction, releasing any open resources.
func (c *Tx) Close(ctx context.Context) error {
// UNWATCH is only needed while a WATCH is still active. EXEC discards the
// watched keys server-side on both commit and abort, so the common
// WATCH/.../EXEC paths leave nothing to release and avoid the extra round
// trip.
if c.watchArmed {
_ = c.Unwatch(ctx).Err()
}
return c.baseClient.Close()
}
// Watch marks the keys to be watched for conditional execution
// of a transaction.
func (c *Tx) Watch(ctx context.Context, keys ...string) *StatusCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "watch"
for i, key := range keys {
args[1+i] = key
}
cmd := NewStatusCmd(ctx, args...)
_ = c.Process(ctx, cmd)
// A successful WATCH leaves keys watched on the connection that Close must
// later release with UNWATCH.
if cmd.Err() == nil {
c.watchArmed = true
}
return cmd
}
// Unwatch flushes all the previously watched keys for a transaction.
func (c *Tx) Unwatch(ctx context.Context, keys ...string) *StatusCmd {
args := make([]interface{}, 1+len(keys))
args[0] = "unwatch"
for i, key := range keys {
args[1+i] = key
}
cmd := NewStatusCmd(ctx, args...)
_ = c.Process(ctx, cmd)
// The watched keys have been released, so Close need not UNWATCH again.
if cmd.Err() == nil {
c.watchArmed = false
}
return cmd
}
// Pipeline creates a pipeline. Usually it is more convenient to use Pipelined.
func (c *Tx) Pipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
return c.processPipelineHook(ctx, cmds)
},
}
pipe.init()
return &pipe
}
// Pipelined executes commands queued in the fn outside of the transaction.
// Use TxPipelined if you need transactional behavior.
func (c *Tx) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.Pipeline().Pipelined(ctx, fn)
}
// TxPipelined executes commands queued in the fn in the transaction.
//
// When using WATCH, EXEC will execute commands only if the watched keys
// were not modified, allowing for a check-and-set mechanism.
//
// Exec always returns list of commands. If transaction fails
// TxFailedErr is returned. Otherwise Exec returns an error of the first
// failed command or nil.
func (c *Tx) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) {
return c.TxPipeline().Pipelined(ctx, fn)
}
// TxPipeline creates a pipeline. Usually it is more convenient to use TxPipelined.
func (c *Tx) TxPipeline() Pipeliner {
pipe := Pipeline{
exec: func(ctx context.Context, cmds []Cmder) error {
cmds = wrapMultiExec(ctx, cmds)
err := c.processTxPipelineHook(ctx, cmds)
// EXEC discards the watched keys server-side, so the watch is
// cleared only when EXEC actually ran: a nil error (committed),
// TxFailedErr (a watched key changed, EXEC returned nil), or an
// EXECABORT error (a queued command was rejected, so EXEC discarded
// the transaction). All three release the watched keys. Any other
// error may be reported before EXEC executes (for example -LOADING on
// the MULTI reply) or on a broken connection, so leave watchArmed set
// and let Close send UNWATCH rather than risk leaving a watch on a
// pooled connection.
if err == nil || errors.Is(err, TxFailedErr) || IsExecAbortError(err) {
c.watchArmed = false
}
return err
},
}
pipe.init()
return &pipe
}
func wrapMultiExec(ctx context.Context, cmds []Cmder) []Cmder {
if len(cmds) == 0 {
panic("not reached")
}
cmdsCopy := make([]Cmder, len(cmds)+2)
cmdsCopy[0] = NewStatusCmd(ctx, "multi")
copy(cmdsCopy[1:], cmds)
cmdsCopy[len(cmdsCopy)-1] = NewSliceCmd(ctx, "exec")
return cmdsCopy
}
package redis
import (
"context"
"crypto/tls"
"net"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/redis/go-redis/v9/push"
)
// UniversalOptions information is required by UniversalClient to establish
// connections.
type UniversalOptions struct {
// Either a single address or a seed list of host:port addresses
// of cluster/sentinel nodes.
Addrs []string
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
ClientName string
// Database to be selected after connecting to the server.
// Only single-node and failover clients.
DB int
// Common options.
Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
OnConnect func(ctx context.Context, cn *Conn) error
Protocol int
Username string
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
SentinelUsername string
SentinelPassword string
MaxRetries int
MinRetryBackoff time.Duration
MaxRetryBackoff time.Duration
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
ReadTimeout time.Duration
WriteTimeout time.Duration
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
// PipelineReadBufferSize / PipelineWriteBufferSize size the dedicated pipeline
// pool's per-connection buffers. PipelinePoolSize sizes that pool; a negative
// value opts out of the dedicated pipeline pool. See the same fields on
// Options for details.
PipelineReadBufferSize int
PipelineWriteBufferSize int
PipelinePoolSize int
// PoolFIFO uses FIFO mode for each node connection pool GET/PUT (default LIFO).
PoolFIFO bool
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 time.Duration
MinIdleConns int
MaxIdleConns int
MaxActiveConns int
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
ConnMaxLifetimeJitter time.Duration
TLSConfig *tls.Config
// Only cluster clients.
MaxRedirects int
ReadOnly bool
RouteByLatency bool
// RouteByLatencyTolerance is passed through to ClusterOptions and FailoverOptions;
// see ClusterOptions.RouteByLatencyTolerance.
RouteByLatencyTolerance time.Duration
RouteRandomly bool
// MasterName is the sentinel master name.
// Only for failover clients.
MasterName string
// 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
IdentitySuffix string
// 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.
// Only applies to cluster clients. Default is 15 seconds.
FailingTimeoutSeconds int
// Deprecated: All RediSearch commands now have stable RESP3 parsing and this
// flag is a no-op. It is kept for backwards compatibility and will be removed
// in a future release.
UnstableResp3 bool
// PushNotificationProcessor is the processor for handling push notifications.
// If nil, a default processor will be created for RESP3 connections.
PushNotificationProcessor push.NotificationProcessor
// IsClusterMode can be used when only one Addrs is provided (e.g. Elasticache supports setting up cluster mode with configuration endpoint).
IsClusterMode bool
// AutoPipelineOptions is the default config for the client's
// autopipeliner faces (AutoPipeline / AsyncAutoPipeline), applied when
// they are called without explicit options. See Options.AutoPipelineOptions.
AutoPipelineOptions *AutoPipelineOptions
// MaintNotificationsConfig provides configuration for maintnotifications upgrades.
MaintNotificationsConfig *maintnotifications.Config
// ClientSideCacheConfig enables client-side caching when NewUniversalClient
// selects a standalone Client. See Options.ClientSideCacheConfig.
//
// Experimental: this API may change in a minor release.
ClientSideCacheConfig *ClientSideCacheConfig
// ClientSideCache supplies an explicit cache when NewUniversalClient selects
// a standalone Client. See Options.ClientSideCache.
//
// Experimental: this API may change in a minor release.
ClientSideCache Cache
// ClientSideCacheStrategy selects the standalone client's invalidation
// strategy. See Options.ClientSideCacheStrategy.
//
// Experimental: this API may change in a minor release.
ClientSideCacheStrategy CSCStrategy
// ClientSideCacheRefreshOnInvalidate re-fetches recently-read keys as soon as
// their invalidation arrives. See Options.ClientSideCacheRefreshOnInvalidate.
//
// Experimental: this API may change in a minor release.
ClientSideCacheRefreshOnInvalidate bool
// ClientSideCacheRefreshRecencyWindow bounds ClientSideCacheRefreshOnInvalidate
// to recently-read keys. See Options.ClientSideCacheRefreshRecencyWindow.
//
// Experimental: this API may change in a minor release.
ClientSideCacheRefreshRecencyWindow time.Duration
// ClientSideCacheCoalesceMisses coalesces concurrent cache misses onto a held
// full-duplex connection. See Options.ClientSideCacheCoalesceMisses.
//
// Experimental: this API may change in a minor release.
ClientSideCacheCoalesceMisses bool
// ClientSideCacheInvalidationBatchWindow batches invalidation-driven deletes.
// See Options.ClientSideCacheInvalidationBatchWindow.
//
// Experimental: this API may change in a minor release.
ClientSideCacheInvalidationBatchWindow time.Duration
}
// Cluster returns cluster options created from the universal options.
func (o *UniversalOptions) Cluster() *ClusterOptions {
if len(o.Addrs) == 0 {
o.Addrs = []string{"127.0.0.1:6379"}
}
return &ClusterOptions{
Addrs: o.Addrs,
ClientName: o.ClientName,
Dialer: o.Dialer,
OnConnect: o.OnConnect,
Protocol: o.Protocol,
Username: o.Username,
Password: o.Password,
CredentialsProvider: o.CredentialsProvider,
CredentialsProviderContext: o.CredentialsProviderContext,
StreamingCredentialsProvider: o.StreamingCredentialsProvider,
MaxRedirects: o.MaxRedirects,
ReadOnly: o.ReadOnly,
RouteByLatency: o.RouteByLatency,
RouteByLatencyTolerance: o.RouteByLatencyTolerance,
RouteRandomly: o.RouteRandomly,
MaxRetries: o.MaxRetries,
MinRetryBackoff: o.MinRetryBackoff,
MaxRetryBackoff: o.MaxRetryBackoff,
DialTimeout: o.DialTimeout,
DialerRetries: o.DialerRetries,
DialerRetryTimeout: o.DialerRetryTimeout,
ReadTimeout: o.ReadTimeout,
WriteTimeout: o.WriteTimeout,
ContextTimeoutEnabled: o.ContextTimeoutEnabled,
ReadBufferSize: o.ReadBufferSize,
WriteBufferSize: o.WriteBufferSize,
PipelineReadBufferSize: o.PipelineReadBufferSize,
PipelineWriteBufferSize: o.PipelineWriteBufferSize,
PipelinePoolSize: o.PipelinePoolSize,
PoolFIFO: o.PoolFIFO,
PoolSize: o.PoolSize,
MaxConcurrentDials: o.MaxConcurrentDials,
PoolTimeout: o.PoolTimeout,
MinIdleConns: o.MinIdleConns,
MaxIdleConns: o.MaxIdleConns,
MaxActiveConns: o.MaxActiveConns,
ConnMaxIdleTime: o.ConnMaxIdleTime,
ConnMaxLifetime: o.ConnMaxLifetime,
ConnMaxLifetimeJitter: o.ConnMaxLifetimeJitter,
TLSConfig: o.TLSConfig,
DisableIdentity: o.DisableIdentity,
DisableIndentity: o.DisableIndentity,
IdentitySuffix: o.IdentitySuffix,
AutoPipelineOptions: o.AutoPipelineOptions,
FailingTimeoutSeconds: o.FailingTimeoutSeconds,
UnstableResp3: o.UnstableResp3,
PushNotificationProcessor: o.PushNotificationProcessor,
MaintNotificationsConfig: o.MaintNotificationsConfig,
}
}
// Failover returns failover options created from the universal options.
func (o *UniversalOptions) Failover() *FailoverOptions {
if len(o.Addrs) == 0 {
o.Addrs = []string{"127.0.0.1:26379"}
}
return &FailoverOptions{
SentinelAddrs: o.Addrs,
MasterName: o.MasterName,
ClientName: o.ClientName,
Dialer: o.Dialer,
OnConnect: o.OnConnect,
DB: o.DB,
Protocol: o.Protocol,
Username: o.Username,
Password: o.Password,
CredentialsProvider: o.CredentialsProvider,
CredentialsProviderContext: o.CredentialsProviderContext,
StreamingCredentialsProvider: o.StreamingCredentialsProvider,
SentinelUsername: o.SentinelUsername,
SentinelPassword: o.SentinelPassword,
RouteByLatency: o.RouteByLatency,
RouteByLatencyTolerance: o.RouteByLatencyTolerance,
RouteRandomly: o.RouteRandomly,
MaxRetries: o.MaxRetries,
MinRetryBackoff: o.MinRetryBackoff,
MaxRetryBackoff: o.MaxRetryBackoff,
DialTimeout: o.DialTimeout,
DialerRetries: o.DialerRetries,
DialerRetryTimeout: o.DialerRetryTimeout,
ReadTimeout: o.ReadTimeout,
WriteTimeout: o.WriteTimeout,
ContextTimeoutEnabled: o.ContextTimeoutEnabled,
ReadBufferSize: o.ReadBufferSize,
WriteBufferSize: o.WriteBufferSize,
PipelineReadBufferSize: o.PipelineReadBufferSize,
PipelineWriteBufferSize: o.PipelineWriteBufferSize,
PipelinePoolSize: o.PipelinePoolSize,
PoolFIFO: o.PoolFIFO,
PoolSize: o.PoolSize,
MaxConcurrentDials: o.MaxConcurrentDials,
PoolTimeout: o.PoolTimeout,
MinIdleConns: o.MinIdleConns,
MaxIdleConns: o.MaxIdleConns,
MaxActiveConns: o.MaxActiveConns,
ConnMaxIdleTime: o.ConnMaxIdleTime,
ConnMaxLifetime: o.ConnMaxLifetime,
ConnMaxLifetimeJitter: o.ConnMaxLifetimeJitter,
TLSConfig: o.TLSConfig,
ReplicaOnly: o.ReadOnly,
DisableIdentity: o.DisableIdentity,
DisableIndentity: o.DisableIndentity,
IdentitySuffix: o.IdentitySuffix,
AutoPipelineOptions: o.AutoPipelineOptions,
UnstableResp3: o.UnstableResp3,
PushNotificationProcessor: o.PushNotificationProcessor,
// Note: MaintNotificationsConfig not supported for FailoverOptions
}
}
// Simple returns basic options created from the universal options.
func (o *UniversalOptions) Simple() *Options {
addr := "127.0.0.1:6379"
if len(o.Addrs) > 0 {
addr = o.Addrs[0]
}
return &Options{
Addr: addr,
ClientName: o.ClientName,
Dialer: o.Dialer,
OnConnect: o.OnConnect,
DB: o.DB,
Protocol: o.Protocol,
Username: o.Username,
Password: o.Password,
CredentialsProvider: o.CredentialsProvider,
CredentialsProviderContext: o.CredentialsProviderContext,
StreamingCredentialsProvider: o.StreamingCredentialsProvider,
MaxRetries: o.MaxRetries,
MinRetryBackoff: o.MinRetryBackoff,
MaxRetryBackoff: o.MaxRetryBackoff,
DialTimeout: o.DialTimeout,
DialerRetries: o.DialerRetries,
DialerRetryTimeout: o.DialerRetryTimeout,
ReadTimeout: o.ReadTimeout,
WriteTimeout: o.WriteTimeout,
ContextTimeoutEnabled: o.ContextTimeoutEnabled,
ReadBufferSize: o.ReadBufferSize,
WriteBufferSize: o.WriteBufferSize,
PipelineReadBufferSize: o.PipelineReadBufferSize,
PipelineWriteBufferSize: o.PipelineWriteBufferSize,
PipelinePoolSize: o.PipelinePoolSize,
PoolFIFO: o.PoolFIFO,
PoolSize: o.PoolSize,
MaxConcurrentDials: o.MaxConcurrentDials,
PoolTimeout: o.PoolTimeout,
MinIdleConns: o.MinIdleConns,
MaxIdleConns: o.MaxIdleConns,
MaxActiveConns: o.MaxActiveConns,
ConnMaxIdleTime: o.ConnMaxIdleTime,
ConnMaxLifetime: o.ConnMaxLifetime,
ConnMaxLifetimeJitter: o.ConnMaxLifetimeJitter,
TLSConfig: o.TLSConfig,
DisableIdentity: o.DisableIdentity,
DisableIndentity: o.DisableIndentity,
IdentitySuffix: o.IdentitySuffix,
AutoPipelineOptions: o.AutoPipelineOptions,
UnstableResp3: o.UnstableResp3,
PushNotificationProcessor: o.PushNotificationProcessor,
MaintNotificationsConfig: o.MaintNotificationsConfig,
ClientSideCacheConfig: o.ClientSideCacheConfig,
ClientSideCache: o.ClientSideCache,
ClientSideCacheStrategy: o.ClientSideCacheStrategy,
ClientSideCacheRefreshOnInvalidate: o.ClientSideCacheRefreshOnInvalidate,
ClientSideCacheRefreshRecencyWindow: o.ClientSideCacheRefreshRecencyWindow,
ClientSideCacheCoalesceMisses: o.ClientSideCacheCoalesceMisses,
ClientSideCacheInvalidationBatchWindow: o.ClientSideCacheInvalidationBatchWindow,
}
}
// --------------------------------------------------------------------
// UniversalClient is an abstract client which - based on the provided options -
// represents either a ClusterClient, a FailoverClient, or a single-node Client.
// This can be useful for testing cluster-specific applications locally or having different
// clients in different environments.
type UniversalClient interface {
Cmdable
AddHook(Hook)
Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error
Do(ctx context.Context, args ...interface{}) *Cmd
Process(ctx context.Context, cmd Cmder) error
// AutoPipeline / AsyncAutoPipeline return an AutoPipeliner for the concrete
// client. Supported on *Client (including sentinel-backed failover clients)
// and *ClusterClient; *Ring returns an error (not supported).
//
// EXPERIMENTAL: this API is subject to change, use with caution.
AutoPipeline() (*AutoPipeliner, error)
AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error)
AsyncAutoPipeline() (*AutoPipeliner, error)
AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error)
Subscribe(ctx context.Context, channels ...string) *PubSub
PSubscribe(ctx context.Context, channels ...string) *PubSub
SSubscribe(ctx context.Context, channels ...string) *PubSub
Close() error
PoolStats() *PoolStats
}
var (
_ UniversalClient = (*Client)(nil)
_ UniversalClient = (*ClusterClient)(nil)
_ UniversalClient = (*Ring)(nil)
// AutoPipeliner is a drop-in for the real clients; non-data operations
// delegate to the underlying client.
_ UniversalClient = (*AutoPipeliner)(nil)
)
// NewUniversalClient returns a new multi client. The type of the returned client depends
// on the following conditions:
//
// 1. If the MasterName option is specified with RouteByLatency, RouteRandomly or IsClusterMode,
// a FailoverClusterClient is returned.
// 2. If the MasterName option is specified without RouteByLatency, RouteRandomly or IsClusterMode,
// a sentinel-backed FailoverClient is returned.
// 3. If the number of Addrs is two or more, or IsClusterMode option is specified,
// a ClusterClient is returned.
// 4. Otherwise, a single-node Client is returned.
//
// Passing nil UniversalOptions will cause a panic.
func NewUniversalClient(opts *UniversalOptions) UniversalClient {
if opts == nil {
panic("redis: NewUniversalClient nil options")
}
switch {
case opts.MasterName != "" && (opts.RouteByLatency || opts.RouteRandomly || opts.IsClusterMode):
return NewFailoverClusterClient(opts.Failover())
case opts.MasterName != "":
return NewFailoverClient(opts.Failover())
case len(opts.Addrs) > 1 || opts.IsClusterMode:
return NewClusterClient(opts.Cluster())
default:
return NewClient(opts.Simple())
}
}
package redis
import (
"context"
"encoding/json"
"strconv"
)
// note: the APIs is experimental and may be subject to change.
type VectorSetCmdable interface {
VAdd(ctx context.Context, key, element string, val Vector) *BoolCmd
VAddWithArgs(ctx context.Context, key, element string, val Vector, addArgs *VAddArgs) *BoolCmd
VCard(ctx context.Context, key string) *IntCmd
VDim(ctx context.Context, key string) *IntCmd
VEmb(ctx context.Context, key, element string, raw bool) *SliceCmd
VGetAttr(ctx context.Context, key, element string) *StringCmd
VInfo(ctx context.Context, key string) *MapStringInterfaceCmd
VLinks(ctx context.Context, key, element string) *StringSliceSliceCmd
VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceSliceCmd
VRandMember(ctx context.Context, key string) *StringCmd
VRandMemberCount(ctx context.Context, key string, count int) *StringSliceCmd
VRem(ctx context.Context, key, element string) *BoolCmd
VSetAttr(ctx context.Context, key, element string, attr interface{}) *BoolCmd
VClearAttributes(ctx context.Context, key, element string) *BoolCmd
VSim(ctx context.Context, key string, val Vector) *StringSliceCmd
VSimWithScores(ctx context.Context, key string, val Vector) *VectorScoreSliceCmd
VSimWithArgs(ctx context.Context, key string, val Vector, args *VSimArgs) *StringSliceCmd
VSimWithArgsWithScores(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorScoreSliceCmd
VSimWithArgsWithAttribs(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorAttribSliceCmd
VSimWithArgsWithScoresWithAttribs(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorScoreAttribSliceCmd
VRange(ctx context.Context, key, start, end string, count int64) *StringSliceCmd
VIsMember(ctx context.Context, key, element string) *BoolCmd
}
type Vector interface {
Value() []any
}
const (
vectorFormatFP32 string = "FP32"
vectorFormatValues string = "Values"
vectorFormatF16 string = "FLOAT16"
vectorFormatBF16 string = "BFLOAT16"
vectorFormatF64 string = "FLOAT64"
vectorFormatI8 string = "INT8"
vectorFormatU8 string = "UINT8"
)
type VectorFP32 struct {
Val []byte
}
func (v *VectorFP32) Value() []any {
return []any{vectorFormatFP32, v.Val}
}
var _ Vector = (*VectorFP32)(nil)
// VectorFloat16 represents a FLOAT16-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorFloat16 struct {
Val []byte
}
func (v *VectorFloat16) Value() []any {
return []any{vectorFormatF16, v.Val}
}
var _ Vector = (*VectorFloat16)(nil)
// VectorBFloat16 represents a BFLOAT16-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorBFloat16 struct {
Val []byte
}
func (v *VectorBFloat16) Value() []any {
return []any{vectorFormatBF16, v.Val}
}
var _ Vector = (*VectorBFloat16)(nil)
// VectorFloat64 represents a FLOAT64-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorFloat64 struct {
Val []byte
}
func (v *VectorFloat64) Value() []any {
return []any{vectorFormatF64, v.Val}
}
var _ Vector = (*VectorFloat64)(nil)
// VectorInt8 represents an INT8-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorInt8 struct {
Val []byte
}
func (v *VectorInt8) Value() []any {
return []any{vectorFormatI8, v.Val}
}
var _ Vector = (*VectorInt8)(nil)
// VectorUint8 represents a UINT8-encoded vector blob.
// note: intended for search/index query commands such as FT.HYBRID.
type VectorUint8 struct {
Val []byte
}
func (v *VectorUint8) Value() []any {
return []any{vectorFormatU8, v.Val}
}
var _ Vector = (*VectorUint8)(nil)
type VectorValues struct {
Val []float64
}
func (v *VectorValues) Value() []any {
res := make([]any, 2+len(v.Val))
res[0] = vectorFormatValues
res[1] = len(v.Val)
for i, v := range v.Val {
res[2+i] = v
}
return res
}
var _ Vector = (*VectorValues)(nil)
type VectorRef struct {
Name string // the name of the referent vector
}
func (v *VectorRef) Value() []any {
return []any{"ele", v.Name}
}
var _ Vector = (*VectorRef)(nil)
type VectorScore struct {
Name string
Score float64
}
type VectorAttrib struct {
Name string
Attribs *string
}
type VectorScoreAttrib struct {
Name string
Score float64
Attribs *string
}
// `VADD key (FP32 | VALUES num) vector element`
// note: the API is experimental and may be subject to change.
func (c cmdable) VAdd(ctx context.Context, key, element string, val Vector) *BoolCmd {
return c.VAddWithArgs(ctx, key, element, val, &VAddArgs{})
}
type VAddArgs struct {
// the REDUCE option must be passed immediately after the key
Reduce int64
Cas bool
// The NoQuant, Q8 and Bin options are mutually exclusive.
NoQuant bool
Q8 bool
Bin bool
EF int64
SetAttr string
M int64
}
func (v VAddArgs) reduce() int64 {
return v.Reduce
}
func (v VAddArgs) appendArgs(args []any) []any {
if v.Cas {
args = append(args, "cas")
}
if v.NoQuant {
args = append(args, "noquant")
} else if v.Q8 {
args = append(args, "q8")
} else if v.Bin {
args = append(args, "bin")
}
if v.EF > 0 {
args = append(args, "ef", strconv.FormatInt(v.EF, 10))
}
if len(v.SetAttr) > 0 {
args = append(args, "setattr", v.SetAttr)
}
if v.M > 0 {
args = append(args, "m", strconv.FormatInt(v.M, 10))
}
return args
}
// `VADD key [REDUCE dim] (FP32 | VALUES num) vector element [CAS] [NOQUANT | Q8 | BIN] [EF build-exploration-factor] [SETATTR attributes] [M numlinks]`
// note: the API is experimental and may be subject to change.
func (c cmdable) VAddWithArgs(ctx context.Context, key, element string, val Vector, addArgs *VAddArgs) *BoolCmd {
if addArgs == nil {
addArgs = &VAddArgs{}
}
args := []any{"vadd", key}
if addArgs.reduce() > 0 {
args = append(args, "reduce", addArgs.reduce())
}
args = append(args, val.Value()...)
args = append(args, element)
args = addArgs.appendArgs(args)
cmd := NewBoolCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VCARD key`
// note: the API is experimental and may be subject to change.
func (c cmdable) VCard(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "vcard", key)
_ = c(ctx, cmd)
return cmd
}
// `VDIM key`
// note: the API is experimental and may be subject to change.
func (c cmdable) VDim(ctx context.Context, key string) *IntCmd {
cmd := NewIntCmd(ctx, "vdim", key)
_ = c(ctx, cmd)
return cmd
}
// `VEMB key element [RAW]`
// note: the API is experimental and may be subject to change.
func (c cmdable) VEmb(ctx context.Context, key, element string, raw bool) *SliceCmd {
args := []any{"vemb", key, element}
if raw {
args = append(args, "raw")
}
cmd := NewSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VGETATTR key element`
// note: the API is experimental and may be subject to change.
func (c cmdable) VGetAttr(ctx context.Context, key, element string) *StringCmd {
cmd := NewStringCmd(ctx, "vgetattr", key, element)
_ = c(ctx, cmd)
return cmd
}
// `VINFO key`
// note: the API is experimental and may be subject to change.
func (c cmdable) VInfo(ctx context.Context, key string) *MapStringInterfaceCmd {
cmd := NewMapStringInterfaceCmd(ctx, "vinfo", key)
_ = c(ctx, cmd)
return cmd
}
// `VLINKS key element`
// note: the API is experimental and may be subject to change.
func (c cmdable) VLinks(ctx context.Context, key, element string) *StringSliceSliceCmd {
cmd := NewStringSliceSliceCmd(ctx, "vlinks", key, element)
_ = c(ctx, cmd)
return cmd
}
// `VLINKS key element WITHSCORES`
// note: the API is experimental and may be subject to change.
func (c cmdable) VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceSliceCmd {
cmd := NewVectorScoreSliceSliceCmd(ctx, "vlinks", key, element, "withscores")
_ = c(ctx, cmd)
return cmd
}
// `VRANDMEMBER key`
// note: the API is experimental and may be subject to change.
func (c cmdable) VRandMember(ctx context.Context, key string) *StringCmd {
cmd := NewStringCmd(ctx, "vrandmember", key)
_ = c(ctx, cmd)
return cmd
}
// `VRANDMEMBER key [count]`
// note: the API is experimental and may be subject to change.
func (c cmdable) VRandMemberCount(ctx context.Context, key string, count int) *StringSliceCmd {
cmd := NewStringSliceCmd(ctx, "vrandmember", key, count)
_ = c(ctx, cmd)
return cmd
}
// `VREM key element`
// note: the API is experimental and may be subject to change.
func (c cmdable) VRem(ctx context.Context, key, element string) *BoolCmd {
cmd := NewBoolCmd(ctx, "vrem", key, element)
_ = c(ctx, cmd)
return cmd
}
// `VSETATTR key element "{ JSON obj }"`
// The `attr` 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.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) VSetAttr(ctx context.Context, key, element string, attr interface{}) *BoolCmd {
var attrStr string
var err error
switch v := attr.(type) {
case string:
attrStr = v
case []byte:
attrStr = string(v)
default:
var bytes []byte
bytes, err = json.Marshal(v)
if err != nil {
// If marshalling fails, create the command and set the error; this command won't be executed.
cmd := NewBoolCmd(ctx, "vsetattr", key, element, "")
cmd.SetErr(err)
return cmd
}
attrStr = string(bytes)
}
cmd := NewBoolCmd(ctx, "vsetattr", key, element, attrStr)
_ = c(ctx, cmd)
return cmd
}
// `VClearAttributes` clear attributes on a vector set element.
// The implementation of `VClearAttributes` is execute command `VSETATTR key element ""`.
// note: the API is experimental and may be subject to change.
func (c cmdable) VClearAttributes(ctx context.Context, key, element string) *BoolCmd {
cmd := NewBoolCmd(ctx, "vsetattr", key, element, "")
_ = c(ctx, cmd)
return cmd
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element)`
// note: the API is experimental and may be subject to change.
func (c cmdable) VSim(ctx context.Context, key string, val Vector) *StringSliceCmd {
return c.VSimWithArgs(ctx, key, val, &VSimArgs{})
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element) WITHSCORES`
// note: the API is experimental and may be subject to change.
func (c cmdable) VSimWithScores(ctx context.Context, key string, val Vector) *VectorScoreSliceCmd {
return c.VSimWithArgsWithScores(ctx, key, val, &VSimArgs{})
}
type VSimArgs struct {
Count int64
EF int64
Filter string
FilterEF int64
Truth bool
NoThread bool
Epsilon float64
}
func (v VSimArgs) appendArgs(args []any) []any {
if v.Count > 0 {
args = append(args, "count", v.Count)
}
if v.EF > 0 {
args = append(args, "ef", v.EF)
}
if len(v.Filter) > 0 {
args = append(args, "filter", v.Filter)
}
if v.FilterEF > 0 {
args = append(args, "filter-ef", v.FilterEF)
}
if v.Truth {
args = append(args, "truth")
}
if v.NoThread {
args = append(args, "nothread")
}
if v.Epsilon > 0 {
args = append(args, "epsilon", v.Epsilon)
}
return args
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [COUNT num] [EPSILON delta]
// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]`
// note: the API is experimental and may be subject to change.
func (c cmdable) VSimWithArgs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *StringSliceCmd {
if simArgs == nil {
simArgs = &VSimArgs{}
}
args := []any{"vsim", key}
args = append(args, val.Value()...)
args = simArgs.appendArgs(args)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHSCORES] [COUNT num] [EPSILON delta]
// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]`
// note: the API is experimental and may be subject to change.
func (c cmdable) VSimWithArgsWithScores(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorScoreSliceCmd {
if simArgs == nil {
simArgs = &VSimArgs{}
}
args := []any{"vsim", key}
args = append(args, val.Value()...)
args = append(args, "withscores")
args = simArgs.appendArgs(args)
cmd := NewVectorInfoSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHATTRIBS] [COUNT num] [EPSILON delta]
// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]`
// WITHATTRIBS is only available in Redis v8.2.0+
// note: the API is experimental and may be subject to change.
func (c cmdable) VSimWithArgsWithAttribs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorAttribSliceCmd {
if simArgs == nil {
simArgs = &VSimArgs{}
}
args := []any{"vsim", key}
args = append(args, val.Value()...)
args = append(args, "withattribs")
args = simArgs.appendArgs(args)
cmd := NewVectorAttribSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHSCORES] [WITHATTRIBS] [COUNT num] [EPSILON delta]
// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]`
// WITHATTRIBS is only available in Redis v8.2.0+
// note: the API is experimental and may be subject to change.
func (c cmdable) VSimWithArgsWithScoresWithAttribs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorScoreAttribSliceCmd {
if simArgs == nil {
simArgs = &VSimArgs{}
}
args := []any{"vsim", key}
args = append(args, val.Value()...)
args = append(args, "withscores", "withattribs")
args = simArgs.appendArgs(args)
cmd := NewVectorScoreAttribSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VRANGE key start end count`
// a negative count means to return all the elements in the vector set.
// note: the API is experimental and may be subject to change.
func (c cmdable) VRange(ctx context.Context, key, start, end string, count int64) *StringSliceCmd {
args := []any{"vrange", key, start, end, count}
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// `VISMEMBER key element`
// Check if an element exists in a vector set.
// note: the API is experimental and may be subject to change.
func (c cmdable) VIsMember(ctx context.Context, key, element string) *BoolCmd {
cmd := NewBoolCmd(ctx, "vismember", key, element)
_ = c(ctx, cmd)
return cmd
}
package redis
// Version is the current release version.
func Version() string {
return "9.23.0-beta.1"
}