Skip to content

Commit 475c43d

Browse files
Merge branch 'master' into feat/8.8-xnack
2 parents 8b98eab + 1a7ac74 commit 475c43d

13 files changed

Lines changed: 586 additions & 47 deletions

File tree

auth/auth.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,30 @@ type StreamingCredentialsProvider interface {
99
// Subscribe subscribes to the credentials provider for updates.
1010
// It returns the current credentials, a cancel function to unsubscribe from the provider,
1111
// and an error if any.
12+
//
13+
// Implementations MUST be idempotent with respect to listener identity:
14+
// subscribing the same listener value more than once must not produce
15+
// duplicate notifications and must not create multiple independent
16+
// subscriptions that each need to be cancelled separately. Every
17+
// UnsubscribeFunc returned for a given listener must cancel that
18+
// listener's subscription; calling any one of them must be sufficient to
19+
// stop updates to that listener, and calling subsequent ones must be a
20+
// safe no-op. Callers (including go-redis internals) may retain only
21+
// the most recently returned UnsubscribeFunc and rely on it to fully
22+
// unsubscribe the listener.
23+
//
1224
// TODO(ndyakov): Should we add context to the Subscribe method?
1325
Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error)
1426
}
1527

1628
// UnsubscribeFunc is a function that is used to cancel the subscription to the credentials provider.
1729
// It is used to unsubscribe from the provider when the credentials are no longer needed.
30+
//
31+
// Per the StreamingCredentialsProvider.Subscribe contract, if the same
32+
// listener is subscribed multiple times, every UnsubscribeFunc returned for
33+
// that listener must fully unsubscribe it on first invocation, and
34+
// subsequent invocations (from any of the equivalent UnsubscribeFuncs) must
35+
// be a safe no-op.
1836
type UnsubscribeFunc func() error
1937

2038
// CredentialsListener is an interface that defines the methods for a credentials listener.

