Skip to content

Commit d17eef9

Browse files
committed
Two concurrent Next() calls could each spawn a deferredCapEnforcement
goroutine. Between spawning and lock acquisition, Metrics() and discovery could read stale lifecycle bits, observing inconsistent active/standby counts. Replace go deferredCapEnforcement() with triggerCapEnforcement(), which acquires the write lock via TryLock before launching the goroutine. If the lock is held, enforcement is skipped and self-heals on the next Next() call. RLock callers (snapshot, Metrics) block until the goroutine releases the write lock, ensuring consistent observations. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 716e04c commit d17eef9

8 files changed

Lines changed: 114 additions & 47 deletions

opensearchtransport/coverage_critical_path_internal_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func TestGetNextActiveConnWithLock(t *testing.T) {
169169
pool := makeTestPool("test", conns, 3, sel)
170170

171171
pool.mu.RLock()
172-
got := pool.getNextActiveConnWithLock()
172+
got, _ := pool.getNextActiveConnWithLock()
173173
pool.mu.RUnlock()
174174

175175
require.Same(t, conns[1], got)
@@ -182,7 +182,7 @@ func TestGetNextActiveConnWithLock(t *testing.T) {
182182
pool := makeTestPool("test", conns, 2, sel)
183183

184184
pool.mu.RLock()
185-
got := pool.getNextActiveConnWithLock()
185+
got, _ := pool.getNextActiveConnWithLock()
186186
pool.mu.RUnlock()
187187

188188
require.Nil(t, got)
@@ -198,7 +198,7 @@ func TestGetNextActiveConnWithLock(t *testing.T) {
198198
pool := makeTestPool("test", conns, 2, sel)
199199

200200
pool.mu.RLock()
201-
got := pool.getNextActiveConnWithLock()
201+
got, _ := pool.getNextActiveConnWithLock()
202202
pool.mu.RUnlock()
203203

204204
require.Same(t, conns[0], got)
@@ -216,9 +216,9 @@ func TestGetNextActiveConnWithLock(t *testing.T) {
216216
pool := makeTestPool("test", conns, 3, nil)
217217

218218
pool.mu.RLock()
219-
c1 := pool.getNextActiveConnWithLock()
220-
c2 := pool.getNextActiveConnWithLock()
221-
c3 := pool.getNextActiveConnWithLock()
219+
c1, _ := pool.getNextActiveConnWithLock()
220+
c2, _ := pool.getNextActiveConnWithLock()
221+
c3, _ := pool.getNextActiveConnWithLock()
222222
pool.mu.RUnlock()
223223

224224
// Round-robin should cycle through all connections

opensearchtransport/policy_chain_internal_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,6 @@ func TestPolicyChain(t *testing.T) {
302302
err := chain.OnFailure(conn)
303303
require.NoError(t, err)
304304
})
305-
306305
}
307306

308307
// testPolicyWithError is a mock policy that returns errors for testing

opensearchtransport/pool_coverage_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ func TestGetNextActiveConnWithLock_Legacy(t *testing.T) {
2929
pool.mu.activeCount = 2
3030

3131
pool.mu.RLock()
32-
first := pool.getNextActiveConnWithLock()
33-
second := pool.getNextActiveConnWithLock()
32+
first, _ := pool.getNextActiveConnWithLock()
33+
second, _ := pool.getNextActiveConnWithLock()
3434
pool.mu.RUnlock()
3535

3636
// With round-robin, two calls should return different connections

opensearchtransport/pool_multi_server.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,32 +101,40 @@ func (cp *multiServerPool) poolCtx() context.Context {
101101
}
102102

103103
// getNextActiveConnWithLock returns the next active connection using the pool's
104-
// selector strategy. Falls back to the legacy round-robin counter when no
105-
// selector is configured.
104+
// selector strategy and a boolean indicating whether cap enforcement is needed.
105+
// Falls back to the legacy round-robin counter when no selector is configured.
106+
//
107+
// When needsCapEnforce is true, the caller must arrange for cap enforcement
108+
// after releasing the read lock (triggerCapEnforcement uses TryLock, which
109+
// fails while any read lock is held). Callers that already hold the write lock
110+
// can call enforceActiveCapWithLock directly.
106111
//
107112
// CALLER RESPONSIBILITIES:
108113
// - Caller must hold pool read or write lock
109114
// - Caller must ensure cp.mu.activeCount > 0 before calling
110-
func (cp *multiServerPool) getNextActiveConnWithLock() *Connection {
115+
func (cp *multiServerPool) getNextActiveConnWithLock() (*Connection, bool) {
111116
if cp.selector != nil {
112117
conn, activeCap, _, err := cp.selector.selectNext(cp.mu.ready, cp.mu.activeCount)
113118
if err != nil {
114-
return nil
119+
return nil, false
115120
}
116-
// Handle cap adjustment signal asynchronously (don't block the read path).
121+
// Handle cap adjustment signals from the selector.
122+
// capGrow (standby promotion) stays async -- adding a connection is safe.
123+
// capShrink is signaled back to the caller, which must call
124+
// triggerCapEnforcement after releasing the read lock.
117125
switch activeCap {
118126
case capGrow:
119127
go cp.deferredStandbyPromotion()
120128
case capShrink:
121-
go cp.deferredCapEnforcement()
129+
return conn, true
122130
}
123-
return conn
131+
return conn, false
124132
}
125133

126134
// Legacy round-robin fallback.
127135
next := cp.nextReady.Add(1)
128136
idx := int(next-1) % cp.mu.activeCount
129-
return cp.mu.ready[idx]
137+
return cp.mu.ready[idx], false
130138
}
131139

132140
// deferredStandbyPromotion acquires the pool write lock and promotes one
@@ -161,6 +169,29 @@ func (cp *multiServerPool) deferredStandbyPromotion() {
161169
}
162170
}
163171

172+
// triggerCapEnforcement attempts to start asynchronous cap enforcement.
173+
//
174+
// Cap enforcement trims the active partition when activeCount > activeListCap
175+
// by demoting fully-warmed connections to standby. Triggered by warmup
176+
// completion in Next() and selector capShrink signals.
177+
//
178+
// TryLock keeps enforcement off the Next() hot path: if the write lock is
179+
// available, we acquire it and launch a goroutine that runs enforcement and
180+
// releases the lock on exit. If the lock is held (by another enforcement
181+
// goroutine, discovery, etc.), the call is a no-op. Self-heals because
182+
// Next() re-checks activeCount > cap on every warmup completion and
183+
// capShrink signal.
184+
func (cp *multiServerPool) triggerCapEnforcement() {
185+
if !cp.mu.TryLock() {
186+
return
187+
}
188+
189+
go func() {
190+
defer cp.mu.Unlock()
191+
cp.enforceActiveCapWithLock()
192+
}()
193+
}
194+
164195
// snapshot returns a point-in-time PolicySnapshot of this pool's partitions and counters.
165196
func (cp *multiServerPool) snapshot() PolicySnapshot {
166197
cp.mu.RLock()

opensearchtransport/pool_selection.go

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,15 @@ func (cp *multiServerPool) Next() (*Connection, error) {
4949
if cp.mu.activeCount > 0 { //nolint:nestif // warmup skip/accept/starvation requires nested branching
5050
var bestWarmingConn *Connection
5151
bestWarmingRemSkip := int(^uint(0) >> 1) // max int
52+
var needsCapEnforce bool
5253

5354
for attempt := range cp.mu.activeCount {
54-
conn := cp.getNextActiveConnWithLock()
55+
conn, selectorCapEnforce := cp.getNextActiveConnWithLock()
56+
needsCapEnforce = needsCapEnforce || selectorCapEnforce
57+
58+
if conn == nil {
59+
continue // selector error
60+
}
5561
state := conn.loadConnState()
5662

5763
if state.lifecycle()&(lcActive|lcStandby) == 0 {
@@ -67,6 +73,9 @@ func (cp *multiServerPool) Next() (*Connection, error) {
6773
if !state.isWarmingUp() {
6874
cp.mu.RUnlock()
6975
cp.poolRequests.Add(1)
76+
if needsCapEnforce {
77+
cp.triggerCapEnforcement()
78+
}
7079
return conn, nil
7180
}
7281

@@ -77,11 +86,11 @@ func (cp *multiServerPool) Next() (*Connection, error) {
7786
// Check if warmup finished and cap enforcement is needed.
7887
warmupDone := !conn.loadConnState().isWarmingUp()
7988
if warmupDone && cp.activeListCap > 0 && cp.mu.activeCount > cp.activeListCap {
89+
needsCapEnforce = true
8090
if debugLogger != nil {
8191
debugLogger.Logf("[%s] Next: warmup complete for %s, triggering cap enforcement (active=%d, cap=%d)\n",
8292
cp.name, conn.URL, cp.mu.activeCount, cp.activeListCap)
8393
}
84-
go cp.deferredCapEnforcement()
8594
} else if warmupDone && debugLogger != nil {
8695
debugLogger.Logf("[%s] Next: warmup complete for %s, no cap enforcement (active=%d, cap=%d)\n",
8796
cp.name, conn.URL, cp.mu.activeCount, cp.activeListCap)
@@ -95,12 +104,18 @@ func (cp *multiServerPool) Next() (*Connection, error) {
95104

96105
cp.mu.RUnlock()
97106
cp.poolRequests.Add(1)
107+
if needsCapEnforce {
108+
cp.triggerCapEnforcement()
109+
}
98110
return conn, nil
99111

100112
case warmupInactive:
101113
// Warmup completed between our isWarmingUp() check and tryWarmupSkip() call.
102114
cp.mu.RUnlock()
103115
cp.poolRequests.Add(1)
116+
if needsCapEnforce {
117+
cp.triggerCapEnforcement()
118+
}
104119
return conn, nil
105120

106121
case warmupSkipped:
@@ -126,6 +141,9 @@ func (cp *multiServerPool) Next() (*Connection, error) {
126141
}
127142
cp.mu.RUnlock()
128143
cp.poolRequests.Add(1)
144+
if needsCapEnforce {
145+
cp.triggerCapEnforcement()
146+
}
129147
return bestWarmingConn, nil
130148
}
131149
}
@@ -141,15 +159,6 @@ func (cp *multiServerPool) Next() (*Connection, error) {
141159
return cp.nextFallback()
142160
}
143161

144-
// deferredCapEnforcement acquires the pool write lock and trims the active
145-
// partition if it exceeds activeListCap. Called as a goroutine when warmup
146-
// completes and the active partition is temporarily over capacity.
147-
func (cp *multiServerPool) deferredCapEnforcement() {
148-
cp.mu.Lock()
149-
defer cp.mu.Unlock()
150-
cp.enforceActiveCapWithLock()
151-
}
152-
153162
// nextWithEviction acquires a write lock and iterates active connections,
154163
// evicting any that were externally demoted (lifecycle != lcActive) by another
155164
// pool's stats poller. Returns the first healthy connection found, or falls
@@ -168,10 +177,16 @@ func (cp *multiServerPool) nextWithEviction() (*Connection, error) {
168177
break
169178
}
170179

171-
conn := cp.getNextActiveConnWithLock()
180+
conn, needsCapEnforce := cp.getNextActiveConnWithLock()
181+
if conn == nil {
182+
continue
183+
}
172184
state := conn.loadConnState()
173185

174186
if state.lifecycle()&(lcActive|lcStandby) != 0 {
187+
if needsCapEnforce {
188+
cp.enforceActiveCapWithLock()
189+
}
175190
cp.poolRequests.Add(1)
176191
return conn, nil
177192
}
@@ -192,23 +207,34 @@ func (cp *multiServerPool) nextFallback() (*Connection, error) {
192207
// Double-check active after acquiring write lock
193208
if cp.mu.activeCount > 0 {
194209
// Re-check state on the selected connection under write lock
195-
conn := cp.getNextActiveConnWithLock()
196-
state := conn.loadConnState()
197-
if state.lifecycle()&(lcActive|lcStandby) != 0 {
198-
cp.poolRequests.Add(1)
199-
return conn, nil
210+
conn, needsCapEnforce := cp.getNextActiveConnWithLock()
211+
if conn != nil {
212+
state := conn.loadConnState()
213+
if state.lifecycle()&(lcActive|lcStandby) != 0 {
214+
if needsCapEnforce {
215+
cp.enforceActiveCapWithLock()
216+
}
217+
cp.poolRequests.Add(1)
218+
return conn, nil
219+
}
220+
// Externally killed (no position bits) -- evict and continue
221+
cp.evictExternallyDemotedWithLock(conn, state)
200222
}
201-
// Externally killed (no position bits) -- evict and continue
202-
cp.evictExternallyDemotedWithLock(conn, state)
203223
// Try remaining active connections
204224
maxSkips := cp.mu.activeCount
205225
for range maxSkips {
206226
if cp.mu.activeCount <= 0 {
207227
break
208228
}
209-
conn = cp.getNextActiveConnWithLock()
210-
state = conn.loadConnState()
229+
conn, needsCapEnforce = cp.getNextActiveConnWithLock()
230+
if conn == nil {
231+
continue
232+
}
233+
state := conn.loadConnState()
211234
if state.lifecycle()&(lcActive|lcStandby) != 0 {
235+
if needsCapEnforce {
236+
cp.enforceActiveCapWithLock()
237+
}
212238
cp.poolRequests.Add(1)
213239
return conn, nil
214240
}

opensearchtransport/pool_selection_internal_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,15 +311,17 @@ func TestEvictExternallyDemotedWithLock(t *testing.T) {
311311
})
312312
}
313313

314-
func TestDeferredCapEnforcement(t *testing.T) {
314+
func TestEnforceActiveCap(t *testing.T) {
315315
t.Run("reduces active to cap", func(t *testing.T) {
316316
a1 := newActiveConn("a1")
317317
a2 := newActiveConn("a2")
318318
a3 := newActiveConn("a3")
319319
pool := newStandbyPool([]*Connection{a1, a2, a3}, nil)
320320
pool.activeListCap = 2
321321

322-
pool.deferredCapEnforcement()
322+
pool.mu.Lock()
323+
pool.enforceActiveCapWithLock()
324+
pool.mu.Unlock()
323325

324326
require.Equal(t, 2, pool.mu.activeCount)
325327
require.Len(t, pool.mu.ready, 3)

opensearchtransport/pool_standby.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func (cp *multiServerPool) promoteFromOverloaded(c *Connection) {
152152
// always allowed in the active partition alongside the capped warmed ones.
153153
// This ensures proven active connections aren't evicted to make room for a
154154
// warming connection that hasn't yet integrated into the traffic mix.
155-
// When a warming connection finishes warmup (detected in Next()), deferredCapEnforcement
155+
// When a warming connection finishes warmup (detected in Next()), triggerCapEnforcement
156156
// fires and this function evicts the excess fully-warmed connection.
157157
//
158158
// No-op when activeListCap <= 0 (disabled) or when the fully-warmed active count

opensearchtransport/standby_rotation_integration_test.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ func discoverWithStandby(t *testing.T, transport *opensearchtransport.Client) op
169169

170170
var m opensearchtransport.Metrics
171171
var lastErr error
172+
var lastActive, lastStandby, lastDead int
172173

173174
// On slower clusters (e.g. v2.0.1 with security plugin), health checks
174175
// during discovery can take several seconds per cycle. Allow up to 30s
@@ -194,8 +195,13 @@ func discoverWithStandby(t *testing.T, transport *opensearchtransport.Client) op
194195
m, err = transport.Metrics()
195196
require.NoError(t, err)
196197

197-
t.Logf("Discovery attempt %d: active=%d, standby=%d, dead=%d",
198-
attempt, m.LiveConnections-m.StandbyConnections, m.StandbyConnections, m.DeadConnections)
198+
active := m.LiveConnections - m.StandbyConnections
199+
// Only log when state changes to reduce CI noise.
200+
if active != lastActive || m.StandbyConnections != lastStandby || m.DeadConnections != lastDead {
201+
t.Logf("Discovery attempt %d: active=%d, standby=%d, dead=%d",
202+
attempt, active, m.StandbyConnections, m.DeadConnections)
203+
lastActive, lastStandby, lastDead = active, m.StandbyConnections, m.DeadConnections
204+
}
199205

200206
if m.StandbyConnections >= 2 {
201207
return m
@@ -205,7 +211,10 @@ func discoverWithStandby(t *testing.T, transport *opensearchtransport.Client) op
205211
if lastErr != nil {
206212
require.NoError(t, lastErr, "all discovery attempts failed")
207213
}
208-
return m
214+
require.FailNowf(t, "discoverWithStandby timed out",
215+
"pool did not reach 2 standby after %d attempts (active=%d, standby=%d, dead=%d)",
216+
attempt, lastActive, lastStandby, lastDead)
217+
return m // unreachable
209218
}
210219

211220
// TestStandbyRotation verifies that standby rotation works end-to-end against
@@ -316,12 +325,12 @@ func TestStandbyRotation(t *testing.T) {
316325
// Each DiscoverNodes call triggers rotateStandby at its end. When the
317326
// pool was just rebuilt from new Connection objects (all lcActive+warming),
318327
// rotation finds no standbys and is a no-op. drainWarmup then completes
319-
// warmup and fires deferredCapEnforcement, re-establishing the standby
328+
// warmup and fires triggerCapEnforcement, re-establishing the standby
320329
// partition. On the *next* DiscoverNodes, connections are reused with
321330
// their lcStandby state intact, so rotation finds standbys and promotes one.
322331
//
323332
// We wait for both promotion AND demotion before breaking because
324-
// deferredCapEnforcement runs asynchronously (goroutine spawned by
333+
// triggerCapEnforcement runs asynchronously (goroutine spawned by
325334
// Next() when warmup completes). By requiring the demotion count to
326335
// advance, we ensure cap enforcement has finished before we assert
327336
// pool state. If the goroutine hasn't run yet, the next iteration's
@@ -406,7 +415,7 @@ func TestStandbyRotation(t *testing.T) {
406415
// clusters like 2.1.0 can move standbys to dead, so we need to
407416
// wait for resurrection + cap enforcement before the next rotation).
408417
// 2. Call DiscoverNodes which triggers rotateStandbyConnections at the end.
409-
// 3. Drain warmup so deferredCapEnforcement can fire.
418+
// 3. Drain warmup so triggerCapEnforcement can fire.
410419
// 4. Check if the observer recorded a new promotion.
411420
for cycle := range 12 {
412421
prevPromotions := obs.promotionCount()

0 commit comments

Comments
 (0)