Skip to content

Commit c9ea9c3

Browse files
committed
fix(opensearchtransport): serialize discovery warmup recalc under pool lock
Backport of the opensearch-project#981 follow-up race fixed on v5 (opensearch-project#989). In createOrUpdateMultiNodePoolWithLock, the warmup recalculation, ready-list partitioning, and mu.activeCount write ran after the pool lock was released. Those touch mu-guarded fields (activeListCap, warmupRounds, warmupSkipCount, activeCount) that resurrectWithLock also reads/writes under pool.mu. The caller holds c.mu(W), which serializes them against metrics.snapshot() but not against resurrection -- a data race. Hold allConnsPool.mu across the whole section; the per-connection conn.mu is taken inside the loop, matching the pool.mu -> conn.mu ordering already used by deferredStandbyPromotion. Rename recalculateWarmupParams/getWarmupParams to *WithLock: every caller now holds the pool lock (the discovery path was the last one that didn't), so the suffix documents the invariant, consistent with the pool's other WithLock methods. Comment-only intent; no behavior change from the rename. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 0515594 commit c9ea9c3

9 files changed

Lines changed: 39 additions & 21 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
215215

216216
### Fixed
217217

218+
- Fix a data race on the multi-server pool's warmup fields in the node-discovery path: `createOrUpdateMultiNodePoolWithLock` recalculated warmup parameters, partitioned the ready list, and wrote `mu.activeCount` after releasing the pool write lock, touching `mu`-guarded fields (`activeListCap`, `warmupRounds`, `warmupSkipCount`, `activeCount`) that `resurrectWithLock` reads and writes under `pool.mu`. The caller holds the transport lock, which serializes this against `metrics.snapshot()` but not against resurrection. The whole section now runs under `allConnsPool.mu`, with per-connection `conn.mu` taken inside the loop (the `pool.mu` -> `conn.mu` ordering already used by `deferredStandbyPromotion`). `recalculateWarmupParams`/`getWarmupParams` are renamed `*WithLock` to document that every caller now holds the pool lock (the discovery path was the last that did not)
218219
- Fix node discovery hijacking the request stream with unverified, unreachable discovered nodes and masking the user-supplied seed-URL fallback. When discovered `publish_address` values are unroutable from the client (NAT'd or misconfigured clusters, e.g. a Kubernetes stack cluster in CI), a freshly discovered but never-health-checked node could be served to requests as a zombie -- failing every request with `no route to host` -- instead of returning `ErrNoConnections` and cascading to the reachable seed URL. Connections are now considered available for routing only when they are a user-supplied seed (assumed reachable) or a discovered node confirmed reachable, and every routing policy and pool (round-robin, role, coordinator, index/doc router, single-server, and multi-server pools) consistently honors that gate on both the enabled-bit and connection-selection paths, so the seed fallback serves requests until a discovered node health-checks clean ([#952](https://github.com/opensearch-project/opensearch-go/pull/952), [#954](https://github.com/opensearch-project/opensearch-go/pull/954), [#956](https://github.com/opensearch-project/opensearch-go/pull/956))
219220
- Fix a data race (reported by the race detector in `TestClientCustomTransport`) between `multiServerPool.snapshot()` and node discovery: `snapshot()` read `activeListCap` after releasing the pool read lock, while `recalculateWarmupParams` writes it under the write lock during `DiscoveryUpdate`. `activeListCap`, `warmupRounds`, and `warmupSkipCount` were guarded by the pool lock only by convention (declared at the top level of the struct), which let the unlocked read look correct; they are now nested inside the pool's lock-guarded `mu` struct so every access is spelled `cp.mu.<field>` and the guard is structural, and `snapshot()` reads `activeListCap` while holding the read lock. For the same reason `healthCheck` is moved under `mu` (it is rewritten by `updateConnectionPool` on pool reuse); this also surfaced one discovery-path read of `healthCheck` that had escaped the lock, now taken under the read lock
220221
- Fix `BulkIndexerStats.NumAdded` overcounting items rejected by `Add()` when the caller's context is cancelled before the item could be enqueued: increment `NumAdded` only after the queue accepts the item, and add a new `BulkAddFailCount` counter for items dropped on the `<-ctx.Done()` branch. Migrate `bulkIndexerStats` fields to `sync/atomic.Uint64` typed values so future direct access is a compile-time error rather than a `-race`-only finding ([#783](https://github.com/opensearch-project/opensearch-go/issues/783))

opensearchtransport/discovery.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,9 +1031,19 @@ func (c *Client) createOrUpdateMultiNodePoolWithLock(readyConnections, deadConne
10311031
}
10321032
}
10331033

