1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-24 07:59:20 +03:00

fix(otel): count connections that predate Init (#4089)

* fix(otel): count connections that predate Init

Connections created before the metric callbacks were set never emitted
+1, but still emitted -1 when they left idle or were closed, so
db.client.connection.count went negative when a client was created
before redisotel-native's Init.

Existing connections are now counted when the callbacks are set and
uncounted when they are cleared.

Closes #4083

* fix(otel): hold pools weakly, uncount on shutdown

Keep weak pointers to live pools so a client that is dropped without
Close can still be collected.

SetGlobalRecorder(nil) now clears the pool callbacks before swapping in
the noop recorder, so the -1 for existing connections reaches the
recorder being removed.

* fix(otel): read count callback with the map change

Removal paths, Close and PubSub tracking now read the connection count
callback under the same lock as the map change, so a concurrent
SetAllMetricCallbacks can't land between them and leave the counter off
by one.

* fix(otel): send count deltas to the recorder that set them

* fix(otel): drop collected pools from the registry

* fix(otel): uncount the refreshed spare under connsMu

* fix(otel): close pubsub conns after releasing trackMu

* fix(otel): serialize SetGlobalRecorder

* test(otel): check per-state counts under churn

* docs(otel): note lock order, relax Init timing

* fix(otel): drop unpooled spare from idleConns

* fix(otel): drop unpooled idle conns in removeConn

* docs(otel): count callback must not re-enter
This commit is contained in:
Ninad Sutrave
2026-10-19 16:27:17 +05:30
committed by GitHub
parent 3a4aeb5d05
commit 04fc134839
9 changed files with 546 additions and 43 deletions

View File

@@ -73,7 +73,8 @@ func GetObservabilityInstance() *ObservabilityInstance {
}
// Init initializes OpenTelemetry observability globally for all Redis clients.
// This should be called once at application startup, BEFORE creating any Redis clients.
// It is usually called once at application startup. Clients created before Init
// are covered too: their existing connections are counted when Init runs.
// After initialization, all Redis clients will automatically collect and export
// metrics without needing any additional configuration.
func (o *ObservabilityInstance) Init(cfg *Config) error {

View File

@@ -0,0 +1,69 @@
package otel
import (
"context"
"net"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9/internal/pool"
)
type countRecorder struct {
noopRecorder
mu sync.Mutex
count int
}
func (r *countRecorder) RecordConnectionCount(_ context.Context, delta int, cn *pool.Conn, _ string, _ bool) {
if cn.PoolName() != "shutdown-count" {
return
}
r.mu.Lock()
r.count += delta
r.mu.Unlock()
}
func (r *countRecorder) get() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.count
}
func TestShutdownUncountsExistingConns(t *testing.T) {
connPool := pool.NewConnPool(&pool.Options{
Name: "shutdown-count",
Dialer: func(context.Context) (net.Conn, error) {
c, _ := net.Pipe()
return c, nil
},
PoolSize: 2,
MinIdleConns: 2,
MaxConcurrentDials: 2,
PoolTimeout: time.Second,
ConnMaxIdleTime: -1,
})
t.Cleanup(func() { _ = connPool.Close() })
deadline := time.Now().Add(time.Second)
for connPool.Len() != 2 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := connPool.Len(); got != 2 {
t.Fatalf("MinIdleConns: got %d, want 2", got)
}
r := &countRecorder{}
SetGlobalRecorder(r)
if got := r.get(); got != 2 {
SetGlobalRecorder(nil)
t.Fatalf("after SetGlobalRecorder: got %d, want 2", got)
}
SetGlobalRecorder(nil)
if got := r.get(); got != 0 {
t.Fatalf("after SetGlobalRecorder(nil): got %d, want 0", got)
}
}

View File

@@ -120,6 +120,9 @@ type PoolRegistrar interface {
}
var (
// setRecorderMu serializes SetGlobalRecorder, so the recorder swap and the pool callbacks change together.
setRecorderMu sync.Mutex
// recorderMu protects globalRecorder and operation duration callbacks
recorderMu sync.RWMutex
@@ -166,16 +169,19 @@ func Enabled() bool {
// SetGlobalRecorder sets the global recorder (called by Init() in extra/redisotel-native)
func SetGlobalRecorder(r Recorder) {
recorderMu.Lock()
setRecorderMu.Lock()
defer setRecorderMu.Unlock()
if r == nil {
// Unregister pool callbacks first so their final deltas reach the current recorder.
pool.SetAllMetricCallbacks(nil)
recorderMu.Lock()
globalRecorder = noopRecorder{}
operationDurationCallback = nil
pipelineOperationDurationCallback = nil
recorderMu.Unlock()
// Unregister all pool metric callbacks atomically
pool.SetAllMetricCallbacks(nil)
return
}
recorderMu.Lock()
globalRecorder = r
// Register operation duration callbacks
@@ -213,8 +219,9 @@ func SetGlobalRecorder(r Recorder) {
ConnectionClosed: func(ctx context.Context, cn *pool.Conn, reason string, err error) {
getRecorder().RecordConnectionClosed(ctx, cn, reason, err)
},
// Uses r, not getRecorder(), so a delta captured before Shutdown reaches the recorder that counted it.
ConnectionCount: func(ctx context.Context, delta int, cn *pool.Conn, state string, isPubSub bool) {
getRecorder().RecordConnectionCount(ctx, delta, cn, state, isPubSub)
r.RecordConnectionCount(ctx, delta, cn, state, isPubSub)
},
PendingRequests: func(ctx context.Context, delta int, cn *pool.Conn, poolName string) {
getRecorder().RecordPendingRequests(ctx, delta, cn, poolName)

View File

@@ -0,0 +1,33 @@
package pool
import (
"context"
"net"
"runtime"
"testing"
"time"
"weak"
)
func TestLivePoolsDropCollectedPools(t *testing.T) {
dialer := func(context.Context) (net.Conn, error) {
c, _ := net.Pipe()
return c, nil
}
connPool := weak.Make(NewConnPool(&Options{Dialer: dialer, PoolSize: 1, PoolTimeout: time.Second}))
pubSubPool := weak.Make(NewPubSubPool(&Options{}, nil))
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
runtime.GC()
livePoolsMu.Lock()
_, connLive := liveConnPools[connPool]
_, pubSubLive := livePubSubPools[pubSubPool]
livePoolsMu.Unlock()
if !connLive && !pubSubLive {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("pools collected without Close are still registered")
}

View File

@@ -5,9 +5,11 @@ import (
"errors"
"math/rand"
"net"
"runtime"
"sync"
"sync/atomic"
"time"
"weak"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/proto"
@@ -88,6 +90,13 @@ var (
// metricCallbackMu protects all global metric callback functions for thread-safe access.
metricCallbackMu sync.RWMutex
// livePoolsMu protects the pools whose connections are counted when metric callbacks change.
// Lock order: livePoolsMu, then ConnPool.connsMu, then PubSubPool.trackMu, then metricCallbackMu.
// SetAllMetricCallbacks holds every pool's connsMu at once; never hold two pools' connsMu elsewhere.
livePoolsMu sync.Mutex
liveConnPools = make(map[weak.Pointer[ConnPool]]struct{})
livePubSubPools = make(map[weak.Pointer[PubSubPool]]struct{})
// Global metric callbacks for connection state changes
metricConnectionStateChangeCallback func(ctx context.Context, cn *Conn, fromState, toState string)
@@ -152,6 +161,8 @@ var (
noExpiration = maxTime
)
type connectionCountCallback = func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool)
// MetricCallbacks holds all metric callback functions.
// Use SetAllMetricCallbacks to register all callbacks atomically.
type MetricCallbacks struct {
@@ -181,6 +192,7 @@ type MetricCallbacks struct {
// delta: +1 when connection added, -1 when connection removed
// state: connection state (e.g., "idle", "used")
// isPubSub: true if this is a PubSub connection
// It may run with connsMu or trackMu held and must not re-enter the pool.
ConnectionCount func(ctx context.Context, delta int, cn *Conn, state string, isPubSub bool)
// PendingRequests is called when pending requests count changes (UpDownCounter)
@@ -199,7 +211,54 @@ type MetricCallbacks struct {
// 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.
//
// Connections that already exist are counted when callbacks are set and
// uncounted when they are cleared, so db.client.connection.count stays balanced.
func SetAllMetricCallbacks(callbacks *MetricCallbacks) {
livePoolsMu.Lock()
defer livePoolsMu.Unlock()
connPools := make([]*ConnPool, 0, len(liveConnPools))
for wp := range liveConnPools {
if p := wp.Value(); p != nil {
connPools = append(connPools, p)
} else {
delete(liveConnPools, wp)
}
}
pubSubPools := make([]*PubSubPool, 0, len(livePubSubPools))
for wp := range livePubSubPools {
if p := wp.Value(); p != nil {
pubSubPools = append(pubSubPools, p)
} else {
delete(livePubSubPools, wp)
}
}
for _, p := range connPools {
p.connsMu.Lock()
}
for _, p := range pubSubPools {
p.trackMu.Lock()
}
defer func() {
for _, p := range connPools {
p.connsMu.Unlock()
}
for _, p := range pubSubPools {
p.trackMu.Unlock()
}
}()
ctx := context.Background()
if cb := getMetricConnectionCountCallback(); cb != nil {
recordLiveConnCounts(ctx, cb, -1, connPools, pubSubPools)
}
setMetricCallbacks(callbacks)
if cb := getMetricConnectionCountCallback(); cb != nil {
recordLiveConnCounts(ctx, cb, 1, connPools, pubSubPools)
}
}
func setMetricCallbacks(callbacks *MetricCallbacks) {
metricCallbackMu.Lock()
defer metricCallbackMu.Unlock()
@@ -308,6 +367,30 @@ func getMetricConnectionCountCallback() func(ctx context.Context, delta int, cn
return cb
}
// recordLiveConnCounts records delta for every connection in the given pools.
// The caller must hold the connsMu of every ConnPool and the trackMu of every PubSubPool.
func recordLiveConnCounts(ctx context.Context, cb connectionCountCallback, delta int, connPools []*ConnPool, pubSubPools []*PubSubPool) {
for _, p := range connPools {
idle := make(map[uint64]struct{}, len(p.idleConns))
for _, cn := range p.idleConns {
idle[cn.GetID()] = struct{}{}
}
for id, cn := range p.conns {
state := MetricStateUsed
if _, ok := idle[id]; ok {
state = MetricStateIdle
}
cb(ctx, delta, cn, state, false)
}
}
for _, p := range pubSubPools {
p.activeConns.Range(func(_, value any) bool {
cb(ctx, delta, value.(*Conn), MetricStateUsed, true)
return true
})
}
}
// getMetricPendingRequestsCallback returns the metric callback for pending requests changes (UpDownCounter).
func getMetricPendingRequestsCallback() func(ctx context.Context, delta int, cn *Conn, poolName string) {
metricCallbackMu.RLock()
@@ -471,6 +554,17 @@ func NewConnPool(opt *Options) *ConnPool {
idleConns: make([]*Conn, 0, opt.PoolSize),
}
wp := weak.Make(p)
livePoolsMu.Lock()
liveConnPools[wp] = struct{}{}
livePoolsMu.Unlock()
// Drops the entry for a pool that is collected without Close.
runtime.AddCleanup(p, func(wp weak.Pointer[ConnPool]) {
livePoolsMu.Lock()
delete(liveConnPools, wp)
livePoolsMu.Unlock()
}, wp)
// Only create MinIdleConns if explicitly requested (> 0)
// This avoids creating connections during pool initialization for tests
if opt.MinIdleConns > 0 {
@@ -583,12 +677,17 @@ func (p *ConnPool) refreshStaleSpare(ctx context.Context, nowNs int64) {
p.connsMu.Unlock()
return
}
copy(p.idleConns, p.idleConns[1:])
p.idleConns = p.idleConns[:len(p.idleConns)-1]
p.idleConnsLen.Add(-1)
removed := p.removeConn(oldest)
countCb := getMetricConnectionCountCallback()
p.connsMu.Unlock()
_ = p.CloseConn(ctx, oldest, CloseReasonStale, MetricStateIdle)
if hookManager := p.hookManager.Load(); hookManager != nil {
hookManager.ProcessOnRemove(ctx, oldest, errors.New(CloseReasonStale))
}
if removed {
p.recordConnectionMetrics(ctx, oldest, CloseReasonStale, MetricStateIdle, countCb)
}
_ = p.closeConn(oldest)
p.checkMinIdleConns()
}
@@ -1522,6 +1621,7 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
var shouldCloseConn bool
var removedFromPool bool
var countCb connectionCountCallback
if p.cfg.MaxIdleConns == 0 || p.idleConnsLen.Load() < p.cfg.MaxIdleConns {
// Hot path optimization: try fast IN_USE → IDLE transition
@@ -1549,7 +1649,7 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
hookManager.ProcessOnRemove(ctx, cn, errHookRequestedRemoval)
}
shouldCloseConn = true
removedFromPool = p.removeConnWithLock(cn)
removedFromPool, countCb = 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)
@@ -1609,8 +1709,8 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
if cb := getMetricConnectionStateChangeCallback(); cb != nil {
cb(ctx, cn, MetricStateUsed, "")
}
if cb := getMetricConnectionCountCallback(); cb != nil {
cb(ctx, -1, cn, "used", false)
if countCb != nil {
countCb(ctx, -1, cn, "used", false)
}
}
}
@@ -1619,7 +1719,7 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
if hookManager != nil {
hookManager.ProcessOnRemove(ctx, cn, errConnEvictedIdle)
}
removedFromPool = p.removeConnWithLock(cn)
removedFromPool, countCb = p.removeConnWithLock(cn)
// Only emit if we actually removed it from the map (not already taken by Close()).
if removedFromPool {
@@ -1628,8 +1728,8 @@ func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) {
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 countCb != nil {
countCb(ctx, -1, cn, "used", false)
}
}
}
@@ -1677,7 +1777,7 @@ func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason erro
hookManager.ProcessOnRemove(ctx, cn, reason)
}
removed := p.removeConnWithLock(cn)
removed, countCb := p.removeConnWithLock(cn)
if freeTurn {
p.freeTurn()
@@ -1691,8 +1791,8 @@ func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason erro
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)
if countCb != nil {
countCb(ctx, -1, cn, "used", false)
}
}
@@ -1725,28 +1825,28 @@ func (p *ConnPool) CloseConn(ctx context.Context, cn *Conn, reason string, fromS
hookManager.ProcessOnRemove(ctx, cn, errors.New(reason))
}
removed := p.removeConnWithLock(cn)
removed, countCb := 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)
p.recordConnectionMetrics(ctx, cn, reason, fromState, countCb)
}
return p.closeConn(cn)
}
func (p *ConnPool) recordConnectionMetrics(ctx context.Context, cn *Conn, reason string, fromState string) {
func (p *ConnPool) recordConnectionMetrics(ctx context.Context, cn *Conn, reason string, fromState string, countCb connectionCountCallback) {
// 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 countCb != nil && fromState != "" {
countCb(ctx, -1, cn, fromState, false)
}
if cb := getMetricConnectionClosedCallback(); cb != nil {
@@ -1758,11 +1858,12 @@ func (p *ConnPool) recordConnectionMetrics(ctx context.Context, cn *Conn, reason
// 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 {
// shutdown race between Close() and concurrent removal paths. The returned count
// callback is read under the same lock, so it matches SetAllMetricCallbacks' snapshot.
func (p *ConnPool) removeConnWithLock(cn *Conn) (bool, connectionCountCallback) {
p.connsMu.Lock()
defer p.connsMu.Unlock()
return p.removeConn(cn)
return p.removeConn(cn), getMetricConnectionCountCallback()
}
// removeConn removes a connection from the pool's internal data structures.
@@ -1778,13 +1879,14 @@ func (p *ConnPool) removeConn(cn *Conn) bool {
// 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
}
}
// An unpooled conn can be idle too: putIdleConn parks an undelivered
// overflow dial in idleConns.
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
@@ -1929,6 +2031,7 @@ func (p *ConnPool) RetireConns(ctx context.Context, conns []*Conn, reason string
}
cn.MarkCloseOnPut(reason)
}
countCb := getMetricConnectionCountCallback()
p.connsMu.Unlock()
if hookManager := p.hookManager.Load(); hookManager != nil {
@@ -1937,7 +2040,7 @@ func (p *ConnPool) RetireConns(ctx context.Context, conns []*Conn, reason string
}
}
for _, cn := range toClose {
p.recordConnectionMetrics(ctx, cn, reason, MetricStateIdle)
p.recordConnectionMetrics(ctx, cn, reason, MetricStateIdle, countCb)
_ = p.closeConn(cn)
}
p.checkMinIdleConns()
@@ -1961,7 +2064,7 @@ func (p *ConnPool) Filter(fn func(*Conn) bool) error {
if _, isIdle := idleConnSet[cn]; isIdle {
// Idle connection - remove from pool and close.
p.removeConn(cn)
p.recordConnectionMetrics(ctx, cn, CloseReasonFailover, MetricStateIdle)
p.recordConnectionMetrics(ctx, cn, CloseReasonFailover, MetricStateIdle, getMetricConnectionCountCallback())
err = p.closeConn(cn)
} else {
// Used connection - set closeReason and close the connection.
@@ -2119,11 +2222,14 @@ func (p *ConnPool) Close() error {
var firstErr error
nowNs := time.Now().UnixNano()
p.connsMu.Lock()
livePoolsMu.Lock()
delete(liveConnPools, weak.Make(p))
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()
livePoolsMu.Unlock()
idleSet := make(map[uint64]struct{}, len(p.idleConns))
for _, cn := range p.idleConns {
idleSet[cn.GetID()] = struct{}{}

View File

@@ -352,7 +352,10 @@ func TestDrainIdleConnsReportsBorrowedConnectionStates(t *testing.T) {
var mu sync.Mutex
var events []event
pool.SetAllMetricCallbacks(&pool.MetricCallbacks{
ConnectionCount: func(_ context.Context, delta int, _ *pool.Conn, state string, _ bool) {
ConnectionCount: func(_ context.Context, delta int, c *pool.Conn, state string, _ bool) {
if c != cn {
return
}
mu.Lock()
events = append(events, event{delta: delta, state: state})
mu.Unlock()
@@ -366,6 +369,7 @@ func TestDrainIdleConnsReportsBorrowedConnectionStates(t *testing.T) {
mu.Lock()
defer mu.Unlock()
want := []event{
{delta: 1, state: pool.MetricStateIdle},
{delta: -1, state: pool.MetricStateIdle},
{delta: 1, state: pool.MetricStateUsed},
{delta: -1, state: pool.MetricStateUsed},
@@ -376,6 +380,170 @@ func TestDrainIdleConnsReportsBorrowedConnectionStates(t *testing.T) {
}
}
func TestConnectionCountIncludesExistingConns(t *testing.T) {
connPool := pool.NewConnPool(&pool.Options{
Name: "existing-conns",
Dialer: dummyDialer,
PoolSize: 4,
MinIdleConns: 4,
MaxConcurrentDials: 4,
PoolTimeout: time.Second,
ConnMaxIdleTime: -1,
})
t.Cleanup(func() { _ = connPool.Close() })
deadline := time.Now().Add(time.Second)
for connPool.Len() != 4 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := connPool.Len(); got != 4 {
t.Fatalf("MinIdleConns: got %d, want 4", got)
}
var mu sync.Mutex
counts := map[string]int{}
pool.SetAllMetricCallbacks(&pool.MetricCallbacks{
ConnectionCount: func(_ context.Context, delta int, cn *pool.Conn, state string, _ bool) {
if cn.PoolName() != "existing-conns" {
return
}
mu.Lock()
counts[state] += delta
mu.Unlock()
},
})
mu.Lock()
got := map[string]int{pool.MetricStateIdle: counts[pool.MetricStateIdle], pool.MetricStateUsed: counts[pool.MetricStateUsed]}
mu.Unlock()
want := map[string]int{pool.MetricStateIdle: 4, pool.MetricStateUsed: 0}
if !reflect.DeepEqual(got, want) {
pool.SetAllMetricCallbacks(nil)
t.Fatalf("after attach: got %v, want %v", got, want)
}
pool.SetAllMetricCallbacks(nil)
mu.Lock()
defer mu.Unlock()
if counts[pool.MetricStateIdle] != 0 || counts[pool.MetricStateUsed] != 0 {
t.Fatalf("after detach: got %v, want all zero", counts)
}
}
func TestConnectionCountBalancedWhileCallbacksChange(t *testing.T) {
opt := &pool.Options{
Name: "callback-churn",
Dialer: dummyDialer,
PoolSize: 8,
MinIdleConns: 2,
MaxConcurrentDials: 8,
PoolTimeout: time.Second,
ConnMaxIdleTime: -1,
}
connPool := pool.NewConnPool(opt)
t.Cleanup(func() { _ = connPool.Close() })
pubSubPool := pool.NewPubSubPool(opt, func(ctx context.Context, _, _ string) (net.Conn, error) {
return dummyDialer(ctx)
})
t.Cleanup(func() { _ = pubSubPool.Close() })
var mu sync.Mutex
counts := map[string]int{}
callbacks := &pool.MetricCallbacks{
ConnectionCount: func(_ context.Context, delta int, cn *pool.Conn, state string, _ bool) {
if cn.PoolName() != "callback-churn" {
return
}
mu.Lock()
counts[state] += delta
mu.Unlock()
},
}
ctx := context.Background()
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for n := 0; ; n++ {
select {
case <-stop:
return
default:
}
cn, err := connPool.Get(ctx)
if err != nil {
continue
}
if (n+i)%3 == 0 {
connPool.Remove(ctx, cn, nil)
} else {
connPool.Put(ctx, cn)
}
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
cn, err := pubSubPool.NewConn(ctx, "tcp", "", nil)
if err != nil {
continue
}
pubSubPool.TrackConn(cn)
pubSubPool.UntrackConn(cn)
_ = cn.Close()
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
p := pool.NewConnPool(&pool.Options{
Name: "callback-churn",
Dialer: dummyDialer,
PoolSize: 1,
MaxConcurrentDials: 1,
PoolTimeout: time.Second,
ConnMaxIdleTime: -1,
})
if cn, err := p.Get(ctx); err == nil {
p.Put(ctx, cn)
}
_ = p.Close()
}
}()
for i := 0; i < 200; i++ {
pool.SetAllMetricCallbacks(callbacks)
pool.SetAllMetricCallbacks(nil)
}
close(stop)
wg.Wait()
mu.Lock()
defer mu.Unlock()
for state, count := range counts {
if count != 0 {
t.Fatalf("connection count for state %q after detach: got %d, want 0", state, count)
}
}
}
func TestDrainIdleConnsSkipsUninitializedMinIdleConns(t *testing.T) {
connPool := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,

View File

@@ -3,8 +3,10 @@ package pool
import (
"context"
"net"
"runtime"
"sync"
"sync/atomic"
"weak"
)
// PubSubStats contains pub/sub connection pool stats.
@@ -24,17 +26,30 @@ type PubSubPool struct {
// Map to track active PubSub connections
activeConns sync.Map // map[uint64]*Conn (connID -> conn)
closed atomic.Bool
stats PubSubStats
// trackMu pairs activeConns changes with the connection count callback they use.
trackMu sync.Mutex
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{
p := &PubSubPool{
opt: opt,
netDialer: netDialer,
}
wp := weak.Make(p)
livePoolsMu.Lock()
livePubSubPools[wp] = struct{}{}
livePoolsMu.Unlock()
// Drops the entry for a pool that is collected without Close.
runtime.AddCleanup(p, func(wp weak.Pointer[PubSubPool]) {
livePoolsMu.Lock()
delete(livePubSubPools, wp)
livePoolsMu.Unlock()
}, wp)
return p
}
func (p *PubSubPool) NewConn(ctx context.Context, network string, addr string, channels []string) (*Conn, error) {
@@ -55,10 +70,13 @@ func (p *PubSubPool) NewConn(ctx context.Context, network string, addr string, c
}
func (p *PubSubPool) TrackConn(cn *Conn) {
p.trackMu.Lock()
atomic.AddUint32(&p.stats.Active, 1)
p.activeConns.Store(cn.GetID(), cn)
cb := getMetricConnectionCountCallback()
p.trackMu.Unlock()
// Emit +1 used for PubSub connection
if cb := getMetricConnectionCountCallback(); cb != nil {
if cb != nil {
cb(context.Background(), 1, cn, "used", true)
}
}
@@ -66,20 +84,29 @@ func (p *PubSubPool) TrackConn(cn *Conn) {
func (p *PubSubPool) UntrackConn(cn *Conn) {
// LoadAndDelete ensures each connection is only decremented once,
// guarding against double-decrement if Close() already untracked it.
p.trackMu.Lock()
if _, loaded := p.activeConns.LoadAndDelete(cn.GetID()); !loaded {
p.trackMu.Unlock()
return
}
cb := getMetricConnectionCountCallback()
p.trackMu.Unlock()
atomic.AddUint32(&p.stats.Active, ^uint32(0))
atomic.AddUint32(&p.stats.Untracked, 1)
// Emit -1 used for PubSub connection
if cb := getMetricConnectionCountCallback(); cb != nil {
if cb != nil {
cb(context.Background(), -1, cn, "used", true)
}
}
func (p *PubSubPool) Close() error {
p.closed.Store(true)
livePoolsMu.Lock()
delete(livePubSubPools, weak.Make(p))
p.trackMu.Lock()
cb := getMetricConnectionCountCallback()
livePoolsMu.Unlock()
var conns []*Conn
p.activeConns.Range(func(key, value interface{}) bool {
cn := value.(*Conn)
// Use LoadAndDelete to atomically claim ownership of this entry.
@@ -93,9 +120,15 @@ func (p *PubSubPool) Close() error {
if cb != nil {
cb(context.Background(), -1, cn, "used", true)
}
_ = cn.Close()
conns = append(conns, cn)
return true
})
p.trackMu.Unlock()
// Close outside trackMu: cn.Close runs the connection's onClose callback.
for _, cn := range conns {
_ = cn.Close()
}
return nil
}

View File

@@ -0,0 +1,85 @@
package pool
import (
"context"
"net"
"testing"
"time"
)
func newUnpooledIdleConn(t *testing.T) (*ConnPool, *Conn) {
t.Helper()
p := NewConnPool(&Options{
Dialer: func(context.Context) (net.Conn, error) {
c, _ := net.Pipe()
return c, nil
},
PoolSize: 2,
PoolTimeout: time.Second,
DialTimeout: time.Second,
ConnMaxIdleTime: time.Minute,
})
t.Cleanup(func() { _ = p.Close() })
// An undelivered dial made while the pool was full is parked in
// idleConns by putIdleConn without being pooled.
cn, err := p.newConn(context.Background(), false)
if err != nil {
t.Fatal(err)
}
p.putIdleConn(context.Background(), cn)
return p, cn
}
func assertNotIdle(t *testing.T, p *ConnPool, cn *Conn) {
t.Helper()
p.connsMu.Lock()
defer p.connsMu.Unlock()
for _, ic := range p.idleConns {
if ic == cn {
t.Fatal("removed unpooled conn is still in idleConns")
}
}
}
// TestRefreshStaleSpareDropsUnpooledConn checks an unpooled conn parked in
// idleConns is dropped from idleConns when it is refreshed as the stale spare.
func TestRefreshStaleSpareDropsUnpooledConn(t *testing.T) {
p, cn := newUnpooledIdleConn(t)
p.minIdleFloor.Store(1)
now := time.Now()
cn.SetUsedAt(now.Add(-time.Hour))
p.refreshStaleSpare(context.Background(), now.UnixNano())
assertNotIdle(t, p, cn)
// The floor is refilled with a fresh pooled spare.
deadline := time.Now().Add(5 * time.Second)
for p.Len() != 1 || p.IdleLen() != 1 {
if time.Now().After(deadline) {
t.Fatalf("Len=%d IdleLen=%d, want 1/1", p.Len(), p.IdleLen())
}
time.Sleep(time.Millisecond)
}
}
// TestRemoveConnDropsUnpooledIdleConn checks the other removeConn callers
// that act on idle conns drop an unpooled one from idleConns too.
func TestRemoveConnDropsUnpooledIdleConn(t *testing.T) {
t.Run("RetireConns", func(t *testing.T) {
p, cn := newUnpooledIdleConn(t)
p.RetireConns(context.Background(), []*Conn{cn}, CloseReasonStale)
assertNotIdle(t, p, cn)
if n := p.IdleLen(); n != 0 {
t.Fatalf("IdleLen=%d, want 0", n)
}
})
t.Run("Filter", func(t *testing.T) {
p, cn := newUnpooledIdleConn(t)
_ = p.Filter(func(c *Conn) bool { return c == cn })
assertNotIdle(t, p, cn)
if n := p.IdleLen(); n != 0 {
t.Fatalf("IdleLen=%d, want 0", n)
}
})
}

View File

@@ -93,6 +93,7 @@ type OTelConnectionCounter interface {
// delta: +1 when connection added, -1 when connection removed
// state: connection state (e.g., "idle", "used")
// isPubSub: true if this is a PubSub connection
// It may be called with pool locks held, so it must not call back into go-redis.
RecordConnectionCount(ctx context.Context, delta int, cn ConnInfo, state string, isPubSub bool)
// RecordPendingRequests records a change in pending requests (UpDownCounter)