Files

1515 lines
43 KiB
Go

package pool_test
import (
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"testing"
"time"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/hanzoai/kv-go/v9/internal/pool"
"github.com/hanzoai/kv-go/v9/logging"
)
var _ = Describe("ConnPool", func() {
ctx := context.Background()
var connPool *pool.ConnPool
BeforeEach(func() {
connPool = pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: int32(10),
MaxConcurrentDials: 10,
PoolTimeout: time.Hour,
DialTimeout: 1 * time.Second,
ConnMaxIdleTime: time.Millisecond,
})
})
AfterEach(func() {
connPool.Close()
})
It("should safe close", func() {
const minIdleConns = 10
var (
wg sync.WaitGroup
closedChan = make(chan struct{})
)
wg.Add(minIdleConns)
connPool = pool.NewConnPool(&pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
wg.Done()
<-closedChan
return &net.TCPConn{}, nil
},
PoolSize: int32(10),
MaxConcurrentDials: 10,
PoolTimeout: time.Hour,
DialTimeout: 1 * time.Second,
ConnMaxIdleTime: time.Millisecond,
MinIdleConns: int32(minIdleConns),
})
wg.Wait()
Expect(connPool.Close()).NotTo(HaveOccurred())
close(closedChan)
// We wait for 1 second and believe that checkIdleConns has been executed.
time.Sleep(time.Second)
Expect(connPool.Stats()).To(Equal(&pool.Stats{
Hits: 0,
Misses: 0,
Timeouts: 0,
WaitCount: 0,
WaitDurationNs: 0,
TotalConns: 0,
IdleConns: 0,
StaleConns: 0,
}))
})
It("should unblock client when conn is removed", func() {
// Reserve one connection.
cn, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
// Reserve all other connections.
var cns []*pool.Conn
for i := 0; i < 9; i++ {
cn, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
cns = append(cns, cn)
}
started := make(chan bool, 1)
done := make(chan bool, 1)
go func() {
defer GinkgoRecover()
started <- true
_, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
done <- true
connPool.Put(ctx, cn)
}()
<-started
// Check that Get is blocked.
select {
case <-done:
Fail("Get is not blocked")
case <-time.After(time.Millisecond):
// ok
}
connPool.Remove(ctx, cn, errors.New("test"))
// Check that Get is unblocked.
select {
case <-done:
// ok
case <-time.After(time.Second):
Fail("Get is not unblocked")
}
for _, cn := range cns {
connPool.Put(ctx, cn)
}
})
})
var _ = Describe("MinIdleConns", func() {
const poolSize = 100
ctx := context.Background()
var minIdleConns int
var connPool *pool.ConnPool
newConnPool := func() *pool.ConnPool {
connPool := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: int32(poolSize),
MaxConcurrentDials: poolSize,
MinIdleConns: int32(minIdleConns),
PoolTimeout: 100 * time.Millisecond,
DialTimeout: 1 * time.Second,
ConnMaxIdleTime: -1,
})
Eventually(func() int {
return connPool.Len()
}).Should(Equal(minIdleConns))
return connPool
}
assert := func() {
It("has idle connections when created", func() {
Expect(connPool.Len()).To(Equal(minIdleConns))
Expect(connPool.IdleLen()).To(Equal(minIdleConns))
})
Context("after Get", func() {
var cn *pool.Conn
BeforeEach(func() {
var err error
cn, err = connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
Eventually(func() int {
return connPool.Len()
}).Should(Equal(minIdleConns + 1))
})
It("has idle connections", func() {
Expect(connPool.Len()).To(Equal(minIdleConns + 1))
Expect(connPool.IdleLen()).To(Equal(minIdleConns))
})
Context("after Remove", func() {
BeforeEach(func() {
connPool.Remove(ctx, cn, errors.New("test"))
})
It("has idle connections", func() {
Expect(connPool.Len()).To(Equal(minIdleConns))
Expect(connPool.IdleLen()).To(Equal(minIdleConns))
})
})
})
Describe("Get does not exceed pool size", func() {
var mu sync.RWMutex
var cns []*pool.Conn
BeforeEach(func() {
cns = make([]*pool.Conn, 0)
perform(poolSize, func(_ int) {
defer GinkgoRecover()
cn, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
mu.Lock()
cns = append(cns, cn)
mu.Unlock()
})
Eventually(func() int {
return connPool.Len()
}).Should(BeNumerically(">=", poolSize))
})
It("Get is blocked", func() {
done := make(chan struct{})
go func() {
connPool.Get(ctx)
close(done)
}()
select {
case <-done:
Fail("Get is not blocked")
case <-time.After(time.Millisecond):
// ok
}
select {
case <-done:
// ok
case <-time.After(time.Second):
Fail("Get is not unblocked")
}
})
Context("after Put", func() {
BeforeEach(func() {
perform(len(cns), func(i int) {
mu.RLock()
connPool.Put(ctx, cns[i])
mu.RUnlock()
})
Eventually(func() int {
return connPool.Len()
}).Should(Equal(poolSize))
})
It("pool.Len is back to normal", func() {
Expect(connPool.Len()).To(Equal(poolSize))
Expect(connPool.IdleLen()).To(Equal(poolSize))
})
})
Context("after Remove", func() {
BeforeEach(func() {
perform(len(cns), func(i int) {
mu.RLock()
connPool.Remove(ctx, cns[i], errors.New("test"))
mu.RUnlock()
})
Eventually(func() int {
return connPool.Len()
}).Should(Equal(minIdleConns))
})
It("has idle connections", func() {
Expect(connPool.Len()).To(Equal(minIdleConns))
Expect(connPool.IdleLen()).To(Equal(minIdleConns))
})
})
})
}
Context("minIdleConns = 1", func() {
BeforeEach(func() {
minIdleConns = 1
connPool = newConnPool()
})
AfterEach(func() {
connPool.Close()
})
assert()
})
Context("minIdleConns = 32", func() {
BeforeEach(func() {
minIdleConns = 32
connPool = newConnPool()
})
AfterEach(func() {
connPool.Close()
})
assert()
})
})
var _ = Describe("race", func() {
ctx := context.Background()
var connPool *pool.ConnPool
var C, N int
BeforeEach(func() {
C, N = 10, 1000
if testing.Short() {
C = 2
N = 50
}
})
AfterEach(func() {
connPool.Close()
})
It("does not happen on Get, Put, and Remove", func() {
connPool = pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: int32(10),
MaxConcurrentDials: 10,
PoolTimeout: time.Minute,
DialTimeout: 1 * time.Second,
ConnMaxIdleTime: time.Millisecond,
})
perform(C, func(id int) {
for i := 0; i < N; i++ {
cn, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
if err == nil {
connPool.Put(ctx, cn)
}
}
}, func(id int) {
for i := 0; i < N; i++ {
cn, err := connPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
if err == nil {
connPool.Remove(ctx, cn, errors.New("test"))
}
}
})
})
It("limit the number of connections", func() {
opt := &pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
return &net.TCPConn{}, nil
},
PoolSize: int32(1000),
MaxConcurrentDials: 1000,
MinIdleConns: int32(50),
PoolTimeout: 3 * time.Second,
DialTimeout: 1 * time.Second,
}
p := pool.NewConnPool(opt)
var wg sync.WaitGroup
for i := int32(0); i < opt.PoolSize; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = p.Get(ctx)
}()
}
wg.Wait()
stats := p.Stats()
Expect(stats.IdleConns).To(Equal(uint32(0)))
Expect(stats.TotalConns).To(Equal(uint32(opt.PoolSize)))
})
It("recover addIdleConn panic", func() {
opt := &pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
panic("test panic")
},
PoolSize: int32(100),
MaxConcurrentDials: 100,
MinIdleConns: int32(30),
}
p := pool.NewConnPool(opt)
p.CheckMinIdleConns()
Eventually(func() bool {
state := p.Stats()
return state.TotalConns == 0 && state.IdleConns == 0 && p.QueueLen() == 0
}, "3s", "50ms").Should(BeTrue())
})
It("wait", func() {
opt := &pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
return &net.TCPConn{}, nil
},
PoolSize: int32(1),
MaxConcurrentDials: 1,
PoolTimeout: 3 * time.Second,
}
p := pool.NewConnPool(opt)
wait := make(chan struct{})
conn, _ := p.Get(ctx)
go func() {
_, _ = p.Get(ctx)
wait <- struct{}{}
}()
time.Sleep(time.Second)
p.Put(ctx, conn)
<-wait
stats := p.Stats()
Expect(stats.IdleConns).To(Equal(uint32(0)))
Expect(stats.TotalConns).To(Equal(uint32(1)))
Expect(stats.WaitCount).To(Equal(uint32(1)))
Expect(stats.WaitDurationNs).To(BeNumerically("~", time.Second.Nanoseconds(), 100*time.Millisecond.Nanoseconds()))
})
It("timeout", func() {
testPoolTimeout := 1 * time.Second
opt := &pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
// Artificial delay to force pool timeout
time.Sleep(3 * testPoolTimeout)
return &net.TCPConn{}, nil
},
PoolSize: int32(1),
MaxConcurrentDials: 1,
PoolTimeout: testPoolTimeout,
}
p := pool.NewConnPool(opt)
stats := p.Stats()
Expect(stats.Timeouts).To(Equal(uint32(0)))
conn, err := p.Get(ctx)
Expect(err).NotTo(HaveOccurred())
_, err = p.Get(ctx)
Expect(err).To(MatchError(pool.ErrPoolTimeout))
p.Put(ctx, conn)
_, err = p.Get(ctx)
Expect(err).NotTo(HaveOccurred())
stats = p.Stats()
Expect(stats.Timeouts).To(Equal(uint32(1)))
})
})
// TestDialerRetryConfiguration tests the new DialerRetries and DialerRetryTimeout options
func TestDialerRetryConfiguration(t *testing.T) {
ctx := context.Background()
t.Run("CustomDialerRetries", func(t *testing.T) {
var attempts int64
failingDialer := func(ctx context.Context) (net.Conn, error) {
atomic.AddInt64(&attempts, 1)
return nil, errors.New("dial failed")
}
connPool := pool.NewConnPool(&pool.Options{
Dialer: failingDialer,
PoolSize: 1,
MaxConcurrentDials: 1,
PoolTimeout: time.Second,
DialTimeout: time.Second,
DialerRetries: 3, // Custom retry count
DialerRetryTimeout: 10 * time.Millisecond, // Fast retries for testing
})
defer connPool.Close()
_, err := connPool.Get(ctx)
if err == nil {
t.Error("Expected error from failing dialer")
}
// Should have attempted at least 3 times (DialerRetries = 3)
// There might be additional attempts due to pool logic
finalAttempts := atomic.LoadInt64(&attempts)
if finalAttempts < 3 {
t.Errorf("Expected at least 3 dial attempts, got %d", finalAttempts)
}
if finalAttempts > 6 {
t.Errorf("Expected around 3 dial attempts, got %d (too many)", finalAttempts)
}
})
t.Run("DefaultDialerRetries", func(t *testing.T) {
var attempts int64
failingDialer := func(ctx context.Context) (net.Conn, error) {
atomic.AddInt64(&attempts, 1)
return nil, errors.New("dial failed")
}
connPool := pool.NewConnPool(&pool.Options{
Dialer: failingDialer,
PoolSize: 1,
MaxConcurrentDials: 1,
PoolTimeout: time.Second,
DialTimeout: time.Second,
// DialerRetries and DialerRetryTimeout not set - should use defaults
})
defer connPool.Close()
_, err := connPool.Get(ctx)
if err == nil {
t.Error("Expected error from failing dialer")
}
// Should have attempted 5 times (default DialerRetries = 5)
// Note: There may be one additional attempt from tryDial() goroutine
// which is launched when dialErrorsNum reaches PoolSize
finalAttempts := atomic.LoadInt64(&attempts)
if finalAttempts < 5 {
t.Errorf("Expected at least 5 dial attempts (default), got %d", finalAttempts)
}
if finalAttempts > 6 {
t.Errorf("Expected around 5 dial attempts, got %d (too many)", finalAttempts)
}
})
}
var _ = Describe("queuedNewConn", func() {
ctx := context.Background()
It("should successfully create connection when pool is exhausted", func() {
testPool := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 1,
MaxConcurrentDials: 2,
DialTimeout: 1 * time.Second,
PoolTimeout: 2 * time.Second,
})
defer testPool.Close()
// Fill the pool
conn1, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(conn1).NotTo(BeNil())
// Get second connection in another goroutine
done := make(chan struct{})
var conn2 *pool.Conn
var err2 error
go func() {
defer GinkgoRecover()
conn2, err2 = testPool.Get(ctx)
close(done)
}()
// Wait a bit to let the second Get start waiting
time.Sleep(100 * time.Millisecond)
// Release first connection to let second Get acquire Turn
testPool.Put(ctx, conn1)
// Wait for second Get to complete
<-done
Expect(err2).NotTo(HaveOccurred())
Expect(conn2).NotTo(BeNil())
// Clean up second connection
testPool.Put(ctx, conn2)
})
It("should handle context cancellation before acquiring dialsInProgress", func() {
slowDialer := func(ctx context.Context) (net.Conn, error) {
// Simulate slow dialing to let first connection creation occupy dialsInProgress
time.Sleep(200 * time.Millisecond)
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: slowDialer,
PoolSize: 2,
MaxConcurrentDials: 1, // Limit to 1 so second request cannot get dialsInProgress permission
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
})
defer testPool.Close()
// Start first connection creation, this will occupy dialsInProgress
done1 := make(chan struct{})
go func() {
defer GinkgoRecover()
conn1, err := testPool.Get(ctx)
if err == nil {
defer testPool.Put(ctx, conn1)
}
close(done1)
}()
// Wait a bit to ensure first request starts and occupies dialsInProgress
time.Sleep(50 * time.Millisecond)
// Create a context that will be cancelled quickly
cancelCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
// Second request should timeout while waiting for dialsInProgress
_, err := testPool.Get(cancelCtx)
Expect(err).To(Equal(context.DeadlineExceeded))
// Wait for first request to complete
<-done1
// Verify all turns are released after requests complete
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0), "All turns should be released after requests complete")
})
It("should handle context cancellation while waiting for connection result", func() {
// This test focuses on proper error handling when context is cancelled
// during queuedNewConn execution (not testing connection reuse)
slowDialer := func(ctx context.Context) (net.Conn, error) {
// Simulate slow dialing
time.Sleep(500 * time.Millisecond)
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: slowDialer,
PoolSize: 1,
MaxConcurrentDials: 2,
DialTimeout: 2 * time.Second,
PoolTimeout: 2 * time.Second,
})
defer testPool.Close()
// Get first connection to fill the pool
conn1, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
// Create a context that will be cancelled during connection creation
cancelCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
// This request should timeout while waiting for connection creation result
// Testing the error handling path in queuedNewConn select statement
done := make(chan struct{})
var err2 error
go func() {
defer GinkgoRecover()
_, err2 = testPool.Get(cancelCtx)
close(done)
}()
<-done
Expect(err2).To(Equal(context.DeadlineExceeded))
// Verify turn state - background goroutine may still hold turn
// Note: Background connection creation will complete and release turn
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(1), "Only conn1's turn should be held")
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0),
"dialsQueue should be empty - cancelled request should be cleaned up")
// Clean up - release the first connection
testPool.Put(ctx, conn1)
// Verify all turns are released after cleanup
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0), "All turns should be released after cleanup")
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0))
})
It("should handle dial failures gracefully", func() {
alwaysFailDialer := func(ctx context.Context) (net.Conn, error) {
return nil, fmt.Errorf("dial failed")
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: alwaysFailDialer,
PoolSize: 1,
MaxConcurrentDials: 1,
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
})
defer testPool.Close()
// This call should fail, testing error handling branch in goroutine
_, err := testPool.Get(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("dial failed"))
// Verify turn is released after dial failure
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0), "Turn should be released after dial failure")
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0),
"dialsQueue should be empty after dial failure")
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.TotalConns).To(Equal(uint32(0)),
"No connections should exist after dial failure")
Expect(stats.IdleConns).To(Equal(uint32(0)))
})
It("should handle connection creation success with normal delivery", func() {
// This test verifies normal case where connection creation and delivery both succeed
testPool := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 1,
MaxConcurrentDials: 2,
DialTimeout: 1 * time.Second,
PoolTimeout: 2 * time.Second,
})
defer testPool.Close()
// Get first connection
conn1, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
// Get second connection in another goroutine
done := make(chan struct{})
var conn2 *pool.Conn
var err2 error
go func() {
defer GinkgoRecover()
conn2, err2 = testPool.Get(ctx)
close(done)
}()
// Wait a bit to let second Get start waiting
time.Sleep(100 * time.Millisecond)
// Release first connection
testPool.Put(ctx, conn1)
// Wait for second Get to complete
<-done
Expect(err2).NotTo(HaveOccurred())
Expect(conn2).NotTo(BeNil())
// Clean up second connection
testPool.Put(ctx, conn2)
})
It("should handle MaxConcurrentDials limit", func() {
testPool := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 3,
MaxConcurrentDials: 1, // Only allow 1 concurrent dial
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
})
defer testPool.Close()
// Get all connections to fill the pool
var conns []*pool.Conn
for i := 0; i < 3; i++ {
conn, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
conns = append(conns, conn)
}
// Now pool is full, next request needs to create new connection
// But due to MaxConcurrentDials=1, only one concurrent dial is allowed
done := make(chan struct{})
var err4 error
var conn4 *pool.Conn
go func() {
defer GinkgoRecover()
conn4, err4 = testPool.Get(ctx)
close(done)
}()
// Release one connection to let the request complete
time.Sleep(100 * time.Millisecond)
testPool.Put(ctx, conns[0])
<-done
Expect(err4).NotTo(HaveOccurred())
if conn4 != nil {
testPool.Put(ctx, conn4)
}
// Clean up remaining connections
for i := 1; i < len(conns); i++ {
testPool.Put(ctx, conns[i])
}
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0),
"dialsQueue should be empty after all operations complete")
// Verify queue is empty
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0))
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.IdleConns).To(Equal(uint32(3)),
"All connections should be idle")
})
It("should reuse connections created in background after request timeout", func() {
// This test focuses on connection reuse mechanism:
// When a request times out but background connection creation succeeds,
// the created connection should be added to pool for future reuse
slowDialer := func(ctx context.Context) (net.Conn, error) {
// Simulate delay for connection creation
time.Sleep(100 * time.Millisecond)
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: slowDialer,
PoolSize: 1,
MaxConcurrentDials: 1,
DialTimeout: 1 * time.Second,
PoolTimeout: 150 * time.Millisecond, // Short timeout for waiting Turn
})
defer testPool.Close()
// Fill the pool with one connection
conn1, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
// Don't put it back yet, so pool is full
// Start a goroutine that will create a new connection but take time
done1 := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done1)
// This will trigger queuedNewConn since pool is full
conn, err := testPool.Get(ctx)
if err == nil {
// Put connection back to pool after creation
time.Sleep(50 * time.Millisecond)
testPool.Put(ctx, conn)
}
}()
// Wait a bit to let the goroutine start and begin connection creation
time.Sleep(50 * time.Millisecond)
// Now make a request that should timeout waiting for Turn
start := time.Now()
_, err = testPool.Get(ctx)
duration := time.Since(start)
Expect(err).To(Equal(pool.ErrPoolTimeout))
// Should timeout around PoolTimeout
Expect(duration).To(BeNumerically("~", 150*time.Millisecond, 50*time.Millisecond))
// Release the first connection to allow the background creation to complete
testPool.Put(ctx, conn1)
// Wait for background connection creation to complete
<-done1
time.Sleep(100 * time.Millisecond)
// CORE TEST: Verify connection reuse mechanism
// The connection created in background should now be available in pool
start = time.Now()
conn3, err := testPool.Get(ctx)
duration = time.Since(start)
Expect(err).NotTo(HaveOccurred())
Expect(conn3).NotTo(BeNil())
// Should be fast since connection is from pool (not newly created)
Expect(duration).To(BeNumerically("<", 50*time.Millisecond))
testPool.Put(ctx, conn3)
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0))
// Verify queue is empty
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0))
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.TotalConns).To(Equal(uint32(1)),
"Should have 1 total connection")
Expect(stats.IdleConns).To(Equal(uint32(1)),
"Connection should be idle")
})
It("recover queuedNewConn panic", func() {
opt := &pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
panic("test panic in queuedNewConn")
},
PoolSize: int32(10),
MaxConcurrentDials: 10,
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
}
testPool := pool.NewConnPool(opt)
defer testPool.Close()
// Trigger queuedNewConn - calling Get() on empty pool will trigger it
// Since dialer will panic, it should be handled by recover
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Try to get connections multiple times, each will trigger panic but should be properly recovered
for i := 0; i < 3; i++ {
conn, err := testPool.Get(ctx)
// Connection should be nil, error should exist (panic converted to error)
Expect(conn).To(BeNil())
Expect(err).To(HaveOccurred())
}
// Verify state after panic recovery:
// - turn should be properly released (QueueLen() == 0)
// - connection counts should be correct (TotalConns == 0, IdleConns == 0)
// - dialsQueue should be empty
Eventually(func() bool {
stats := testPool.Stats()
queueLen := testPool.QueueLen()
dialsQueueLen := testPool.DialsQueueLen()
return stats.TotalConns == 0 && stats.IdleConns == 0 && queueLen == 0 && dialsQueueLen == 0
}, "3s", "50ms").Should(BeTrue(),
"After panic recovery, all resources should be cleaned up")
})
It("should handle connection creation success but delivery failure (putIdleConn path)", func() {
// This test covers the most important untested branch in queuedNewConn:
// cnErr == nil && !delivered -> putIdleConn()
// Use slow dialer to ensure request times out before connection is ready
slowDialer := func(ctx context.Context) (net.Conn, error) {
// Delay long enough for client request to timeout first
time.Sleep(300 * time.Millisecond)
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: slowDialer,
PoolSize: 1,
MaxConcurrentDials: 2,
DialTimeout: 500 * time.Millisecond, // Long enough for dialer to complete
PoolTimeout: 100 * time.Millisecond, // Client requests will timeout quickly
})
defer testPool.Close()
// Record initial idle connection count
initialIdleConns := testPool.Stats().IdleConns
// Make a request that will timeout
// This request will start queuedNewConn, create connection, but fail to deliver due to timeout
shortCtx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
conn, err := testPool.Get(shortCtx)
// Request should fail due to timeout
Expect(err).To(HaveOccurred())
Expect(conn).To(BeNil())
// However, background queuedNewConn should continue and complete connection creation
// Since it cannot deliver (request timed out), it should call putIdleConn to add connection to idle pool
Eventually(func() bool {
stats := testPool.Stats()
return stats.IdleConns > initialIdleConns
}, "1s", "50ms").Should(BeTrue())
// Verify the connection can indeed be used by subsequent requests
conn2, err2 := testPool.Get(context.Background())
Expect(err2).NotTo(HaveOccurred())
Expect(conn2).NotTo(BeNil())
Expect(conn2.IsUsable()).To(BeTrue())
// Cleanup
testPool.Put(context.Background(), conn2)
// Verify turn is released after putIdleConn path completes
// This is critical: ensures freeTurn() was called in the putIdleConn branch
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0),
"Turn should be released after putIdleConn path completes")
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0),
"dialsQueue should be empty - timed out request should be dequeued")
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.IdleConns).To(Equal(uint32(1)),
"Connection should be in idle pool for reuse")
Expect(stats.TotalConns).To(Equal(uint32(1)))
})
It("should not leak turn when delivering connection via putIdleConn", func() {
// This test verifies that freeTurn() is called when putIdleConn successfully
// delivers a connection to another waiting request
//
// Scenario:
// 1. Request A: timeout 150ms, connection creation takes 200ms
// 2. Request B: timeout 500ms, connection creation takes 400ms
// 3. Both requests enter dialsQueue and start async connection creation
// 4. Request A times out at 150ms
// 5. Request A's connection completes at 200ms
// 6. putIdleConn delivers Request A's connection to Request B
// 7. queuedNewConn must call freeTurn()
// 8. Check: QueueLen should be 1 (only B holding turn), not 2 (A's turn leaked)
callCount := int32(0)
controlledDialer := func(ctx context.Context) (net.Conn, error) {
count := atomic.AddInt32(&callCount, 1)
if count == 1 {
// Request A's connection: takes 200ms
time.Sleep(200 * time.Millisecond)
} else {
// Request B's connection: takes 400ms (longer, so A's connection is used)
time.Sleep(400 * time.Millisecond)
}
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: controlledDialer,
PoolSize: 2, // Allows both requests to get turns
MaxConcurrentDials: 2, // Allows both connections to be created simultaneously
DialTimeout: 500 * time.Millisecond,
PoolTimeout: 1 * time.Second,
})
defer testPool.Close()
// Verify initial state
Expect(testPool.QueueLen()).To(Equal(0))
// Request A: Short timeout (150ms), connection takes 200ms
reqADone := make(chan error, 1)
go func() {
defer GinkgoRecover()
shortCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond)
defer cancel()
_, err := testPool.Get(shortCtx)
reqADone <- err
}()
// Wait for Request A to acquire turn and enter dialsQueue
time.Sleep(50 * time.Millisecond)
Expect(testPool.QueueLen()).To(Equal(1), "Request A should occupy turn")
// Request B: Long timeout (500ms), will receive Request A's connection
reqBDone := make(chan struct{})
var reqBConn *pool.Conn
var reqBErr error
go func() {
defer GinkgoRecover()
longCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
reqBConn, reqBErr = testPool.Get(longCtx)
close(reqBDone)
}()
// Wait for Request B to acquire turn and enter dialsQueue
time.Sleep(50 * time.Millisecond)
Expect(testPool.QueueLen()).To(Equal(2), "Both requests should occupy turns")
// Request A times out at 150ms
reqAErr := <-reqADone
Expect(reqAErr).To(HaveOccurred(), "Request A should timeout")
// Request A's connection completes at 200ms
// putIdleConn delivers it to Request B via tryDeliver
// queuedNewConn MUST call freeTurn() to release Request A's turn
<-reqBDone
Expect(reqBErr).NotTo(HaveOccurred(), "Request B should receive Request A's connection")
Expect(reqBConn).NotTo(BeNil())
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "500ms", "50ms").Should(BeNumerically("<=", 1),
"Request A's wantConn should be dequeued after delivery to Request B")
// FIRST CRITICAL CHECK: Turn state after connection delivery
// After Request B receives connection from putIdleConn:
// - Request A's turn is held by Request B (connection delivered)
// - Request B's turn is still held by Request B's dial to complete the connection
// Expected QueueLen: 2 (Request B holding turn for connection usage)
time.Sleep(100 * time.Millisecond) // ~300ms total
Expect(testPool.QueueLen()).To(Equal(2))
// SECOND CRITICAL CHECK: Turn release after dial completion
// Wait for Request B's dial result to complete
time.Sleep(300 * time.Millisecond) // ~600ms total
Expect(testPool.QueueLen()).To(Equal(1))
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "1s", "50ms").Should(Equal(0),
"All wantConn should be dequeued after connections are delivered")
// Cleanup and verify turn is released
testPool.Put(ctx, reqBConn)
Eventually(func() int { return testPool.QueueLen() }, "600ms").Should(Equal(0))
})
// Test for race condition where nil context can be passed to newConn
// This reproduces the issue reported in GitHub where queuedNewConn panics
// with "cannot create context from nil parent"
It("should handle nil context race condition in queuedNewConn", func() {
// Create a pool with very short timeouts to trigger the race condition
testPool := pool.NewConnPool(&pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
// Add a small delay to increase chance of race condition
time.Sleep(50 * time.Millisecond)
return dummyDialer(ctx)
},
PoolSize: int32(10),
MaxConcurrentDials: 10,
PoolTimeout: 10 * time.Millisecond, // Very short timeout
DialTimeout: 100 * time.Millisecond,
ConnMaxIdleTime: time.Millisecond,
})
defer testPool.Close()
// Try to trigger the race condition by making many concurrent requests
// with short timeouts
const numGoroutines = 50
var wg sync.WaitGroup
errors := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
// Use a very short context timeout to trigger the race
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
_, err := testPool.Get(ctx)
if err != nil {
// We expect timeout errors, but not panics
errors <- err
}
}()
}
wg.Wait()
close(errors)
// Check that we got timeout errors (expected) but no panics
// The test passes if it doesn't panic
timeoutCount := 0
for err := range errors {
if err == context.DeadlineExceeded || err == pool.ErrPoolTimeout {
timeoutCount++
}
}
// We should have at least some timeouts due to the short timeout
Expect(timeoutCount).To(BeNumerically(">", 0))
// Verify all asynchronous operations are completed
time.Sleep(200 * time.Millisecond)
// Verify no resources are leaked
Eventually(func() int {
return testPool.QueueLen()
}, "2s", "50ms").Should(Equal(0),
"All turns should be released after race condition test")
Eventually(func() int {
return testPool.DialsQueueLen()
}, "2s", "50ms").Should(Equal(0),
"dialsQueue should be empty - no zombie wantConn")
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.TotalConns).To(BeNumerically(">=", 0))
Eventually(func() uint32 {
return testPool.Stats().IdleConns + testPool.Stats().StaleConns
}, "2s", "50ms").Should(Equal(stats.TotalConns),
"All connections should be accounted for")
})
It("should cleanup dialsQueue under high concurrency with continuous dial failures", func() {
alwaysFailDialer := func(ctx context.Context) (net.Conn, error) {
return nil, fmt.Errorf("network unreachable")
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: alwaysFailDialer,
PoolSize: 100,
MaxConcurrentDials: 100,
DialTimeout: 50 * time.Millisecond,
PoolTimeout: 30 * time.Millisecond,
})
defer testPool.Close()
// Make many concurrent requests
const totalRequests = 1000
var wg sync.WaitGroup
for i := 0; i < totalRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = testPool.Get(context.Background())
}()
}
wg.Wait()
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "2s", "100ms").Should(Equal(0),
"dialsQueue should be empty after all failed requests complete")
// Verify turn is released
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0))
// Verify connection counts are correct
stats := testPool.Stats()
Expect(stats.TotalConns).To(Equal(uint32(0)),
"No connections should exist after all failures")
})
It("should cleanup zombie wantConn when request times out and dial fails", func() {
// Delayed failed dialer
slowFailDialer := func(ctx context.Context) (net.Conn, error) {
time.Sleep(100 * time.Millisecond) // Exceed request timeout
return nil, fmt.Errorf("dial failed")
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: slowFailDialer,
PoolSize: 10,
MaxConcurrentDials: 10,
DialTimeout: 200 * time.Millisecond,
PoolTimeout: 50 * time.Millisecond, // Request timeout quickly
})
defer testPool.Close()
// Make many requests with quick timeout
const numRequests = 100
var wg sync.WaitGroup
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
shortCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
_, _ = testPool.Get(shortCtx)
}()
}
wg.Wait()
// Wait for asynchronous connection creation to complete
time.Sleep(200 * time.Millisecond)
// Verify dialsQueue is empty
Eventually(func() int {
return testPool.DialsQueueLen()
}, "2s", "100ms").Should(Equal(0),
"dialsQueue should be empty even when requests timeout and dials fail")
// Verify turn is released
Eventually(func() int {
return testPool.QueueLen()
}, "1s", "50ms").Should(Equal(0))
})
It("should handle intermittent dial failures without queue accumulation", func() {
callCount := int64(0)
intermittentDialer := func(ctx context.Context) (net.Conn, error) {
count := atomic.AddInt64(&callCount, 1)
if count%2 == 0 {
return nil, fmt.Errorf("network timeout")
}
return newDummyConn(), nil
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: intermittentDialer,
PoolSize: 50,
MaxConcurrentDials: 50,
DialTimeout: 100 * time.Millisecond,
PoolTimeout: 50 * time.Millisecond,
})
defer testPool.Close()
// Send requests continuously for 5 seconds
const duration = 5 * time.Second
start := time.Now()
var wg sync.WaitGroup
for time.Since(start) < duration {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = testPool.Get(context.Background())
}()
time.Sleep(10 * time.Millisecond) // Control request rate
}
wg.Wait()
// Verify dialsQueue does not accumulate indefinitely
maxExpectedQueueLen := int(testPool.Size()) * 2
queueLen := testPool.DialsQueueLen()
Expect(queueLen).To(BeNumerically("<=", maxExpectedQueueLen),
fmt.Sprintf("dialsQueue length (%d) should not exceed reasonable limit (%d)",
queueLen, maxExpectedQueueLen))
// Wait for final cleanup
Eventually(func() int {
return testPool.DialsQueueLen()
}, "2s", "100ms").Should(Equal(0),
"dialsQueue should eventually be cleaned up")
})
It("should enforce dialsQueue upper bound", func() {
alwaysFailDialer := func(ctx context.Context) (net.Conn, error) {
return nil, fmt.Errorf("dial failed")
}
testPool := pool.NewConnPool(&pool.Options{
Dialer: alwaysFailDialer,
PoolSize: 100,
MaxConcurrentDials: 100,
DialTimeout: 50 * time.Millisecond,
PoolTimeout: 30 * time.Millisecond,
})
defer testPool.Close()
const numRequests = 1000
maxExpectedQueueLen := int(testPool.Size()) * 2 // Reasonable upper bound
var wg sync.WaitGroup
maxObservedQueueLen := int32(0)
// Send many failed requests concurrently
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = testPool.Get(context.Background())
}()
// Check queue length periodically
if i%50 == 0 {
queueLen := testPool.DialsQueueLen()
if queueLen > int(atomic.LoadInt32(&maxObservedQueueLen)) {
atomic.StoreInt32(&maxObservedQueueLen, int32(queueLen))
}
Expect(queueLen).To(BeNumerically("<=", maxExpectedQueueLen),
fmt.Sprintf("Queue length (%d) should not exceed limit (%d)",
queueLen, maxExpectedQueueLen))
}
}
wg.Wait()
// Verify final cleanup
Eventually(func() int {
return testPool.DialsQueueLen()
}, "2s", "100ms").Should(Equal(0),
"dialsQueue should be empty after all requests complete")
})
Describe("Close suppresses errors for stale connections", func() {
It("should not return close error when connection is stale due to ConnMaxIdleTime", func() {
closeErr := fmt.Errorf("tls: failed to send closeNotify alert (but connection was closed anyway): write tcp: connection timed out")
testPool := pool.NewConnPool(&pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
return &failCloseConn{
dummyConn: &dummyConn{rawConn: new(dummyRawConn)},
closeErr: closeErr,
}, nil
},
PoolSize: 1,
MaxConcurrentDials: 1,
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
ConnMaxIdleTime: 50 * time.Millisecond,
})
// Get a connection, put it back, then wait for it to become stale
cn, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
testPool.Put(ctx, cn)
// Wait for the connection to exceed ConnMaxIdleTime
time.Sleep(100 * time.Millisecond)
// Close the pool — stale connection close errors should be suppressed
err = testPool.Close()
Expect(err).NotTo(HaveOccurred())
})
It("should return close error when connection is still healthy", func() {
closeErr := fmt.Errorf("tls: failed to send closeNotify alert")
testPool := pool.NewConnPool(&pool.Options{
Dialer: func(ctx context.Context) (net.Conn, error) {
return &failCloseConn{
dummyConn: &dummyConn{rawConn: new(dummyRawConn)},
closeErr: closeErr,
}, nil
},
PoolSize: 1,
MaxConcurrentDials: 1,
DialTimeout: 1 * time.Second,
PoolTimeout: 1 * time.Second,
ConnMaxIdleTime: 10 * time.Minute, // Long idle time — connection stays healthy
})
// Get a connection and put it back
cn, err := testPool.Get(ctx)
Expect(err).NotTo(HaveOccurred())
testPool.Put(ctx, cn)
// Close the pool — healthy connection close error should be returned
err = testPool.Close()
Expect(err).To(MatchError(closeErr))
})
})
Describe("calcConnExpiresAt", func() {
// Case 1: lifetime <= 0 returns noExpiration
It("returns noExpiration when ConnMaxLifetime is not positive", func() {
p := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 1,
ConnMaxLifetime: 0,
ConnMaxLifetimeJitter: 0,
})
defer p.Close()
Expect(p.CalcConnExpiresAt()).To(Equal(pool.NoExpiration))
})
// Case 2: lifetime > 0, jitter <= 0 returns exact lifetime
It("returns exact lifetime when jitter is zero", func() {
lifetime := 1 * time.Hour
p := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 1,
ConnMaxLifetime: lifetime,
ConnMaxLifetimeJitter: 0,
})
defer p.Close()
before := time.Now()
expiresAt := p.CalcConnExpiresAt()
after := time.Now()
Expect(expiresAt).To(BeTemporally(">=", before.Add(lifetime)))
Expect(expiresAt).To(BeTemporally("<=", after.Add(lifetime)))
})
// Case 3: lifetime > 0, jitter > 0 returns value in jitter range
It("returns value in jitter range when jitter is positive", func() {
lifetime := 1 * time.Hour
jitter := 6 * time.Minute
p := pool.NewConnPool(&pool.Options{
Dialer: dummyDialer,
PoolSize: 1,
ConnMaxLifetime: lifetime,
ConnMaxLifetimeJitter: jitter,
})
defer p.Close()
before := time.Now()
expiresAt := p.CalcConnExpiresAt()
Expect(expiresAt).To(BeTemporally(">=", before.Add(lifetime-jitter)))
Expect(expiresAt).To(BeTemporally("<=", before.Add(lifetime+jitter)))
})
})
})
func init() {
logging.Disable()
}