1034+
// Recalculate warmup parameters and partition the ready list under the pool
1035+
// write lock. recalculateWarmupParamsWithLock writes mu.activeListCap/warmupRounds/
1036+
// warmupSkipCount, getWarmupParamsWithLock reads them, and the final assignment sets
1037+
// mu.activeCount -- all mu-guarded fields that resurrectWithLock also touches
1038+
// under pool.mu. Holding the lock across the whole section serializes it
1039+
// against resurrection (c.mu, held by the caller, only serializes it against
1040+
// metrics.snapshot). Per-connection conn.mu is taken inside the loop, matching
1041+
// the pool.mu -> conn.mu ordering used by deferredStandbyPromotion.
1042+
allConnsPool.mu.Lock()
1043+
10341044
// Recalculate activeListCap and warmup parameters for the allConns pool before
10351045
// partitioning so startWarmup calls use the correctly-scaled values.
1036-
allConnsPool.recalculateWarmupParams(len(allReadyConns) + len(allDeadConns))
1046+
allConnsPool.recalculateWarmupParamsWithLock(len(allReadyConns) + len(allDeadConns))
10371047

10381048
// Partition ready connections by their current lifecycle state.
10391049
// Reused connections (unchanged in discovery) may already be in standby
@@ -1065,7 +1075,7 @@ func (c *Client) createOrUpdateMultiNodePoolWithLock(readyConnections, deadConne
10651075
conn.mu.Lock()
10661076
conn.casLifecycle(conn.loadConnState(), 0, lcActive, lcUnknown|lcStandby) //nolint:errcheck // lock held; only errLifecycleNoop possible
10671077
conn.mu.Unlock()
1068-
rounds, skip := allConnsPool.getWarmupParams()
1078+
rounds, skip := allConnsPool.getWarmupParamsWithLock()
10691079
conn.startWarmup(rounds, skip)
10701080
if i != activeCount {
10711081
allReadyConns[i], allReadyConns[activeCount] = allReadyConns[activeCount], allReadyConns[i]
@@ -1074,6 +1084,7 @@ func (c *Client) createOrUpdateMultiNodePoolWithLock(readyConnections, deadConne
10741084
}
10751085
}
10761086
allConnsPool.mu.activeCount = activeCount
1087+
allConnsPool.mu.Unlock()
10771088

10781089
// NOTE: enforceActiveCapWithLock() is intentionally NOT called here.
10791090
// The allConns pool is a transport-level container for discovery bookkeeping.

opensearchtransport/policy_cluster_coordinator.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func (p *CoordinatorPolicy) DiscoveryUpdate(added, removed, unchanged []*Connect
130130
targetPoolSize--
131131
}
132132
}
133-
p.pool.recalculateWarmupParams(targetPoolSize)
133+
p.pool.recalculateWarmupParamsWithLock(targetPoolSize)
134134

135135
// Add new coordinating-only connections
136136
for _, newConn := range added {
@@ -152,7 +152,7 @@ func (p *CoordinatorPolicy) DiscoveryUpdate(added, removed, unchanged []*Connect
152152
newConn.mu.Lock()
153153
newConn.casLifecycle(newConn.loadConnState(), 0, lcActive, lcUnknown|lcStandby) //nolint:errcheck,lll // lock held; only errLifecycleNoop possible
154154
newConn.mu.Unlock()
155-
rounds, skip := p.pool.getWarmupParams()
155+
rounds, skip := p.pool.getWarmupParamsWithLock()
156156
newConn.startWarmup(rounds, skip)
157157
p.pool.appendToReadyActiveWithLock(newConn)
158158
} else {

opensearchtransport/policy_role.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,8 @@ func (p *RolePolicy) DiscoveryUpdate(added, removed, unchanged []*Connection) er
164164

165165
// Compute projected pool size for warmup/activeListCap scaling and
166166
// recalculate the warmup parameters under the pool write lock. The lock is
167-
// required because recalculateWarmupParams writes the pool's warmupRounds,
168-
// warmupSkipCount, and activeListCap fields, which getWarmupParams and the
167+
// required because recalculateWarmupParamsWithLock writes the pool's warmupRounds,
168+
// warmupSkipCount, and activeListCap fields, which getWarmupParamsWithLock and the
169169
// other DiscoveryUpdate callers (roundrobin, cluster_coordinator) read and
170170
// write under the same lock. Done before the mutations below so startWarmup
171171
// calls during discoveryUpdateAdd use the new values. The lock is released
@@ -183,7 +183,7 @@ func (p *RolePolicy) DiscoveryUpdate(added, removed, unchanged []*Connection) er
183183
targetPoolSize--
184184
}
185185
}
186-
p.pool.recalculateWarmupParams(targetPoolSize)
186+
p.pool.recalculateWarmupParamsWithLock(targetPoolSize)
187187
p.pool.Unlock()
188188

