Skip to content

Commit 32e02b6

Browse files
committed
fix: resolve connection pool concurrency issues and enhance test coverage
This commit addresses several concurrency-related issues discovered during the mutex refactoring and improves test coverage and documentation. Fixes: - Initialize connection pool dead slice to empty slice instead of nil - Resolve scheduleResurrect deadlock by passing deadSince parameter - Fix test deadlocks in connection_internal_test.go by extracting state before lock release - Implement rwLocker interface methods for statusConnectionPool compatibility - Update integration test to use new mutex-embedded pool access patterns Improvements: - Add test coverage for metrics incrementResponse method - Add explanatory comments for connection resurrection logic and dead connection sorting - Document concurrency tradeoffs in connection pool management These changes ensure robust concurrent behavior and comprehensive test coverage for the refactored mutex patterns introduced in the previous commit. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 0282f67 commit 32e02b6

5 files changed

Lines changed: 117 additions & 41 deletions

File tree

opensearchtransport/connection.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ type ConnectionPool interface {
5555
URLs() []*url.URL // URLs returns the list of URLs of available connections.
5656
}
5757

58+
// rwLocker defines the interface for connection pools that support read-write locking.
59+
// This allows for more efficient concurrent access when only read operations are needed.
60+
type rwLocker interface {
61+
sync.Locker // Embeds Lock() and Unlock() methods
62+
RLock()
63+
RUnlock()
64+
}
65+
5866
// Connection represents a connection to a node.
5967
type Connection struct {
6068
URL *url.URL
@@ -98,8 +106,10 @@ type roundRobinSelector struct {
98106

99107
// Compile-time checks to ensure interface compliance
100108
var (
101-
_ ConnectionPool = (*statusConnectionPool)(nil)
102109
_ ConnectionPool = (*singleConnectionPool)(nil)
110+
111+
_ ConnectionPool = (*statusConnectionPool)(nil)
112+
_ rwLocker = (*statusConnectionPool)(nil)
103113
)
104114

105115
// NewConnectionPool creates and returns a default connection pool.
@@ -120,6 +130,7 @@ func NewConnectionPool(conns []*Connection, selector Selector) ConnectionPool {
120130
resurrectTimeoutFactorCutoff: defaultResurrectTimeoutFactorCutoff,
121131
}
122132
pool.mu.live = conns
133+
pool.mu.dead = []*Connection{}
123134
return pool
124135
}
125136

@@ -199,16 +210,26 @@ func (cp *statusConnectionPool) OnFailure(c *Connection) error {
199210
}
200211

201212
c.markAsDeadWithLock()
202-
cp.scheduleResurrect(c)
213+
deadSince := c.mu.deadSince
203214
c.mu.Unlock()
204215

216+
cp.scheduleResurrect(c, deadSince)
217+
205218
// Push item to dead list and sort slice by number of failures
206219
cp.mu.dead = append(cp.mu.dead, c)
220+
221+
// Sort by failure count for resurrection prioritization.
222+
// CONCURRENCY TRADEOFF: Atomic loads are used without additional locking during sort,
223+
// allowing failure counts to change mid-sort and resulting in slightly inconsistent
224+
// ordering. This design prioritizes common-case latency over absolute correctness
225+
// during failure scenarios. While failure counts could be snapshotted before sorting,
226+
// the list ordering is not guaranteed to remain perfectly sorted by failure count
227+
// between operations, making "mostly correct" sorting with atomics acceptable.
228+
// Any temporary misordering self-corrects on subsequent failure events.
207229
sort.Slice(cp.mu.dead, func(i, j int) bool {
208230
c1 := cp.mu.dead[i]
209231
c2 := cp.mu.dead[j]
210232

211-
// Use atomic loads for failure counts - no locking needed
212233
failures1 := c1.failures.Load()
213234
failures2 := c2.failures.Load()
214235

@@ -261,6 +282,30 @@ func (cp *statusConnectionPool) connections() []*Connection {
261282
return conns
262283
}
263284

285+
// RLock acquires a read lock on the connection pool.
286+
// Implements rwLocker interface for efficient concurrent read access.
287+
func (cp *statusConnectionPool) RLock() {
288+
cp.mu.RLock()
289+
}
290+
291+
// RUnlock releases the read lock on the connection pool.
292+
// Implements rwLocker interface for efficient concurrent read access.
293+
func (cp *statusConnectionPool) RUnlock() {
294+
cp.mu.RUnlock()
295+
}
296+
297+
// Lock acquires a write lock on the connection pool.
298+
// Implements rwLocker interface (via embedded sync.Locker).
299+
func (cp *statusConnectionPool) Lock() {
300+
cp.mu.Lock()
301+
}
302+
303+
// Unlock releases the write lock on the connection pool.
304+
// Implements rwLocker interface (via embedded sync.Locker).
305+
func (cp *statusConnectionPool) Unlock() {
306+
cp.mu.Unlock()
307+
}
308+
264309
// resurrect adds the connection to the list of available connections.
265310
// When removeDead is true, it also removes it from the dead list.
266311
//
@@ -294,16 +339,12 @@ func (cp *statusConnectionPool) resurrectWithLock(c *Connection, removeDead bool
294339
}
295340

296341
// scheduleResurrect schedules the connection to be resurrected.
297-
func (cp *statusConnectionPool) scheduleResurrect(c *Connection) {
342+
func (cp *statusConnectionPool) scheduleResurrect(c *Connection, deadSince time.Time) {
298343
failures := c.failures.Load()
299344
factor := min(failures-1, int64(cp.resurrectTimeoutFactorCutoff))
300345
timeout := time.Duration(cp.resurrectTimeoutInitial.Seconds() * math.Exp2(float64(factor)) * float64(time.Second))
301346

302347
if debugLogger != nil {
303-
c.mu.RLock()
304-
deadSince := c.mu.deadSince
305-
c.mu.RUnlock()
306-
307348
debugLogger.Logf(
308349
"Resurrect %s (failures=%d, factor=%d, timeout=%s) in %s\n",
309350
c.URL,

opensearchtransport/connection_integration_test.go

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ func TestStatusConnectionPool(t *testing.T) {
8484

8585
transport, _ := New(cfg)
8686

87-
pool := transport.pool.(*statusConnectionPool)
87+
transport.mu.RLock()
88+
pool := transport.mu.pool.(*statusConnectionPool)
89+
transport.mu.RUnlock()
8890
pool.resurrectTimeoutInitial = time.Second
8991

9092
for i := 1; i <= 9; i++ {
@@ -98,11 +100,11 @@ func TestStatusConnectionPool(t *testing.T) {
98100
}
99101
}
100102

101-
pool.Lock()
102-
if len(pool.live) != 3 {
103-
t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.live))
103+
pool.mu.Lock()
104+
if len(pool.mu.live) != 3 {
105+
t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.mu.live))
104106
}
105-
pool.Unlock()
107+
pool.mu.Unlock()
106108

107109
server = servers[1]
108110
fmt.Printf("==> Closing server: %s\n", server.Addr)
@@ -121,17 +123,17 @@ func TestStatusConnectionPool(t *testing.T) {
121123
}
122124
}
123125

124-
pool.Lock()
125-
if len(pool.live) != 2 {
126-
t.Errorf("Unexpected number of live connections, want=2, got=%d", len(pool.live))
126+
pool.mu.Lock()
127+
if len(pool.mu.live) != 2 {
128+
t.Errorf("Unexpected number of live connections, want=2, got=%d", len(pool.mu.live))
127129
}
128-
pool.Unlock()
130+
pool.mu.Unlock()
129131

130-
pool.Lock()
131-
if len(pool.dead) != 1 {
132-
t.Errorf("Unexpected number of dead connections, want=1, got=%d", len(pool.dead))
132+
pool.mu.Lock()
133+
if len(pool.mu.dead) != 1 {
134+
t.Errorf("Unexpected number of dead connections, want=1, got=%d", len(pool.mu.dead))
133135
}
134-
pool.Unlock()
136+
pool.mu.Unlock()
135137

136138
server = NewServer("localhost:10002", http.HandlerFunc(defaultHandler))
137139
servers[1] = server
@@ -156,15 +158,15 @@ func TestStatusConnectionPool(t *testing.T) {
156158
}
157159
}
158160

159-
pool.Lock()
160-
if len(pool.live) != 3 {
161-
t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.live))
161+
pool.mu.Lock()
162+
if len(pool.mu.live) != 3 {
163+
t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.mu.live))
162164
}
163-
pool.Unlock()
165+
pool.mu.Unlock()
164166

165-
pool.Lock()
166-
if len(pool.dead) != 0 {
167-
t.Errorf("Unexpected number of dead connections, want=0, got=%d", len(pool.dead))
167+
pool.mu.Lock()
168+
if len(pool.mu.dead) != 0 {
169+
t.Errorf("Unexpected number of dead connections, want=0, got=%d", len(pool.mu.dead))
168170
}
169-
pool.Unlock()
171+
pool.mu.Unlock()
170172
}

opensearchtransport/connection_internal_test.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,17 @@ func TestStatusConnectionPoolOnFailure(t *testing.T) {
259259
t.Fatalf("Unexpected error: %s", err)
260260
}
261261
conn.mu.Lock()
262-
if !conn.mu.isDead {
263-
t.Errorf("Expected the connection to be dead; %s", conn)
262+
isDead := conn.mu.isDead
263+
deadSince := conn.mu.deadSince
264+
conn.mu.Unlock()
265+
266+
if !isDead {
267+
t.Errorf("Expected the connection to be dead")
264268
}
265269

266-
if conn.mu.deadSince.IsZero() {
267-
t.Errorf("Unexpected value for DeadSince: %s", conn.mu.deadSince)
270+
if deadSince.IsZero() {
271+
t.Errorf("Unexpected value for DeadSince: %s", deadSince)
268272
}
269-
conn.mu.Unlock()
270273

271274
pool.mu.Lock()
272275
defer pool.mu.Unlock()
@@ -340,8 +343,8 @@ func TestStatusConnectionPoolResurrect(t *testing.T) {
340343
isDead := conn.mu.isDead
341344
conn.mu.Unlock()
342345

343-
if conn.mu.isDead {
344-
t.Errorf("Expected connection to be dead, got: %s", conn)
346+
if isDead {
347+
t.Errorf("Expected connection to be live, got dead=true")
345348
}
346349

347350
if len(pool.mu.dead) != 0 {
@@ -404,7 +407,10 @@ func TestStatusConnectionPoolResurrect(t *testing.T) {
404407
}()
405408

406409
conn := pool.mu.dead[0]
407-
pool.scheduleResurrect(conn)
410+
conn.mu.RLock()
411+
deadSince := conn.mu.deadSince
412+
conn.mu.RUnlock()
413+
pool.scheduleResurrect(conn, deadSince)
408414
time.Sleep(50 * time.Millisecond)
409415

410416
pool.mu.Lock()
@@ -439,7 +445,7 @@ func TestConnection(t *testing.T) {
439445
}
440446

441447
if !match {
442-
t.Errorf("Unexpected output: %s", conn)
448+
t.Errorf("Unexpected output: %s", conn.String())
443449
}
444450
})
445451
}

opensearchtransport/metrics.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ func (c *Client) Metrics() (Metrics, error) {
101101
}
102102
c.metrics.mu.RUnlock()
103103

104-
if lockable, ok := c.mu.pool.(sync.Locker); ok {
105-
lockable.Lock()
106-
defer lockable.Unlock()
104+
// Acquire read lock on pool since we're only reading connection state
105+
if rwLock, ok := c.mu.pool.(rwLocker); ok {
106+
rwLock.RLock()
107+
defer rwLock.RUnlock()
107108
}
108109

109110
m := Metrics{

opensearchtransport/metrics_internal_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"net/http"
3434
"net/url"
3535
"regexp"
36+
"sync"
3637
"testing"
3738
"time"
3839
)
@@ -123,4 +124,29 @@ func TestMetrics(t *testing.T) {
123124
t.Errorf("Unexpected output: %s", m)
124125
}
125126
})
127+
128+
t.Run("incrementResponse method", func(t *testing.T) {
129+
m := &metrics{
130+
mu: struct {
131+
sync.RWMutex
132+
responses map[int]int
133+
}{
134+
responses: make(map[int]int),
135+
},
136+
}
137+
138+
// Test incrementResponse method directly
139+
m.incrementResponse(200)
140+
m.incrementResponse(404)
141+
m.incrementResponse(200) // increment same code again
142+
143+
m.mu.RLock()
144+
if m.mu.responses[200] != 2 {
145+
t.Errorf("Expected 2 responses for status 200, got %d", m.mu.responses[200])
146+
}
147+
if m.mu.responses[404] != 1 {
148+
t.Errorf("Expected 1 response for status 404, got %d", m.mu.responses[404])
149+
}
150+
m.mu.RUnlock()
151+
})
126152
}

0 commit comments

Comments
 (0)