error.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ func shouldRetry(err error, retryTimeout bool) bool {
105105
// Check for timeout errors (works with wrapped errors)
106106
if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout {
107107
if hasTimeoutFlag {
108+
// A dial error means the TCP connection was never established and the
109+
// command was never sent to the server, so retry is always safe
110+
var opErr *net.OpError
111+
if errors.As(err, &opErr) && opErr.Op == "dial" {
112+
return true
113+
}
108114
return retryTimeout
109115
}
110116
return true

error_test.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package redis_test
33
import (
44
"context"
55
"io"
6+
"net"
67

78
. "github.com/bsm/ginkgo/v2"
89
. "github.com/bsm/gomega"
10+
911
"github.com/redis/go-redis/v9"
1012
"github.com/redis/go-redis/v9/internal/proto"
1113
)
@@ -24,11 +26,9 @@ func (t testTimeout) Error() string {
2426

2527
var _ = Describe("error", func() {
2628
BeforeEach(func() {
27-
2829
})
2930

3031
AfterEach(func() {
31-
3232
})
3333

3434
It("should retry", func() {
@@ -39,15 +39,16 @@ var _ = Describe("error", func() {
3939
context.Canceled: false,
4040
context.DeadlineExceeded: false,
4141
redis.ErrPoolTimeout: true,
42+
&net.OpError{Op: "dial"}: true,
4243
// Use typed errors instead of plain errors.New()
43-
proto.ParseErrorReply([]byte("-ERR max number of clients reached")): true,
44-
proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory")): true,
45-
proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica")): true,
44+
proto.ParseErrorReply([]byte("-ERR max number of clients reached")): true,
45+
proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory")): true,
46+
proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica")): true,
4647
proto.ParseErrorReply([]byte("-ERR Error running script (call to f_abc123): @user_script:1: -READONLY You can't write against a read only replica.")): true,
47-
proto.ParseErrorReply([]byte("-CLUSTERDOWN The cluster is down")): true,
48-
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
49-
proto.ParseErrorReply([]byte("-NOREPLICAS Not enough good replicas to write")): true,
50-
proto.ParseErrorReply([]byte("-ERR other")): false,
48+
proto.ParseErrorReply([]byte("-CLUSTERDOWN The cluster is down")): true,
49+
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
50+
proto.ParseErrorReply([]byte("-NOREPLICAS Not enough good replicas to write")): true,
51+
proto.ParseErrorReply([]byte("-ERR other")): false,
5152
}
5253

5354
for err, expected := range data {

internal/hscan/hscan_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ type TimeData struct {
4848
Time *TimeRFC3339Nano `redis:"login"`
4949
}
5050

51+
type binaryUnmarshaler struct {
52+
s string
53+
}
54+
55+
func (b *binaryUnmarshaler) UnmarshalBinary(data []byte) error {
56+
b.s = string(data)
57+
return nil
58+
}
59+
60+
type BinaryData struct {
61+
Binary *binaryUnmarshaler `redis:"binary"`
62+
}
63+
5164
type i []interface{}
5265

5366
func TestGinkgoSuite(t *testing.T) {
@@ -217,4 +230,9 @@ var _ = Describe("Scan", func() {
217230
Expect(Scan(&tt, i{"time"}, i{now.Format(time.RFC3339Nano)})).NotTo(HaveOccurred())
218231
Expect(now.Unix()).To(Equal(tt.Time.Unix()))
219232
})
233+
It("Implements BinaryUnmarshaler", func() {
234+
var bd BinaryData
235+
Expect(Scan(&bd, i{"binary"}, i{"hello"})).NotTo(HaveOccurred())
236+
Expect(bd.Binary.s).To(Equal("hello"))
237+
})
220238
})

internal/hscan/structmap.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ func (s StructValue) Scan(key string, value string) error {
109109
return scan.ScanRedis(value)
110110
case encoding.TextUnmarshaler:
111111
return scan.UnmarshalText(util.StringToBytes(value))
112+
case encoding.BinaryUnmarshaler:
113+
return scan.UnmarshalBinary(util.StringToBytes(value))
112114
}
113115
}
114116

internal/pool/conn.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ type Conn struct {
102102

103103
pooled bool
104104
pubsub bool
105-
closed atomic.Bool
106105
createdAt time.Time
107106
expiresAt time.Time
108107
poolName string // Name of the pool this connection belongs to (for metrics)
@@ -576,6 +575,41 @@ func (cn *Conn) getEffectiveWriteTimeout(normalTimeout time.Duration) time.Durat
576575
}
577576
}
578577

578+
// SetOnClose installs fn as the callback invoked exactly once when this
579+
// connection is closed (via Conn.Close).
580+
//
581+
// IMPORTANT: SetOnClose OVERWRITES any previously installed callback — it
582+
// does not compose, chain, or deduplicate. A Conn has room for a single
583+
// onClose hook by design, because its lifecycle is bounded (a Conn is
584+
// created, optionally re-initialized on its own net.Conn, and then closed
585+
// once) and the pool's OnRemove hooks handle any registry-level cleanup
586+
// that must survive the net.Conn being swapped.
587+
//
588+
// This has a subtle implication for per-connection subscriptions such as
589+
// the unsubscribe function returned by StreamingCredentialsProvider
590+
// (e.g. EntraID token rotation): if SetOnClose is called twice on the
591+
// same Conn with DIFFERENT unsubscribe closures — for example because
592+
// initConn ran a second time and obtained a fresh Subscribe() —
593+
// the previous unsubscribe is dropped and will NEVER run, leaking a
594+
// subscription on the provider. Callers must therefore ensure either:
595+
//
596+
// - the provider's Subscribe is idempotent for the same listener (the
597+
// streaming credentials Manager deduplicates listeners by connection
598+
// id, so re-Subscribe returns an equivalent unsubscribe), OR
599+
// - the previous callback has already been invoked before SetOnClose is
600+
// called again.
601+
//
602+
// Design note: unlike the client-level onCloseHooks registry (see
603+
// redis.baseClient), there is intentionally NO named-hook dedup or
604+
// multi-callback support on Conn. This is a deliberate trade-off to keep
605+
// the Conn object slim — a pool can hold thousands of Conn values and
606+
// each one is a hot allocation, so paying for a sync.Mutex plus a
607+
// map[string]func() error per connection to support a feature that would
608+
// only be used by at most one subsystem today (streaming credentials) is
609+
// not worth the per-connection memory and allocation cost. For a single
610+
// Conn there is at most one meaningful close callback at any point in
611+
// time, and a richer registry here would not even solve the "stale
612+
// closure" hazard described above.
579613
func (cn *Conn) SetOnClose(fn func() error) {
580614
cn.onClose = fn
581615
}
@@ -882,12 +916,10 @@ func (cn *Conn) WithWriter(
882916
}
883917

884918
func (cn *Conn) IsClosed() bool {
885-
return cn.closed.Load() || cn.stateMachine.GetState() == StateClosed
919+
return cn.stateMachine.GetState() == StateClosed
886920
}
887921

888922
func (cn *Conn) Close() error {
889-
cn.closed.Store(true)
890-
891923
// Transition to CLOSED state
892924
cn.stateMachine.Transition(StateClosed)
893925

internal/pool/main_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,22 @@ func (d *dummyRawConn) Close() {
121121
d.closed = true
122122
d.mu.Unlock()
123123
}
124+
125+
// failCloseConn wraps a dummyConn but returns a configured error on Close().
126+
// This is used to simulate TLS closeNotify errors or network timeouts during connection close.
127+
type failCloseConn struct {
128+
*dummyConn
129+
closeErr error
130+
}
131+
132+
func (f *failCloseConn) Close() error {
133+
// Close the underlying raw conn so that connCheck sees a closed fd,
134+
// matching real-world behavior (e.g., TLS closeNotify failure after
135+
// the OS-level socket is torn down).
136+
f.dummyConn.rawConn.Close()
137+
return f.closeErr
138+
}
139+
140+
func (f *failCloseConn) SyscallConn() (syscall.RawConn, error) {
141+
return f.dummyConn.SyscallConn()
142+
}

internal/pool/pool.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,7 @@ func (p *ConnPool) Close() error {
15721572
}
15731573

15741574
var firstErr error
1575+
nowNs := time.Now().UnixNano()
15751576
p.connsMu.Lock()
15761577

15771578
// Emit -1 for each connection. Since all idle↔used transitions happen
@@ -1583,6 +1584,10 @@ func (p *ConnPool) Close() error {
15831584
}
15841585
ctx := context.Background()
15851586
for _, cn := range p.conns {
1587+
// Check health before closing, since closeConn invalidates the
1588+
// underlying fd and would make connCheck (inside isHealthyConn)
1589+
// always fail with EBADF.
1590+
healthy := p.isHealthyConn(cn, nowNs)
15861591
if cb != nil {
15871592
if _, isIdle := idleSet[cn.GetID()]; isIdle {
15881593
cb(ctx, -1, cn, "idle", false)
@@ -1594,7 +1599,11 @@ func (p *ConnPool) Close() error {
15941599
closedCb(ctx, cn, "pool_shutdown", nil)
15951600
}
15961601
if err := p.closeConn(cn); err != nil && firstErr == nil {
1597-
firstErr = err
1602+
// Suppress close errors for stale connections, consistent
1603+
// with how Get() handles them (see CloseReasonStale path).
1604+
if healthy {
1605+
firstErr = err
1606+
}
15981607
}
15991608
}
16001609
p.conns = nil

internal/pool/pool_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,65 @@ var _ = Describe("queuedNewConn", func() {
13971397
"dialsQueue should be empty after all requests complete")
13981398
})
13991399

1400+
Describe("Close suppresses errors for stale connections", func() {
1401+
It("should not return close error when connection is stale due to ConnMaxIdleTime", func() {
1402+
closeErr := fmt.Errorf("tls: failed to send closeNotify alert (but connection was closed anyway): write tcp: connection timed out")
1403+
1404+
testPool := pool.NewConnPool(&pool.Options{
1405+
Dialer: func(ctx context.Context) (net.Conn, error) {
1406+
return &failCloseConn{
1407+
dummyConn: &dummyConn{rawConn: new(dummyRawConn)},
1408+
closeErr: closeErr,
1409+
}, nil
1410+
},
1411+
PoolSize: 1,
1412+
MaxConcurrentDials: 1,
1413+
DialTimeout: 1 * time.Second,
1414+
PoolTimeout: 1 * time.Second,
1415+
ConnMaxIdleTime: 50 * time.Millisecond,
1416+
})
1417+
1418+
// Get a connection, put it back, then wait for it to become stale
1419+
cn, err := testPool.Get(ctx)
1420+
Expect(err).NotTo(HaveOccurred())
1421+
testPool.Put(ctx, cn)
1422+
1423+
// Wait for the connection to exceed ConnMaxIdleTime
1424+
time.Sleep(100 * time.Millisecond)
1425+
1426+
// Close the pool — stale connection close errors should be suppressed
1427+
err = testPool.Close()
1428+
Expect(err).NotTo(HaveOccurred())
1429+
})
1430+
1431+
It("should return close error when connection is still healthy", func() {
1432+
closeErr := fmt.Errorf("tls: failed to send closeNotify alert")
1433+
1434+
testPool := pool.NewConnPool(&pool.Options{
1435+
Dialer: func(ctx context.Context) (net.Conn, error) {
1436+
return &failCloseConn{
1437+
dummyConn: &dummyConn{rawConn: new(dummyRawConn)},
1438+
closeErr: closeErr,
1439+
}, nil
1440+
},
1441+
PoolSize: 1,
1442+
MaxConcurrentDials: 1,
1443+
DialTimeout: 1 * time.Second,
1444+
PoolTimeout: 1 * time.Second,
1445+
ConnMaxIdleTime: 10 * time.Minute, // Long idle time — connection stays healthy
1446+
})
1447+
1448+
// Get a connection and put it back
1449+
cn, err := testPool.Get(ctx)
1450+
Expect(err).NotTo(HaveOccurred())
1451+
testPool.Put(ctx, cn)
1452+
1453+
// Close the pool — healthy connection close error should be returned
1454+
err = testPool.Close()
1455+
Expect(err).To(MatchError(closeErr))
1456+
})
1457+
})
1458+
14001459
Describe("calcConnExpiresAt", func() {
14011460
// Case 1: lifetime <= 0 returns noExpiration
14021461
It("returns noExpiration when ConnMaxLifetime is not positive", func() {

0 commit comments

Comments
 (0)