189189
if added != nil {
@@ -248,7 +248,7 @@ func (p *RolePolicy) discoveryUpdateAdd(added []*Connection) {
248248
conn.mu.Lock()
249249
conn.casLifecycle(conn.loadConnState(), 0, lcActive, lcUnknown|lcStandby) //nolint:errcheck // lock held; only errLifecycleNoop possible
250250
conn.mu.Unlock()
251-
rounds, skip := p.pool.getWarmupParams()
251+
rounds, skip := p.pool.getWarmupParamsWithLock()
252252
conn.startWarmup(rounds, skip)
253253
p.pool.appendToReadyActiveWithLock(conn)
254254
p.pool.shuffleActiveWithLock()

opensearchtransport/policy_role_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ func TestInvalidRoleError(t *testing.T) {
354354

355355
// TestRolePolicyDiscoveryUpdateConcurrent guards against the data race that
356356
// occurs when two DiscoverNodes calls drive DiscoveryUpdate on a shared pool
357-
// simultaneously. recalculateWarmupParams writes the pool's warmupRounds,
357+
// simultaneously. recalculateWarmupParamsWithLock writes the pool's warmupRounds,
358358
// warmupSkipCount, and activeListCap fields; those writes must happen under the
359359
// pool write lock (as the roundrobin and cluster_coordinator policies already
360360
// do). Without the lock, concurrent updates race on those fields. Run under
@@ -367,7 +367,7 @@ func TestRolePolicyDiscoveryUpdateConcurrent(t *testing.T) {
367367
require.NoError(t, rolePolicy.configurePolicySettings(createTestConfig()))
368368

369369
// Two connections that alternate in and out of the pool so each goroutine
370-
// drives a real add/remove pass through recalculateWarmupParams.
370+
// drives a real add/remove pass through recalculateWarmupParamsWithLock.
371371
connA := createTestConnection("http://localhost:9200", RoleData)
372372
connB := createTestConnection("http://localhost:9201", RoleData)
373373

opensearchtransport/policy_roundrobin.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func (p *RoundRobinPolicy) DiscoveryUpdate(added, removed, unchanged []*Connecti
114114
// Recalculate activeListCap and warmup parameters based on projected pool size.
115115
// Done before adds/removes so startWarmup calls use the correctly-scaled values.
116116
targetPoolSize := len(p.pool.mu.ready) + len(p.pool.mu.dead) + len(added) - len(removed)
117-
p.pool.recalculateWarmupParams(targetPoolSize)
117+
p.pool.recalculateWarmupParamsWithLock(targetPoolSize)
118118

119119
// Add new connections based on their health status
120120
for _, conn := range added {
@@ -132,7 +132,7 @@ func (p *RoundRobinPolicy) DiscoveryUpdate(added, removed, unchanged []*Connecti
132132
conn.mu.Lock()
133133
conn.casLifecycle(conn.loadConnState(), 0, lcActive, lcUnknown|lcStandby) //nolint:errcheck // lock held; only errLifecycleNoop possible
134134
conn.mu.Unlock()
135-
rounds, skip := p.pool.getWarmupParams()
135+
rounds, skip := p.pool.getWarmupParamsWithLock()
136136
conn.startWarmup(rounds, skip)
137137
p.pool.appendToReadyActiveWithLock(conn)
138138

opensearchtransport/pool_coverage_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ func TestRecalculateWarmupParams(t *testing.T) {
325325
t.Run("auto-scales activeListCap", func(t *testing.T) {
326326
t.Parallel()
327327
pool := &multiServerPool{}
328-
pool.recalculateWarmupParams(5)
328+
pool.recalculateWarmupParamsWithLock(5)
329329

330330
require.Equal(t, 5, pool.mu.activeListCap)
331331
require.Positive(t, pool.mu.warmupRounds)
@@ -337,7 +337,7 @@ func TestRecalculateWarmupParams(t *testing.T) {
337337
explicitCap := 2
338338
pool := &multiServerPool{activeListCapConfig: &explicitCap}
339339
pool.mu.activeListCap = 2
340-
pool.recalculateWarmupParams(5)
340+
pool.recalculateWarmupParamsWithLock(5)
341341

342342
// activeListCap should not change when explicit
343343
require.Equal(t, 2, pool.mu.activeListCap)

opensearchtransport/pool_multi_server.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type multiServerPool struct {
4747
// instead of active when the ready list's active partition is at capacity.
4848
activeListCap int // 0 = disabled (all connections go to active)
4949

50-
// Dynamic warmup parameters, scaled by recalculateWarmupParams().
50+
// Dynamic warmup parameters, scaled by recalculateWarmupParamsWithLock().
5151
// Small pools get lighter warmup (fewer rounds, fewer skips) so connections
5252
// ramp up quickly. Large pools get heavier warmup to avoid traffic spikes.
5353
warmupRounds int // 0 = use defaultWarmupRounds
@@ -314,7 +314,7 @@ func (cp *multiServerPool) hasAvailableConnsWithLock() bool {
314314
return false
315315
}
316316

317-
// recalculateWarmupParams recalculates activeListCap (when auto-scaling) and sets
317+
// recalculateWarmupParamsWithLock recalculates activeListCap (when auto-scaling) and sets
318318
// warmupRounds/warmupSkipCount based on effective pool size.
319319
//
320320
// poolSize is the projected total number of connections in the pool (ready + dead)
@@ -326,7 +326,10 @@ func (cp *multiServerPool) hasAvailableConnsWithLock() bool {
326326
// n = poolSize when activeListCap <= 0
327327
// rounds = clamp(n, minWarmupRounds, maxWarmupRounds)
328328
// skipCount = rounds * warmupSkipMultiple
329-
func (cp *multiServerPool) recalculateWarmupParams(poolSize int) {
329+
//
330+
// Caller must hold the pool write lock: it reads and writes the mu-guarded
331+
// activeListCap, warmupRounds, and warmupSkipCount fields.
332+
func (cp *multiServerPool) recalculateWarmupParamsWithLock(poolSize int) {
330333
// Auto-scale activeListCap when the user didn't specify an explicit value.
331334
if cp.activeListCapConfig == nil && poolSize > 0 {
332335
cp.mu.activeListCap = poolSize
@@ -345,9 +348,12 @@ func (cp *multiServerPool) recalculateWarmupParams(poolSize int) {
345348
cp.mu.warmupSkipCount = rounds * warmupSkipMultiple
346349
}
347350

348-
// getWarmupParams returns the effective warmup parameters for this pool.
351+
// getWarmupParamsWithLock returns the effective warmup parameters for this pool.
349352
// Returns pool-specific values if set, otherwise falls back to defaults.
350-
func (cp *multiServerPool) getWarmupParams() (int, int) {
353+
//
354+
// Caller must hold the pool lock: it reads the mu-guarded warmupRounds and
355+
// warmupSkipCount fields.
356+
func (cp *multiServerPool) getWarmupParamsWithLock() (int, int) {
351357
rounds := cp.mu.warmupRounds
352358
if rounds <= 0 {
353359
rounds = defaultWarmupRounds
@@ -602,7 +608,7 @@ func (cp *multiServerPool) resurrectWithLock(c *Connection) {
602608
if cp.mu.activeListCap <= 0 || cp.mu.activeCount < cp.mu.activeListCap {
603609
// Transition state: dead -> active with warmup (lcNeedsWarmup preserved if set)
604610
c.casLifecycle(c.loadConnState(), 0, lcActive, lcUnknown|lcStandby) //nolint:errcheck // lock held; only errLifecycleNoop possible
605-
rounds, skip := cp.getWarmupParams()
611+
rounds, skip := cp.getWarmupParamsWithLock()
606612
c.startWarmup(rounds, skip)
607613
cp.appendToReadyActiveWithLock(c)
608614
cp.shuffleActiveWithLock()

opensearchtransport/pool_standby.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ func (cp *multiServerPool) promoteStandbyWithLock(c *Connection) bool {
302302
c.mu.Lock()
303303
c.casLifecycle(c.loadConnState(), 0, lcActive, lcStandby) //nolint:errcheck // lock held; only errLifecycleNoop possible
304304
c.mu.Unlock()
305-
rounds, skip := cp.getWarmupParams()
305+
rounds, skip := cp.getWarmupParamsWithLock()
306306
c.startWarmup(rounds, skip)
307307
cp.mu.activeCount++
308308

0 commit comments

Comments
 (0)