This is an automated email from the ASF dual-hosted git repository.

AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git


The following commit(s) were added to refs/heads/develop by this push:
     new 0f71ba09e fix(cluster): eliminate data race on StickyInvoker with 
atomic.Value (#3439)
0f71ba09e is described below

commit 0f71ba09e67bcaf84a089ae098965c93c933ed57
Author: aias00 <[email protected]>
AuthorDate: Sat Jul 25 18:20:24 2026 -0700

    fix(cluster): eliminate data race on StickyInvoker with atomic.Value (#3439)
    
    * fix(cluster): eliminate data race on StickyInvoker with atomic.Value
    
    StickyInvoker in BaseClusterInvoker was a plain field read/written by
    multiple goroutines (e.g. failback retry goroutine + Invoke goroutine)
    without any synchronization, causing a data race on the RPC hot path.
    
    Replace the unexported StickyInvoker field with an atomic.Value,
    accessed via getStickyInvoker()/setStickyInvoker() helpers. This
    eliminates the race while keeping the lock-free read path fast.
    
    Key changes:
    - StickyInvoker base.Invoker -> stickyInvoker atomic.Value (unexported)
    - Add getStickyInvoker() / setStickyInvoker() helpers
    - Update IsAvailable() and DoSelect() to use atomic accessors
    - DoSelect reads sticky invoker into a local variable to avoid
      redundant atomic loads and ensure consistent logic within one call
    - Add concurrent tests (TestStickyConcurrentDoSelect,
      TestStickyConcurrentIsAvailableAndDoSelect) that pass with -race
    
    Co-Authored-By: Claude <[email protected]>
    
    * fix(cluster): use wrapper type for atomic.Value to prevent nil Store and 
type mismatch panics
    
    Address Copilot review feedback:
    1. atomic.Value.Store(nil) panics - wrap base.Invoker in 
stickyInvokerWrapper
       struct so nil invoker is stored as {invoker: nil} (non-nil wrapper)
    2. atomic.Value requires consistent concrete type - the wrapper struct
       ensures the same type is always stored regardless of Invoker impl
    3. Fix test TestStickyConcurrentIsAvailableAndDoSelect to actually call
       IsAvailable() with a proper mock Directory (instead of getStickyInvoker)
    4. Fix imports formatting per dubbo-go convention (separate import groups)
    
    Co-Authored-By: Claude <[email protected]>
    
    * style: fix import formatting in test file per imports-formatter
    
    Co-Authored-By: Claude <[email protected]>
    
    * refactor(cluster): use atomic.Pointer for StickyInvoker, return 
*BaseClusterInvoker from constructor
    
    - Replace atomic.Value + stickyInvokerWrapper + getter/setter with
      atomic.Pointer[base.Invoker] — idiomatic Go, nil-safe, type-safe,
      no wrapper needed
    - NewBaseClusterInvoker now returns *BaseClusterInvoker (pointer),
      preventing accidental value copies of the struct containing atomic
      fields
    - All 9 cluster implementations updated to use pointer embedding
      (*base.BaseClusterInvoker) to match the new constructor return type
    - StickyInvoker field remains exported (atomic.Pointer is safe for
      concurrent access by design)
    
    Co-Authored-By: Claude <[email protected]>
    
    * fix(cluster): fix StickyInvoker data race without breaking exported v3 API
    
    Address [P1] review on PR #3439: the previous fix changed two exported
    symbols already released in v3.3.2-20260709 — the StickyInvoker field
    type (base.Invoker -> atomic.Pointer[base.Invoker]) and the
    NewBaseClusterInvoker return type (value -> pointer) — breaking source
    compatibility for external consumers.
    
    Restore the original signatures and implement the race fix via
    unexported internal state instead:
    
    - Keep StickyInvoker as the exported base.Invoker field.
    - Keep NewBaseClusterInvoker returning BaseClusterInvoker by value.
    - Revert cluster invoker embeddings back to value embeddings.
    - Guard StickyInvoker access in IsAvailable / DoSelect with an
      unexported stickyLock sync.RWMutex via getStickyInvoker /
      setStickyInvoker accessors, eliminating the read/write data race
      without touching any exported symbol.
    
    The existing TestStickyConcurrent* tests continue to pass under -race.
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 cluster/cluster/base/cluster_invoker.go      | 44 ++++++++++----
 cluster/cluster/base/cluster_invoker_test.go | 87 ++++++++++++++++++++++++++++
 2 files changed, 120 insertions(+), 11 deletions(-)

diff --git a/cluster/cluster/base/cluster_invoker.go 
b/cluster/cluster/base/cluster_invoker.go
index ddb9339fc..eacd07771 100644
--- a/cluster/cluster/base/cluster_invoker.go
+++ b/cluster/cluster/base/cluster_invoker.go
@@ -20,6 +20,7 @@ package base
 
 import (
        "slices"
+       "sync"
 )
 
 import (
@@ -27,7 +28,7 @@ import (
 
        perrors "github.com/pkg/errors"
 
-       "go.uber.org/atomic"
+       uberatomic "go.uber.org/atomic"
 )
 
 import (
@@ -42,15 +43,20 @@ import (
 type BaseClusterInvoker struct {
        Directory      directory.Directory
        AvailableCheck bool
-       Destroyed      *atomic.Bool
+       Destroyed      *uberatomic.Bool
        StickyInvoker  base.Invoker
+
+       // stickyLock guards StickyInvoker against the data race between 
IsAvailable
+       // (read) and DoSelect (read/write). It is unexported so the public 
field and
+       // constructor signatures stay source-compatible across the v3.x line.
+       stickyLock sync.RWMutex
 }
 
 func NewBaseClusterInvoker(directory directory.Directory) BaseClusterInvoker {
        return BaseClusterInvoker{
                Directory:      directory,
                AvailableCheck: true,
-               Destroyed:      atomic.NewBool(false),
+               Destroyed:      uberatomic.NewBool(false),
        }
 }
 
@@ -66,12 +72,26 @@ func (invoker *BaseClusterInvoker) Destroy() {
 }
 
 func (invoker *BaseClusterInvoker) IsAvailable() bool {
-       if invoker.StickyInvoker != nil {
-               return invoker.StickyInvoker.IsAvailable()
+       if sticky := invoker.getStickyInvoker(); sticky != nil {
+               return sticky.IsAvailable()
        }
        return invoker.Directory.IsAvailable()
 }
 
+// getStickyInvoker returns the sticky invoker under a read lock.
+func (invoker *BaseClusterInvoker) getStickyInvoker() base.Invoker {
+       invoker.stickyLock.RLock()
+       defer invoker.stickyLock.RUnlock()
+       return invoker.StickyInvoker
+}
+
+// setStickyInvoker replaces the sticky invoker under a write lock.
+func (invoker *BaseClusterInvoker) setStickyInvoker(v base.Invoker) {
+       invoker.stickyLock.Lock()
+       defer invoker.stickyLock.Unlock()
+       invoker.StickyInvoker = v
+}
+
 // CheckInvokers checks invokers' status if is available or not
 func (invoker *BaseClusterInvoker) CheckInvokers(invokers []base.Invoker, 
invocation base.Invocation) error {
        if len(invokers) == 0 {
@@ -104,19 +124,21 @@ func (invoker *BaseClusterInvoker) DoSelect(lb 
loadbalance.LoadBalance, invocati
        // Get the service method sticky config if have
        sticky = url.GetMethodParamBool(invocation.MethodName(), 
constant.StickyKey, sticky)
 
-       if invoker.StickyInvoker != nil && !isInvoked(invoker.StickyInvoker, 
invokers) {
-               invoker.StickyInvoker = nil
+       stickyInvoker := invoker.getStickyInvoker()
+       if stickyInvoker != nil && !isInvoked(stickyInvoker, invokers) {
+               invoker.setStickyInvoker(nil)
+               stickyInvoker = nil
        }
 
        if sticky && invoker.AvailableCheck &&
-               invoker.StickyInvoker != nil && 
invoker.StickyInvoker.IsAvailable() &&
-               (invoked == nil || !isInvoked(invoker.StickyInvoker, invoked)) {
-               return invoker.StickyInvoker
+               stickyInvoker != nil && stickyInvoker.IsAvailable() &&
+               (invoked == nil || !isInvoked(stickyInvoker, invoked)) {
+               return stickyInvoker
        }
 
        selectedInvoker = invoker.doSelectInvoker(lb, invocation, invokers, 
invoked)
        if sticky {
-               invoker.StickyInvoker = selectedInvoker
+               invoker.setStickyInvoker(selectedInvoker)
        }
        return selectedInvoker
 }
diff --git a/cluster/cluster/base/cluster_invoker_test.go 
b/cluster/cluster/base/cluster_invoker_test.go
index 9907a43c2..813aabcaf 100644
--- a/cluster/cluster/base/cluster_invoker_test.go
+++ b/cluster/cluster/base/cluster_invoker_test.go
@@ -19,6 +19,7 @@ package base
 
 import (
        "fmt"
+       "sync"
        "testing"
 )
 
@@ -73,3 +74,89 @@ func TestStickyNormalWhenError(t *testing.T) {
        result1 := base.DoSelect(random.NewRandomLoadBalance(), 
invocation.NewRPCInvocation(baseClusterInvokerMethodName, nil, nil), invokers, 
invoked)
        assert.NotEqual(t, result, result1)
 }
+
+// TestStickyConcurrentDoSelect verifies that concurrent calls to DoSelect
+// with sticky enabled do not cause a data race on StickyInvoker.
+func TestStickyConcurrentDoSelect(t *testing.T) {
+       var invokers []protocolbase.Invoker
+       for i := 0; i < 10; i++ {
+               url, _ := common.NewURL(fmt.Sprintf(baseClusterInvokerFormat, 
i))
+               url.SetParam("sticky", "true")
+               invokers = append(invokers, clusterpkg.NewMockInvoker(url, 1))
+       }
+       base := &BaseClusterInvoker{}
+       base.AvailableCheck = true
+
+       lb := random.NewRandomLoadBalance()
+       invocation1 := 
invocation.NewRPCInvocation(baseClusterInvokerMethodName, nil, nil)
+
+       const concurrency = 100
+       var wg sync.WaitGroup
+       wg.Add(concurrency)
+       for i := 0; i < concurrency; i++ {
+               go func() {
+                       defer wg.Done()
+                       invoked := make([]protocolbase.Invoker, 0)
+                       result := base.DoSelect(lb, invocation1, invokers, 
invoked)
+                       assert.NotNil(t, result)
+               }()
+       }
+       wg.Wait()
+}
+
+// TestStickyConcurrentIsAvailableAndDoSelect verifies that concurrent
+// IsAvailable and DoSelect calls do not cause a data race on StickyInvoker.
+func TestStickyConcurrentIsAvailableAndDoSelect(t *testing.T) {
+       var invokers []protocolbase.Invoker
+       for i := 0; i < 10; i++ {
+               url, _ := common.NewURL(fmt.Sprintf(baseClusterInvokerFormat, 
i))
+               url.SetParam("sticky", "true")
+               invokers = append(invokers, clusterpkg.NewMockInvoker(url, 1))
+       }
+
+       // Use NewBaseClusterInvoker so that Directory is initialized,
+       // allowing IsAvailable() to work without panicking.
+       dir := newMockDirectory(invokers)
+       base := NewBaseClusterInvoker(dir)
+       base.AvailableCheck = true
+
+       lb := random.NewRandomLoadBalance()
+       invocation1 := 
invocation.NewRPCInvocation(baseClusterInvokerMethodName, nil, nil)
+
+       // First DoSelect to set the sticky invoker so IsAvailable uses the 
sticky path
+       invoked := make([]protocolbase.Invoker, 0)
+       base.DoSelect(lb, invocation1, invokers, invoked)
+
+       const concurrency = 100
+       var wg sync.WaitGroup
+       wg.Add(concurrency * 2)
+       for i := 0; i < concurrency; i++ {
+               go func() {
+                       defer wg.Done()
+                       base.IsAvailable()
+               }()
+               go func() {
+                       defer wg.Done()
+                       base.DoSelect(lb, invocation1, invokers, invoked)
+               }()
+       }
+       wg.Wait()
+}
+
+// mockDirectory is a minimal directory.Directory implementation for testing.
+type mockDirectory struct {
+       invokers []protocolbase.Invoker
+       url      *common.URL
+}
+
+func newMockDirectory(invokers []protocolbase.Invoker) *mockDirectory {
+       url, _ := common.NewURL(baseClusterInvokerFormat)
+       url.SetParam("sticky", "true")
+       return &mockDirectory{invokers: invokers, url: url}
+}
+
+func (d *mockDirectory) GetURL() *common.URL                                 { 
return d.url }
+func (d *mockDirectory) IsAvailable() bool                                   { 
return true }
+func (d *mockDirectory) Destroy()                                            {}
+func (d *mockDirectory) List(protocolbase.Invocation) []protocolbase.Invoker { 
return d.invokers }
+func (d *mockDirectory) Subscribe(*common.URL) error                         { 
return nil }

Reply via email to