Skip to content

Commit 9716425

Browse files
authored
perf(opensearchtransport): make connection dead/overloaded timestamps lock-free (#892) (#906)
Convert deadSince/overloadedAt from mu-guarded time.Time to lock-free atomic.Int64 (UnixNano, 0 = unset). Writes still occur under c.mu so the resurrection/standby read-modify-write decisions stay serialized; only the reads went lock-free, so buildConnectionMetric no longer takes each connection's mutex -- the #1 explicit-lock contention site under concurrent load. Port of the non-breaking perf work from #901; v4 retains EnableMetrics (unchanged surface and behavior). Also fix a pre-existing data race in RolePolicy.DiscoveryUpdate, surfaced while auditing the pool locking. It called recalculateWarmupParams without holding the pool write lock, while the roundrobin and cluster_coordinator policies took pool.Lock() for the identical call. recalculateWarmupParams writes the pool's warmupRounds, warmupSkipCount, and activeListCap fields, which getWarmupParams and the other DiscoveryUpdate callers read and write under that same lock, so two concurrent DiscoverNodes calls raced on those fields. Compute the projected pool size and recalculate warmup parameters under pool.Lock(), releasing before discoveryUpdateAdd/Remove. Add TestRolePolicyDiscoveryUpdateConcurrent, which reproduces the race under -race. Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
1 parent ca3c1c5 commit 9716425

22 files changed

Lines changed: 297 additions & 96 deletions

CHANGELOG.md

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

165165
### Changed
166166

167+
- Connection dead/overloaded timestamps (`deadSince`/`overloadedAt`) are now lock-free `atomic.Int64` values. `buildConnectionMetric` no longer locks each connection to read them, removing the dominant metrics-snapshot lock contention against the per-request writers. No public API change; `EnableMetrics` behavior is unchanged. ([#892](https://github.com/opensearch-project/opensearch-go/issues/892))
167168
- Per-request transport metrics (`requests`, `failures`, responses-by-status) are now always collected via lock-free atomics, independent of `EnableMetrics`. `EnableMetrics` now gates only the detailed-metrics snapshot (per-connection, per-policy, and router state returned by `Metrics()`). The responses-by-status counter moved from a mutex-guarded map to a lock-free atomic array. `Metrics()` no longer returns an error when metrics are disabled — it always returns the per-request counters (callers that branched on `if err != nil` for the disabled case should drop that check). ([#891](https://github.com/opensearch-project/opensearch-go/issues/891))
168169
- **BREAKING**: `opensearch.Request` interface signature changed from `GetRequest() (*http.Request, error)` to `GetRequest(method string) (*http.Request, error)`. The HTTP method is now caller-provided rather than hardcoded per operation, enabling correct method selection for operations that support multiple HTTP methods (e.g. search supports both GET and POST). This only affects code that implements or calls `GetRequest` directly; standard usage through client methods (e.g. `client.Search(ctx, req)`) is unaffected ([#650](https://github.com/opensearch-project/opensearch-go/issues/650))
169170
- Bump CI and developer guide OpenSearch versions: compatibility matrix to 2.19.5, default integration test version to 3.6.0 ([#810](https://github.com/opensearch-project/opensearch-go/pull/810))

opensearchtransport/cluster_health_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,7 +1382,7 @@ func TestFetchAndEvaluateNodeStats(t *testing.T) {
13821382
conn := &Connection{URL: serverURL}
13831383
conn.state.Store(int64(newConnState(lcStandby | lcOverloaded)))
13841384
conn.mu.Lock()
1385-
conn.mu.overloadedAt = time.Now()
1385+
conn.storeOverloadedAt(time.Now())
13861386
conn.mu.Unlock()
13871387

13881388
a1 := newActiveConn("a1")

opensearchtransport/connection.go

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ type Connection struct {
183183
failures atomic.Int64
184184
state atomic.Int64 // Packed connState: connLifecycle (12b) + 2*warmupManager (26b each)
185185

186+
// deadSinceNano and overloadedAtNano hold Unix-nanosecond timestamps, with 0
187+
// meaning "unset" (the zero time). They are read lock-free by Metrics() and
188+
// written under c.mu; see c.mu for the locking protocol. Use the
189+
// loadDeadSince/storeDeadSince/deadSinceIsZero accessors (and the overloadedAt
190+
// equivalents) rather than touching these directly.
191+
deadSinceNano atomic.Int64
192+
overloadedAtNano atomic.Int64
193+
186194
// drainingQuiescingRemaining counts the number of successful health checks remaining
187195
// before this connection can be resurrected. Set to defaultDrainingQuiescingChecks when
188196
// an HTTP/2 stream reset is observed (RST_STREAM, e.g., REFUSED_STREAM). Each successful
@@ -194,13 +202,14 @@ type Connection struct {
194202
// defaultDrainingQuiescingChecks * resurrectTimeout.
195203
drainingQuiescingRemaining atomic.Int64
196204

205+
// mu guards the fields below and serializes the resurrection/standby
206+
// read-modify-write decisions. The deadSinceNano/overloadedAtNano atomics are
207+
// written under mu but read lock-free.
197208
mu struct {
198209
sync.RWMutex
199-
deadSince time.Time
200210
checkStartedAt time.Time
201211
clusterHealth *ClusterHealthLocal // Populated when lcClusterHealthAvailable is set
202212
clusterHealthCheckedAt time.Time // When cluster health was last probed (for retry timing)
203-
overloadedAt time.Time // When overloaded state was last set (lcOverloaded metadata bit)
204213
lastBreakerTripped map[string]int64 // Previous tripped counts for delta detection
205214
}
206215

@@ -222,6 +231,44 @@ type Connection struct {
222231
}
223232
}
224233

234+
// timeToNano converts a time.Time to its Unix-nanosecond representation. The
235+
// zero value is preserved to imply "unset."
236+
func timeToNano(t time.Time) int64 {
237+
if t.IsZero() {
238+
return 0
239+
}
240+
return t.UnixNano()
241+
}
242+
243+
// nanoToTime converts a stored Unix-nanosecond value back to time.Time in UTC,
244+
// mapping the 0 sentinel to the zero time.
245+
func nanoToTime(n int64) time.Time {
246+
if n == 0 {
247+
return time.Time{}
248+
}
249+
return time.Unix(0, n).UTC()
250+
}
251+
252+
// loadDeadSince returns the time the connection was marked dead, or the zero
253+
// time if it is alive. Lock-free.
254+
func (c *Connection) loadDeadSince() time.Time { return nanoToTime(c.deadSinceNano.Load()) }
255+
256+
// storeDeadSince records (or clears, with the zero time) the dead timestamp.
257+
// Callers hold c.mu; see c.mu for the locking protocol.
258+
func (c *Connection) storeDeadSince(t time.Time) { c.deadSinceNano.Store(timeToNano(t)) }
259+
260+
// deadSinceIsZero reports whether the connection is alive (no dead timestamp).
261+
// Lock-free.
262+
func (c *Connection) deadSinceIsZero() bool { return c.deadSinceNano.Load() == 0 }
263+
264+
// loadOverloadedAt returns the time the connection was last marked overloaded,
265+
// or the zero time. Lock-free.
266+
func (c *Connection) loadOverloadedAt() time.Time { return nanoToTime(c.overloadedAtNano.Load()) }
267+
268+
// storeOverloadedAt records (or clears, with the zero time) the overloaded
269+
// timestamp. Callers hold c.mu; see c.mu for the locking protocol.
270+
func (c *Connection) storeOverloadedAt(t time.Time) { c.overloadedAtNano.Store(timeToNano(t)) }
271+
225272
// effectiveWeight returns the connection's weight for round-robin selection.
226273
// Returns 1 if weight is zero (default for connections created without explicit weight).
227274
func (c *Connection) effectiveWeight() int {
@@ -270,20 +317,20 @@ func (c *Connection) decrementDrainingQuiescing() int64 {
270317

271318
// markAsDeadWithLock marks the connection as dead (caller must hold lock).
272319
func (c *Connection) markAsDeadWithLock() {
273-
if c.mu.deadSince.IsZero() {
274-
c.mu.deadSince = time.Now().UTC()
320+
if c.deadSinceIsZero() {
321+
c.storeDeadSince(time.Now().UTC())
275322
}
276323
c.failures.Add(1)
277324
}
278325

279326
// markAsReadyWithLock marks the connection as alive (caller must hold lock).
280327
func (c *Connection) markAsReadyWithLock() {
281-
c.mu.deadSince = time.Time{}
328+
c.storeDeadSince(time.Time{})
282329
}
283330

284331
// markAsHealthyWithLock marks the connection as healthy (caller must hold lock).
285332
func (c *Connection) markAsHealthyWithLock() {
286-
c.mu.deadSince = time.Time{}
333+
c.storeDeadSince(time.Time{})
287334
c.failures.Store(0)
288335
}
289336

@@ -452,9 +499,7 @@ func (c *Connection) storeMaxCwnd(poolName string, size int) {
452499

453500
// String returns a readable connection representation.
454501
func (c *Connection) String() string {
455-
c.mu.RLock()
456-
deadAt := c.mu.deadSince
457-
c.mu.RUnlock()
502+
deadAt := c.loadDeadSince()
458503

459504
if deadAt.IsZero() {
460505
return fmt.Sprintf("<%s> dead=false failures=%d", c.URL, c.failures.Load())

opensearchtransport/connection_benchmark_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func createMultiServerPool(conns []*Connection) *multiServerPool {
160160
for _, conn := range ready {
161161
conn.state.Store(int64(newConnState(lcActive)))
162162
conn.mu.Lock()
163-
conn.mu.deadSince = time.Time{} // Reset from prior benchmark sub-runs
163+
conn.storeDeadSince(time.Time{}) // Reset from prior benchmark sub-runs
164164
conn.mu.Unlock()
165165
}
166166
pool.mu.ready = ready

opensearchtransport/connection_internal_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,14 @@ func TestMultiServerPoolOnSuccess(t *testing.T) {
142142
pool.OnSuccess(conn)
143143

144144
conn.mu.Lock()
145-
isDead := !conn.mu.deadSince.IsZero()
145+
isDead := !conn.loadDeadSince().IsZero()
146146
conn.mu.Unlock()
147147
if isDead {
148148
t.Errorf("Expected the connection to be ready; %s", conn)
149149
}
150150

151151
conn.mu.Lock()
152-
deadSince := conn.mu.deadSince
152+
deadSince := conn.loadDeadSince()
153153
conn.mu.Unlock()
154154
if !deadSince.IsZero() {
155155
t.Errorf("Unexpected value for DeadSince: %s", deadSince)
@@ -203,8 +203,8 @@ func TestMultiServerPoolOnFailure(t *testing.T) {
203203
t.Fatalf("Unexpected error: %s", err)
204204
}
205205
conn.mu.Lock()
206-
isDead := !conn.mu.deadSince.IsZero()
207-
deadSince := conn.mu.deadSince
206+
isDead := !conn.loadDeadSince().IsZero()
207+
deadSince := conn.loadDeadSince()
208208
conn.mu.Unlock()
209209

210210
if !isDead {
@@ -268,7 +268,7 @@ func TestMultiServerPoolOnFailure(t *testing.T) {
268268
conn := pool.mu.ready[0]
269269
conn.state.Store(int64(newConnState(lcDead)))
270270
conn.mu.Lock()
271-
conn.mu.deadSince = time.Now().UTC()
271+
conn.storeDeadSince(time.Now().UTC())
272272
conn.mu.Unlock()
273273

274274
if err := pool.OnFailure(conn); err != nil {
@@ -288,8 +288,8 @@ func TestConnection(t *testing.T) {
288288
}
289289
conn.failures.Store(10)
290290
conn.mu.Lock()
291-
conn.mu.deadSince = time.Now().UTC()
292-
conn.mu.deadSince = time.Now().UTC()
291+
conn.storeDeadSince(time.Now().UTC())
292+
conn.storeDeadSince(time.Now().UTC())
293293
conn.mu.Unlock()
294294

295295
match, err := regexp.MatchString(

opensearchtransport/coverage_critical_path_internal_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func TestPolicyChainOnSuccess(t *testing.T) {
4343
chain.OnSuccess(conn)
4444
// Verify still alive
4545
conn.mu.RLock()
46-
require.True(t, conn.mu.deadSince.IsZero())
46+
require.True(t, conn.loadDeadSince().IsZero())
4747
conn.mu.RUnlock()
4848
})
4949

@@ -53,13 +53,13 @@ func TestPolicyChainOnSuccess(t *testing.T) {
5353
conn := createDeadTestConnection("http://node:9200")
5454

5555
conn.mu.RLock()
56-
require.False(t, conn.mu.deadSince.IsZero(), "precondition: must be dead")
56+
require.False(t, conn.loadDeadSince().IsZero(), "precondition: must be dead")
5757
conn.mu.RUnlock()
5858

5959
chain.OnSuccess(conn)
6060

6161
conn.mu.RLock()
62-
require.True(t, conn.mu.deadSince.IsZero(), "OnSuccess should mark dead connection healthy")
62+
require.True(t, conn.loadDeadSince().IsZero(), "OnSuccess should mark dead connection healthy")
6363
conn.mu.RUnlock()
6464
})
6565

@@ -70,13 +70,13 @@ func TestPolicyChainOnSuccess(t *testing.T) {
7070
conn.drainingQuiescingRemaining.Store(3)
7171

7272
conn.mu.RLock()
73-
deadBefore := conn.mu.deadSince
73+
deadBefore := conn.loadDeadSince()
7474
conn.mu.RUnlock()
7575

7676
chain.OnSuccess(conn)
7777

7878
conn.mu.RLock()
79-
require.Equal(t, deadBefore, conn.mu.deadSince, "draining connection must stay dead")
79+
require.Equal(t, deadBefore, conn.loadDeadSince(), "draining connection must stay dead")
8080
conn.mu.RUnlock()
8181
})
8282

@@ -88,13 +88,13 @@ func TestPolicyChainOnSuccess(t *testing.T) {
8888
conn.state.Store(int64(newConnState(lcDead | lcOverloaded | lcNeedsWarmup)))
8989

9090
conn.mu.RLock()
91-
deadBefore := conn.mu.deadSince
91+
deadBefore := conn.loadDeadSince()
9292
conn.mu.RUnlock()
9393

9494
chain.OnSuccess(conn)
9595

9696
conn.mu.RLock()
97-
require.Equal(t, deadBefore, conn.mu.deadSince, "overloaded connection must stay dead")
97+
require.Equal(t, deadBefore, conn.loadDeadSince(), "overloaded connection must stay dead")
9898
conn.mu.RUnlock()
9999
})
100100

@@ -115,7 +115,7 @@ func TestPolicyChainOnSuccess(t *testing.T) {
115115
wg.Wait()
116116

117117
conn.mu.RLock()
118-
require.True(t, conn.mu.deadSince.IsZero(), "after concurrent OnSuccess, connection must be healthy")
118+
require.True(t, conn.loadDeadSince().IsZero(), "after concurrent OnSuccess, connection must be healthy")
119119
conn.mu.RUnlock()
120120
})
121121
}

opensearchtransport/discovery.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,12 +694,12 @@ func (c *Client) updateConnectionPool(
694694
for url, conn := range finalConnectionsByURL {
695695
if _, isReady := readyURLs[url]; isReady {
696696
conn.mu.Lock()
697-
deadSince := conn.mu.deadSince
697+
deadSince := conn.loadDeadSince()
698698
stale := !deadSince.IsZero() && !healthCheckedAt.IsZero() && deadSince.Before(healthCheckedAt)
699699
switch {
700700
case stale:
701701
// Dead state predates the health check -- resurrect.
702-
conn.mu.deadSince = time.Time{}
702+
conn.storeDeadSince(time.Time{})
703703
conn.mu.Unlock()
704704
conn.failures.Store(0)
705705
case !deadSince.IsZero():

opensearchtransport/discovery_internal_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,7 +1869,7 @@ func TestUpdateConnectionPool(t *testing.T) {
18691869

18701870
// Mark the connection as dead in the past
18711871
conn.mu.Lock()
1872-
conn.mu.deadSince = time.Now().Add(-5 * time.Second)
1872+
conn.storeDeadSince(time.Now().Add(-5 * time.Second))
18731873
conn.mu.Unlock()
18741874
conn.failures.Store(3)
18751875

@@ -1881,7 +1881,7 @@ func TestUpdateConnectionPool(t *testing.T) {
18811881

18821882
// The old connection's dead state should have been cleared (resurrected)
18831883
conn.mu.RLock()
1884-
deadSince := conn.mu.deadSince
1884+
deadSince := conn.loadDeadSince()
18851885
conn.mu.RUnlock()
18861886
require.True(t, deadSince.IsZero(), "stale dead state should be cleared")
18871887
require.Equal(t, int64(0), conn.failures.Load(), "failures should be reset")
@@ -1901,15 +1901,15 @@ func TestUpdateConnectionPool(t *testing.T) {
19011901

19021902
// Mark dead AFTER the health check time
19031903
conn.mu.Lock()
1904-
conn.mu.deadSince = healthCheckedAt.Add(1 * time.Second)
1904+
conn.storeDeadSince(healthCheckedAt.Add(1 * time.Second))
19051905
conn.mu.Unlock()
19061906

19071907
// Re-discover with SAME pointer: dead state is newer than healthCheckedAt -> should stay dead
19081908
err = client.updateConnectionPool(t.Context(), healthCheckedAt, []*Connection{conn}, nil)
19091909
require.NoError(t, err)
19101910

19111911
conn.mu.RLock()
1912-
deadSince := conn.mu.deadSince
1912+
deadSince := conn.loadDeadSince()
19131913
conn.mu.RUnlock()
19141914
require.False(t, deadSince.IsZero(), "concurrent dead state should be preserved")
19151915
})
@@ -1983,7 +1983,7 @@ func TestUpdateConnectionPool(t *testing.T) {
19831983

19841984
// Verify deadSince is initially zero
19851985
dead.mu.RLock()
1986-
require.True(t, dead.mu.deadSince.IsZero(), "deadSince should be zero before pool placement")
1986+
require.True(t, dead.loadDeadSince().IsZero(), "deadSince should be zero before pool placement")
19871987
dead.mu.RUnlock()
19881988

19891989
err := client.updateConnectionPool(t.Context(), time.Time{}, []*Connection{ready}, []*Connection{dead})
@@ -2001,7 +2001,7 @@ func TestUpdateConnectionPool(t *testing.T) {
20012001

20022002
// Verify deadSince was set by appendToDeadWithLock
20032003
deadConn.mu.RLock()
2004-
require.False(t, deadConn.mu.deadSince.IsZero(), "deadSince must be set for dead-list connections")
2004+
require.False(t, deadConn.loadDeadSince().IsZero(), "deadSince must be set for dead-list connections")
20052005
deadConn.mu.RUnlock()
20062006

20072007
// Verify lcUnknown is set

opensearchtransport/metrics.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,12 @@ func buildConnectionMetric(c *Connection) ConnectionMetric {
416416
state := c.loadConnState()
417417
lc := state.lifecycle()
418418

419-
c.mu.Lock()
420-
deadSince := c.mu.deadSince
421-
overloadedAt := c.mu.overloadedAt
422-
c.mu.Unlock()
419+
// Read the dead/overloaded timestamps lock-free: they are written under c.mu
420+
// but safe to read without it, so the metrics snapshot avoids the dominant
421+
// per-connection lock contention against the per-request OnSuccess/OnFailure
422+
// writers.
423+
deadSince := c.loadDeadSince()
424+
overloadedAt := c.loadOverloadedAt()
423425

424426
cm := ConnectionMetric{
425427
URL: c.URL.String(),

0 commit comments

Comments
 (0)