@@ -48,6 +48,14 @@ import (
4848// connection pool is left untouched.
4949var errDiscoveryEmpty = errors .New ("discovery returned zero successful nodes" )
5050
51+ // errDiscoveryInterrupted is returned to a waiting caller when the discovery
52+ // it blocked on was aborted by the cancellation of the goroutine that ran it,
53+ // rather than by the waiter's own context. The waiter's context is still
54+ // healthy, so it must not inherit the runner's context.Canceled (which would
55+ // look like the waiter's own cancellation and could suppress a legitimate
56+ // retry); it receives this sentinel instead and may retry discovery.
57+ var errDiscoveryInterrupted = errors .New ("discovery interrupted by another caller's cancellation" )
58+
5159// Node role constants matching upstream OpenSearch server definitions.
5260const (
5361 // RoleData nodes store and retrieve data, perform indexing, searching, and
@@ -252,34 +260,101 @@ func (m *_NodesMeta) formatFailures() string {
252260 return string (b )
253261}
254262
255- // DiscoverNodes reloads the client connections by fetching information from the cluster.
263+ // DiscoverNodes reloads the client connections by fetching information from
264+ // the cluster. If another discovery is already in progress, DiscoverNodes
265+ // blocks until that discovery completes (or ctx is cancelled) and returns
266+ // its result.
256267func (c * Client ) DiscoverNodes (ctx context.Context ) error {
257- // Bail out early if the context is already cancelled (e.g. client shutting down).
258268 if ctx .Err () != nil {
259269 return ctx .Err ()
260270 }
261271
262- // Prevent concurrent discovery operations
263- c .mu .Lock ()
264- if c .mu .discoveryInProgress {
265- c .mu .Unlock ()
272+ c .discoverMu .Lock ()
273+
274+ if c .discoverMu .inProgress {
275+ // Another goroutine is running discovery. Wait for it using
276+ // sync.Cond + context.AfterFunc so that context cancellation
277+ // wakes us even though Cond.Wait is not context-aware.
278+ stopf := context .AfterFunc (ctx , func () {
279+ c .discoverMu .Lock ()
280+ defer c .discoverMu .Unlock ()
281+ c .discoverMu .cond .Broadcast ()
282+ })
283+ defer stopf ()
284+
285+ for c .discoverMu .inProgress {
286+ c .discoverMu .cond .Wait ()
287+ if ctx .Err () != nil {
288+ c .discoverMu .Unlock ()
289+ return ctx .Err ()
290+ }
291+ }
292+ err := c .discoverMu .lastErr
293+ c .discoverMu .Unlock ()
294+ // If our own context was cancelled (it can race the final
295+ // Broadcast and skip the in-loop check), that takes precedence.
296+ if ctx .Err () != nil {
297+ return ctx .Err ()
298+ }
299+ // Otherwise the discovery we waited on may have been aborted by the
300+ // runner's context, not ours. Don't inherit a cancellation we never
301+ // initiated -- report a retryable sentinel instead.
302+ if err != nil && (errors .Is (err , context .Canceled ) || errors .Is (err , context .DeadlineExceeded )) {
303+ return errDiscoveryInterrupted
304+ }
305+ return err
306+ }
307+
308+ // We won the race: start discovery.
309+ // Lock is held — doDiscoverNodes takes ownership and releases it.
310+ return c .doDiscoverNodes (ctx )
311+ }
312+
313+ // tryDiscoverNodes attempts to start a discovery cycle. If discovery is
314+ // already in progress it returns nil immediately without waiting.
315+ //
316+ // This is used by the internal discoveryLoop, which must never block on
317+ // another discovery. It could be exported in the future if callers need
318+ // fire-and-forget semantics on the public API.
319+ func (c * Client ) tryDiscoverNodes (ctx context.Context ) error {
320+ if ctx .Err () != nil {
321+ return ctx .Err ()
322+ }
323+
324+ c .discoverMu .Lock ()
325+ if c .discoverMu .inProgress {
326+ c .discoverMu .Unlock ()
266327 return nil
267328 }
268- c .mu .discoveryInProgress = true
269- c .mu .Unlock ()
329+ // Lock is held — doDiscoverNodes takes ownership and releases it.
330+ return c .doDiscoverNodes (ctx )
331+ }
270332
333+ // doDiscoverNodes performs the discovery work.
334+ //
335+ // Called with c.discoverMu held. It sets inProgress = true, releases the
336+ // lock for I/O, then re-acquires it on completion to clear inProgress,
337+ // store the result, and wake any waiters.
338+ func (c * Client ) doDiscoverNodes (ctx context.Context ) error {
339+ c .discoverMu .inProgress = true
340+ c .discoverMu .Unlock ()
341+
342+ var discoverErr error
271343 defer func () {
272- c .mu .Lock ()
273- c .mu .discoveryInProgress = false
274- c .mu .Unlock ()
344+ c .discoverMu .Lock ()
345+ c .discoverMu .inProgress = false
346+ c .discoverMu .lastErr = discoverErr
347+ c .discoverMu .cond .Broadcast ()
348+ c .discoverMu .Unlock ()
275349 }()
276350
277351 discovered , err := c .getNodesInfo (ctx )
278352 if err != nil {
279353 if dl := loadDebugLogger (); dl != nil {
280354 dl .Logf ("Error getting nodes info: %s\n " , err )
281355 }
282- return fmt .Errorf ("discovery: get nodes: %w" , err )
356+ discoverErr = fmt .Errorf ("discovery: get nodes: %w" , err )
357+ return discoverErr
283358 }
284359
285360 c .mu .RLock ()
@@ -289,11 +364,13 @@ func (c *Client) DiscoverNodes(ctx context.Context) error {
289364
290365 if isColdStart {
291366 if err := c .nodeDiscoveryAsyncStart (ctx , discovered ); err != nil {
292- return err
367+ discoverErr = err
368+ return discoverErr
293369 }
294370 } else {
295371 if err := c .nodeDiscovery (ctx , discovered ); err != nil {
296- return err
372+ discoverErr = err
373+ return discoverErr
297374 }
298375 }
299376
@@ -1217,7 +1294,7 @@ func (c *Client) discoveryLoop() {
12171294 switch {
12181295 case ! now .Before (nextNodes ):
12191296 // Full node + shard discovery.
1220- c .DiscoverNodes (c .ctx ) //nolint:errcheck // errors logged inside
1297+ c .tryDiscoverNodes (c .ctx ) //nolint:errcheck // errors logged inside
12211298
12221299 nextNodes = time .Now ().Add (c .discoverNodesInterval )
12231300 nextCat = time.Time {}
0 commit comments