This is an automated email from the ASF dual-hosted git repository.
Similarityoung pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new 6ebc858b8 feat(cluster): decouple config from runtime — private
Config, RuntimeState, and RoundRobin cursor migration (#973)
6ebc858b8 is described below
commit 6ebc858b8d4857ed2281d7909537bb58918aaef9
Author: Yuqi Qiao <[email protected]>
AuthorDate: Thu Aug 20 14:38:06 2026 +0800
feat(cluster): decouple config from runtime — private Config, RuntimeState,
and RoundRobin cursor migration (#973)
* feat(model): add ConfigID identity and CloneClusterConfig for
config-runtime decoupling
- Add ConfigID field to ClusterConfig for stable config-runtime identity
(replaces fragile pointer comparison after deep copy)
- Add CloneClusterConfig deep-copy helper with independent Endpoints,
HealthChecks, and ConsistentHash (hash set to nil for runtime rebuild)
- Update ClusterConfig godoc: document immutability after publication,
runtime state ownership, and PrePickEndpointIndex legacy status
* feat(cluster): decouple config from runtime with private Config and
RuntimeState
- Make Cluster.Config private (config) with Config() getter
- Add RuntimeState struct with RoundRobinCursor; add accessors
- Add RoundRobinCursor to PickContext; update snapshot RoundRobin
to use runtime-owned cursor instead of ClusterConfig.PrePickEndpointIndex
- Add ConfigID for stable config-runtime identity; replace pointer
comparison with ConfigIsIdenticalTo
- Wire CloneClusterConfig into replaceClusterRuntimeWithSnapshot
- Remove dead getCluster method
- Adapt tests and benchmarks to new API
* fix(cluster): reconcile legacy RR cursor delta back to RoundRobinCursor
when present
When the legacy pickEndpoint fallback path reads cursorBefore from
RoundRobinCursor, the Handler advances config.PrePickEndpointIndex (a local
shallow copy). The delta was unconditionally written back to
context.Config.PrePickEndpointIndex, which left the runtime-owned
RoundRobinCursor stale. Apply the delta to RoundRobinCursor when it is
the source of cursorBefore.
* fix(cluster): add nil guard in NewClusterWithEndpointSnapshot for config
dereferences
Guard clusterConfig.ConfigID and HealthChecks access against nil config,
preserving the prior behaviour where a nil config built an empty snapshot
without panicking.
* fix(cluster): sync config endpoints to runtime before RefreshEndpoints
When SetEndpoint or replaceEndpointAt appends/modifies endpoints on the
store's ClusterConfig, the runtime holds an independent deep copy from
replaceClusterRuntimeWithSnapshot. RefreshEndpoints reads from the
runtime's config, missing the store-side mutations.
Add SyncConfigEndpoints to sync the store's authoritative endpoint slice
back to the runtime config before RefreshEndpoints.
* fix: gofmt indentation in cluster_manager.go
* fix(cluster): preserve custom hash across runtime config clone
---
admin/initialize/E2E_OPA.md | 2 -
pkg/cluster/cluster.go | 89 ++++++++++++++++++--
pkg/cluster/loadbalancer/load_balancer.go | 18 +++-
pkg/cluster/loadbalancer/roundrobin/round_robin.go | 8 +-
pkg/model/cluster.go | 51 ++++++++++-
pkg/model/cluster_test.go | 30 +++++++
pkg/server/cluster_manager.go | 66 +++++++--------
pkg/server/cluster_manager_bench_test.go | 5 +-
pkg/server/cluster_manager_test.go | 98 ++++++++++++++++++----
9 files changed, 301 insertions(+), 66 deletions(-)
diff --git a/admin/initialize/E2E_OPA.md b/admin/initialize/E2E_OPA.md
index 37b55caba..c1755a6a5 100644
--- a/admin/initialize/E2E_OPA.md
+++ b/admin/initialize/E2E_OPA.md
@@ -67,5 +67,3 @@ Expected output (verbose):
PASS
ok github.com/apache/dubbo-go-pixiu/admin/initialize 0.342s
```
-
-
diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go
index a0adefbbe..24fa5a02a 100644
--- a/pkg/cluster/cluster.go
+++ b/pkg/cluster/cluster.go
@@ -28,15 +28,28 @@ import (
"github.com/apache/dubbo-go-pixiu/pkg/model"
)
+// Cluster is the runtime representation of an upstream cluster. It holds
+// a deep copy of the desired config and publishes immutable EndpointSnapshot
+// via CAS for the request path. Runtime state (cursors, health, snapshots)
+// lives here, not in model.ClusterConfig.
+//
+// RuntimeState holds operational state that belongs to the runtime, not the
+// configuration. Fields here are mutated only by the request path.
+type RuntimeState struct {
+ roundRobinCursor atomic.Uint32
+}
+
type Cluster struct {
HealthCheck *healthcheck.HealthChecker
- // Config is the desired cluster configuration. Runtime picks read the
- // published EndpointSnapshot, so direct edits to Config.Endpoints or
+ // config is the desired cluster configuration. Runtime picks read the
+ // published EndpointSnapshot, so direct edits to config.Endpoints or
// Endpoint.UnHealthy are not observed by PickEndpoint immediately.
Publish
// membership/address changes with RefreshEndpoints; publish runtime
health
// changes with UpdateEndpointHealth, or RefreshEndpoints for clusters
// without health checks.
- Config *model.ClusterConfig
+ config *model.ClusterConfig
+ configID uint64 // snapshot of config.ConfigID at
construction time
+ runtimeState *RuntimeState
healthMu sync.Mutex
acceptHealthEvents bool
endpoints atomic.Pointer[EndpointSnapshot]
@@ -47,17 +60,23 @@ func NewCluster(clusterConfig *model.ClusterConfig)
*Cluster {
}
func NewClusterWithEndpointSnapshot(clusterConfig *model.ClusterConfig,
previous *EndpointSnapshot) *Cluster {
+ configID := uint64(0)
+ if clusterConfig != nil {
+ configID = clusterConfig.ConfigID
+ }
c := &Cluster{
- Config: clusterConfig,
+ config: clusterConfig,
+ configID: configID,
+ runtimeState: &RuntimeState{},
acceptHealthEvents: true,
}
c.RefreshEndpointsFrom(previous)
// only handle one health checker
- if len(c.Config.HealthChecks) != 0 {
+ if clusterConfig != nil && len(clusterConfig.HealthChecks) != 0 {
c.HealthCheck = healthcheck.CreateHealthCheckWithCallback(
clusterConfig,
- c.Config.HealthChecks[0],
+ c.config.HealthChecks[0],
c.handleEndpointHealth,
)
c.HealthCheck.Start()
@@ -98,7 +117,7 @@ func (c *Cluster) RefreshEndpointsFrom(previous
*EndpointSnapshot) {
if current != nil && current != previous {
source = current
}
- next := newEndpointSnapshot(c.Config, source,
len(c.Config.HealthChecks) != 0)
+ next := newEndpointSnapshot(c.config, source,
len(c.config.HealthChecks) != 0)
if c.endpoints.CompareAndSwap(current, next) {
recordSnapshotPublish(c.clusterName(), next)
return
@@ -179,10 +198,62 @@ func (c *Cluster) handleEndpointHealth(event
healthcheck.EndpointHealthEvent) {
}
func (c *Cluster) clusterName() string {
- if c == nil || c.Config == nil {
+ if c == nil || c.config == nil {
return ""
}
- return c.Config.Name
+ return c.config.Name
+}
+
+// SyncConfigEndpoints updates the runtime's config endpoints to match the
+// store's authoritative copy. Required before RefreshEndpoints when the store
+// mutates endpoints through SetEndpoint or replaceEndpointAt.
+func (c *Cluster) SyncConfigEndpoints(endpoints []*model.Endpoint) {
+ if c == nil {
+ return
+ }
+ c.config.Endpoints = model.CloneEndpoints(endpoints)
+}
+
+// Config returns the cluster configuration this runtime was built from.
+// Callers must treat the returned pointer as read-only; the runtime owns the
+// config and never mutates it after construction.
+func (c *Cluster) Config() *model.ClusterConfig {
+ if c == nil {
+ return nil
+ }
+ return c.config
+}
+
+// ConfigIsIdenticalTo reports whether this runtime was built from cfg (by
+// config object identity, not by deep equality). Used after deep-cloning to
+// detect when a runtime needs replacement without relying on pointer equality.
+func (c *Cluster) ConfigIsIdenticalTo(cfg *model.ClusterConfig) bool {
+ if c == nil || cfg == nil {
+ return false
+ }
+ return c.configID == cfg.ConfigID
+}
+
+// RoundRobinCursor returns the runtime's atomic RoundRobin cursor. Snapshot
+// load balancers use this cursor instead of
ClusterConfig.PrePickEndpointIndex.
+func (c *Cluster) RoundRobinCursor() *atomic.Uint32 {
+ if c == nil || c.runtimeState == nil {
+ return nil
+ }
+ return &c.runtimeState.roundRobinCursor
+}
+
+// CarryOverCursorTo copies this runtime's RR cursor value into target, used
+// when replacing runtimes to preserve fairness state.
+func (c *Cluster) CarryOverCursorTo(target *Cluster) {
+ if c == nil || target == nil {
+ return
+ }
+ src := c.RoundRobinCursor()
+ dst := target.RoundRobinCursor()
+ if src != nil && dst != nil {
+ dst.Store(src.Load())
+ }
}
// EndpointSnapshot endpoint membership and health indexes are immutable after
diff --git a/pkg/cluster/loadbalancer/load_balancer.go
b/pkg/cluster/loadbalancer/load_balancer.go
index 4b577eab8..e3e87e7cb 100644
--- a/pkg/cluster/loadbalancer/load_balancer.go
+++ b/pkg/cluster/loadbalancer/load_balancer.go
@@ -52,6 +52,9 @@ type PickContext struct {
// Snapshot-aware balancers must treat endpoints as read-only and return
// the chosen endpoint without mutating or retaining it.
HealthyEndpoints []*model.Endpoint
+ // RoundRobinCursor is the runtime-owned atomic RR cursor.
Snapshot-aware
+ // RoundRobin balancers should use this instead of
Config.PrePickEndpointIndex.
+ RoundRobinCursor *atomic.Uint32
// HealthyByID resolves a healthy snapshot endpoint by ID in O(1) for
the
// post-pick identity recheck. It is set by the snapshot-published pick
path;
// when nil (e.g. a hand-built context in a test), the recheck falls
back to
@@ -231,12 +234,21 @@ func pickEndpoint(balancer LoadBalancer, context
PickContext, policy model.LbPol
}
config := *context.Config
config.Endpoints = model.CloneEndpoints(allEndpoints)
- cursorBefore := atomic.LoadUint32(&context.Config.PrePickEndpointIndex)
+ var cursorBefore, cursorAfter uint32
+ if context.RoundRobinCursor != nil {
+ cursorBefore = context.RoundRobinCursor.Load()
+ } else {
+ cursorBefore =
atomic.LoadUint32(&context.Config.PrePickEndpointIndex)
+ }
atomic.StoreUint32(&config.PrePickEndpointIndex, cursorBefore)
endpoint := balancer.Handler(&config, policy)
- cursorAfter := atomic.LoadUint32(&config.PrePickEndpointIndex)
+ cursorAfter = atomic.LoadUint32(&config.PrePickEndpointIndex)
if cursorAfter != cursorBefore {
- atomic.AddUint32(&context.Config.PrePickEndpointIndex,
cursorAfter-cursorBefore)
+ if context.RoundRobinCursor != nil {
+ context.RoundRobinCursor.Add(cursorAfter - cursorBefore)
+ } else {
+ atomic.AddUint32(&context.Config.PrePickEndpointIndex,
cursorAfter-cursorBefore)
+ }
}
return healthyEndpointFromSnapshot(endpoint, context)
}
diff --git a/pkg/cluster/loadbalancer/roundrobin/round_robin.go
b/pkg/cluster/loadbalancer/roundrobin/round_robin.go
index ecd787ddb..bc03a2404 100644
--- a/pkg/cluster/loadbalancer/roundrobin/round_robin.go
+++ b/pkg/cluster/loadbalancer/roundrobin/round_robin.go
@@ -49,7 +49,11 @@ func (RoundRobin) HandlerWithSnapshot(c
loadbalancer.PickContext, _ model.LbPoli
if len(endpoints) == 0 {
return nil
}
- // AddUint32 returns the incremented value, so subtract 1 for a
zero-based index.
- index := atomic.AddUint32(&c.Config.PrePickEndpointIndex, 1) - 1
+ var index uint32
+ if c.RoundRobinCursor != nil {
+ index = c.RoundRobinCursor.Add(1) - 1
+ } else {
+ index = atomic.AddUint32(&c.Config.PrePickEndpointIndex, 1) - 1
+ }
return endpoints[int(index%uint32(len(endpoints)))]
}
diff --git a/pkg/model/cluster.go b/pkg/model/cluster.go
index 6238f6001..41de7c4f9 100644
--- a/pkg/model/cluster.go
+++ b/pkg/model/cluster.go
@@ -57,7 +57,16 @@ var (
)
type (
- // ClusterConfig a single upstream cluster
+ // ClusterConfig represents the desired state of an upstream cluster.
+ // It is created from YAML, xDS, or ClusterManager APIs. After
publication
+ // to a runtime Cluster the runtime holds its own deep copy — mutations
to
+ // ClusterConfig are invisible to the running cluster until the next
+ // explicit update.
+ //
+ // Runtime state (cursors, health, snapshots) belongs on
cluster.Cluster,
+ // not here. PrePickEndpointIndex is retained only for the legacy
+ // LoadBalancer.Handler interface; new code should use
+ // PickContext.RoundRobinCursor.
ClusterConfig struct {
Name string `yaml:"name"
json:"name"` // Name the cluster unique name
TypeStr string `yaml:"type"
json:"type"` // Type the cluster discovery type string value
@@ -67,7 +76,8 @@ type (
ConsistentHash ConsistentHash `yaml:"consistent"
json:"consistent"` // Consistent hash config info
HealthChecks []HealthCheckConfig `yaml:"health_checks"
json:"health_checks"`
Endpoints []*Endpoint `yaml:"endpoints"
json:"endpoints"`
- PrePickEndpointIndex uint32 `yaml:"-" json:"-"` //
runtime-only round-robin cursor state
+ PrePickEndpointIndex uint32 `yaml:"-" json:"-"` //
runtime-only round-robin cursor state (legacy Handler compat)
+ ConfigID uint64 `yaml:"-" json:"-"` //
stable identity for config-runtime association
}
// EdsClusterConfig todo remove un-used EdsClusterConfig
@@ -305,3 +315,40 @@ func cloneLLMMeta(meta *LLMMeta) *LLMMeta {
cloned.RetryPolicy.Config =
copyutil.CloneStringAnyMap(meta.RetryPolicy.Config)
return &cloned
}
+
+// CloneClusterConfig returns a deep copy of c suitable for handing to a new
+// runtime Cluster. The clone owns its Endpoints and HealthChecks slices.
+// ConsistentHash.Hash (a mutable runtime object) is set to nil when the policy
+// has a registered factory, so the runtime can rebuild it from its endpoint
+// snapshot. For an unregistered/custom policy, a programmatically supplied
+// Hash is preserved because there is no factory available to reconstruct it;
+// custom implementations are responsible for their own concurrency safety.
+// configID is preserved so callers can detect config-object identity changes.
+// PrePickEndpointIndex is NOT copied — runtime cursor state belongs on the
+// runtime, not in the config clone.
+func CloneClusterConfig(c *ClusterConfig) *ClusterConfig {
+ if c == nil {
+ return nil
+ }
+ clone := *c
+ clone.Endpoints = CloneEndpoints(c.Endpoints)
+ clone.HealthChecks = cloneHealthChecks(c.HealthChecks)
+ clone.ConsistentHash = c.ConsistentHash
+ if c.HasConsistentHashFactory() {
+ clone.ConsistentHash.Hash = nil
+ }
+ return &clone
+}
+
+func cloneHealthChecks(checks []HealthCheckConfig) []HealthCheckConfig {
+ if checks == nil {
+ return nil
+ }
+ cloned := make([]HealthCheckConfig, len(checks))
+ for i, hc := range checks {
+ cloned[i] = hc
+ cloned[i].CommonCallbacks = append([]string(nil),
hc.CommonCallbacks...)
+ cloned[i].SessionConfig =
copyutil.CloneStringAnyMap(hc.SessionConfig)
+ }
+ return cloned
+}
diff --git a/pkg/model/cluster_test.go b/pkg/model/cluster_test.go
index b53a6fa6c..a1a71ad5f 100644
--- a/pkg/model/cluster_test.go
+++ b/pkg/model/cluster_test.go
@@ -108,6 +108,36 @@ func TestClusterConfig_EnsureConsistentHashBuildsOnce(t
*testing.T) {
assert.Same(t, first, cluster.ConsistentHash.Hash)
}
+func TestCloneClusterConfigHandlesConsistentHashByFactoryAvailability(t
*testing.T) {
+ customHash := &testConsistentHash{}
+
+ registered := &model.ClusterConfig{
+ LbStr: model.LoadBalancerRingHashing,
+ ConsistentHash: model.ConsistentHash{Hash: customHash},
+ }
+ registeredClone := model.CloneClusterConfig(registered)
+ assert.Nil(t, registeredClone.ConsistentHash.Hash,
+ "a registered factory can rebuild the hash for the runtime
snapshot")
+ assert.Same(t, customHash, registered.ConsistentHash.Hash,
+ "cloning must not mutate the source config")
+
+ unregistered := &model.ClusterConfig{
+ LbStr: model.LbPolicyType("ProgrammaticCustomHash"),
+ ConsistentHash: model.ConsistentHash{Hash: customHash},
+ }
+ unregisteredClone := model.CloneClusterConfig(unregistered)
+ assert.Same(t, customHash, unregisteredClone.ConsistentHash.Hash,
+ "a programmatic hash must survive when no factory can rebuild
it")
+}
+
+type testConsistentHash struct{}
+
+func (*testConsistentHash) Hash(string) uint32 { return 0 }
+func (*testConsistentHash) Get(string) (string, error) { return "", nil }
+func (*testConsistentHash) GetHash(uint32) (string, error) { return "", nil }
+func (*testConsistentHash) Add(string) {}
+func (*testConsistentHash) Remove(string) bool { return false }
+
func TestClusterConfig_PrePickEndpointIndexIsRuntimeOnly(t *testing.T) {
cluster := &model.ClusterConfig{
Name: "runtime-cursor",
diff --git a/pkg/server/cluster_manager.go b/pkg/server/cluster_manager.go
index 31c907872..c47304ef0 100644
--- a/pkg/server/cluster_manager.go
+++ b/pkg/server/cluster_manager.go
@@ -37,6 +37,7 @@ import (
// generate cluster name for unnamed cluster
var (
clusterIndex int32 = 1
+ configIDSeq uint64
)
type (
@@ -272,15 +273,6 @@ func (cm *ClusterManager)
GetHealthyEndpointByID(clusterName, endpointID string)
return runtimeCluster.EndpointSnapshot().HealthyEndpointByID(endpointID)
}
-// getCluster returns the cluster configuration by its name.
-func (cm *ClusterManager) getCluster(clusterName string) *model.ClusterConfig {
- runtimeCluster := cm.getRuntimeCluster(clusterName)
- if runtimeCluster == nil {
- return nil
- }
- return runtimeCluster.Config
-}
-
func (cm *ClusterManager) getRuntimeCluster(clusterName string)
*cluster.Cluster {
return cm.store.clustersMap[clusterName]
}
@@ -292,7 +284,7 @@ func (cm *ClusterManager) pickOneEndpoint(runtimeCluster
*cluster.Cluster, polic
}
healthyEndpoints := snapshot.HealthyEndpointsForPick()
- c := runtimeCluster.Config
+ c := runtimeCluster.Config()
loadBalancer, ok := loadbalancer.LoadBalancerStrategy[c.LbStr]
if !ok {
loadBalancer =
loadbalancer.LoadBalancerStrategy[model.LoadBalancerRand]
@@ -308,6 +300,7 @@ func (cm *ClusterManager) pickOneEndpoint(runtimeCluster
*cluster.Cluster, polic
HealthyConsistentHash: snapshot.HealthyConsistentHash(),
AllEndpoints: allEndpoints,
HealthyEndpoints: healthyEndpoints,
+ RoundRobinCursor: runtimeCluster.RoundRobinCursor(),
HealthyByID: snapshot,
}, policy)
}
@@ -384,6 +377,9 @@ func (s *ClusterStore) prepareClusterConfig(c
*model.ClusterConfig) {
// preserve any programmatically supplied hash because there is no factory
// available to rebuild it later.
func (s *ClusterStore) prepareOwnedClusterConfig(c *model.ClusterConfig) {
+ if c.ConfigID == 0 {
+ c.ConfigID = atomic.AddUint64(&configIDSeq, 1)
+ }
s.assembleClusterEndpoints(c)
if c.HasConsistentHashFactory() {
c.ConsistentHash.Hash = nil
@@ -448,7 +444,11 @@ func (s *ClusterStore) replaceClusterRuntimeWithSnapshot(
s.ensureRuntimeClusterMap()
oldRuntime := s.clustersMap[name]
- s.clustersMap[name] = cluster.NewClusterWithEndpointSnapshot(config,
previous)
+ // Deep-clone so the runtime owns its config copy. Mutations to
+ // store.Config[i] no longer affect the running cluster until the next
+ // explicit update.
+ cloned := model.CloneClusterConfig(config)
+ s.clustersMap[name] = cluster.NewClusterWithEndpointSnapshot(cloned,
previous)
return oldRuntime
}
@@ -475,7 +475,7 @@ func (s *ClusterStore) ensureRuntimeClusters()
[]*cluster.Cluster {
configsByName[clusterConfig.Name] = clusterConfig
runtimeCluster := s.clustersMap[clusterConfig.Name]
- if runtimeCluster == nil || runtimeCluster.Config !=
clusterConfig {
+ if runtimeCluster == nil ||
!runtimeCluster.ConfigIsIdenticalTo(clusterConfig) {
if oldRuntime :=
s.replaceClusterRuntime(clusterConfig.Name, clusterConfig); oldRuntime != nil {
replacedClusters = append(replacedClusters,
oldRuntime)
}
@@ -522,7 +522,7 @@ func (s *ClusterStore) repairRuntimeClusterFromPrevious(
s.prepareClusterConfig(clusterConfig)
previous := snapshotForRuntimeReplacement(old, clusterConfig.Name)
runtimeCluster := s.clustersMap[clusterConfig.Name]
- if runtimeCluster != nil && runtimeCluster.Config == clusterConfig &&
previous == nil {
+ if runtimeCluster != nil &&
runtimeCluster.ConfigIsIdenticalTo(clusterConfig) && previous == nil {
return clusterConfig.Name, nil
}
return clusterConfig.Name,
s.replaceClusterRuntimeWithSnapshot(clusterConfig.Name, clusterConfig, previous)
@@ -599,12 +599,14 @@ func (s *ClusterStore) UpdateCluster(new
*model.ClusterConfig) {
}
if c.Name == new.Name {
s.prepareClusterConfig(new)
- atomic.StoreUint32(
- &new.PrePickEndpointIndex,
- atomic.LoadUint32(&c.PrePickEndpointIndex),
- )
+ oldRuntime := s.clustersMap[new.Name]
s.Config[i] = new
-
stopClusters([]*cluster.Cluster{s.replaceClusterRuntime(new.Name, new)})
+ oldReplaced := s.replaceClusterRuntime(new.Name, new)
+ if oldRuntime != nil {
+ newRuntime := s.clustersMap[new.Name]
+ oldRuntime.CarryOverCursorTo(newRuntime)
+ }
+ stopClusters([]*cluster.Cluster{oldReplaced})
return
}
}
@@ -625,7 +627,7 @@ func (s *ClusterStore) SetEndpoint(clusterName string,
endpoint *model.Endpoint)
}
runtimeCluster := s.clustersMap[clusterName]
- if runtimeCluster == nil || runtimeCluster.Config != clusterConfig {
+ if runtimeCluster == nil ||
!runtimeCluster.ConfigIsIdenticalTo(clusterConfig) {
stopClusters([]*cluster.Cluster{s.replaceClusterRuntime(clusterName,
clusterConfig)})
runtimeCluster = s.clustersMap[clusterName]
}
@@ -646,6 +648,7 @@ func (s *ClusterStore) SetEndpoint(clusterName string,
endpoint *model.Endpoint)
case setEndpointAppend:
clusterConfig.Endpoints = append(clusterConfig.Endpoints,
endpoint)
s.prepareOwnedClusterConfig(clusterConfig)
+ runtimeCluster.SyncConfigEndpoints(clusterConfig.Endpoints)
runtimeCluster.RefreshEndpoints()
runtimeCluster.AddEndpoint(endpoint)
}
@@ -681,6 +684,7 @@ func (s *ClusterStore) replaceEndpointAt(
}
clusterConfig.Endpoints[idx] = endpoint
s.prepareOwnedClusterConfig(clusterConfig)
+ runtimeCluster.SyncConfigEndpoints(clusterConfig.Endpoints)
runtimeCluster.RefreshEndpoints()
if addressChanged {
runtimeCluster.AddEndpoint(endpoint)
@@ -936,7 +940,7 @@ func (s *ClusterStore) DeleteEndpoint(clusterName string,
endpointID string) {
}
runtimeCluster := s.clustersMap[clusterName]
- if runtimeCluster == nil || runtimeCluster.Config != clusterConfig {
+ if runtimeCluster == nil ||
!runtimeCluster.ConfigIsIdenticalTo(clusterConfig) {
stopClusters([]*cluster.Cluster{s.replaceClusterRuntime(clusterName,
clusterConfig)})
runtimeCluster = s.clustersMap[clusterName]
}
@@ -946,6 +950,7 @@ func (s *ClusterStore) DeleteEndpoint(clusterName string,
endpointID string) {
runtimeCluster.RemoveEndpoint(e)
clusterConfig.Endpoints =
slices.Delete(clusterConfig.Endpoints, i, i+1)
s.prepareOwnedClusterConfig(clusterConfig)
+
runtimeCluster.SyncConfigEndpoints(clusterConfig.Endpoints)
runtimeCluster.RefreshEndpoints()
return
}
@@ -980,23 +985,18 @@ func (s *ClusterStore) carryOverRuntimeStateFrom(old
*ClusterStore) {
return
}
- oldConfigsByName := make(map[string]*model.ClusterConfig,
len(old.Config))
- for _, clusterConfig := range old.Config {
- if clusterConfig != nil {
- oldConfigsByName[clusterConfig.Name] = clusterConfig
- }
- }
-
- // Preserve runtime-only load-balancer state when a rebuilt store is
swapped in.
+ // Preserve runtime-only RoundRobin cursor when a rebuilt store is
swapped in.
+ // The cursor lives on Cluster.runtimeState, not on ClusterConfig.
for _, clusterConfig := range s.Config {
if clusterConfig == nil {
continue
}
- if oldConfig := oldConfigsByName[clusterConfig.Name]; oldConfig
!= nil {
- atomic.StoreUint32(
- &clusterConfig.PrePickEndpointIndex,
-
atomic.LoadUint32(&oldConfig.PrePickEndpointIndex),
- )
+ newRuntime := s.clustersMap[clusterConfig.Name]
+ if newRuntime == nil {
+ continue
+ }
+ if oldRuntime := old.clustersMap[clusterConfig.Name];
oldRuntime != nil {
+ oldRuntime.CarryOverCursorTo(newRuntime)
}
}
}
diff --git a/pkg/server/cluster_manager_bench_test.go
b/pkg/server/cluster_manager_bench_test.go
index 523d14e7a..09724ab77 100644
--- a/pkg/server/cluster_manager_bench_test.go
+++ b/pkg/server/cluster_manager_bench_test.go
@@ -71,7 +71,10 @@ func BenchmarkClusterLookupSerial(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
cm.rw.RLock()
- benchmarkClusterSink =
cm.getCluster(names[i%len(names)])
+ runtimeCluster :=
cm.getRuntimeCluster(names[i%len(names)])
+ if runtimeCluster != nil {
+ benchmarkClusterSink =
runtimeCluster.Config()
+ }
cm.rw.RUnlock()
}
})
diff --git a/pkg/server/cluster_manager_test.go
b/pkg/server/cluster_manager_test.go
index c7834aeb0..264790350 100644
--- a/pkg/server/cluster_manager_test.go
+++ b/pkg/server/cluster_manager_test.go
@@ -38,6 +38,7 @@ import (
import (
"github.com/apache/dubbo-go-pixiu/pkg/cluster"
+ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer"
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/maglev"
// Register Maglev for cluster-manager tests.
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/rand"
// Register Rand for cluster-manager tests.
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer/ringhash"
// Register RingHash for cluster-manager tests.
@@ -180,7 +181,9 @@ func
TestClusterManager_CompareAndSetStorePreservesRoundRobinCursorAcrossRefresh
cm := testClusterManager(cluster)
const expectedCursor uint32 = 5
- atomic.StoreUint32(&cm.store.Config[0].PrePickEndpointIndex,
expectedCursor)
+ // Set cursor on the runtime, not the config.
+ oldRuntime := cm.store.clustersMap[cluster.Name]
+ oldRuntime.RoundRobinCursor().Store(expectedCursor)
oldStore, err := cm.CloneStore()
if !assert.NoError(t, err) {
@@ -194,7 +197,10 @@ func
TestClusterManager_CompareAndSetStorePreservesRoundRobinCursorAcrossRefresh
assert.True(t, cm.CompareAndSetStore(newStore))
if assert.Len(t, cm.store.Config, 1) {
- assert.Equal(t, expectedCursor,
atomic.LoadUint32(&cm.store.Config[0].PrePickEndpointIndex))
+ newRuntime := cm.store.clustersMap[cluster.Name]
+ if assert.NotNil(t, newRuntime) {
+ assert.Equal(t, expectedCursor,
newRuntime.RoundRobinCursor().Load())
+ }
}
endpoint := cm.PickEndpoint(cluster.Name, nil)
@@ -214,11 +220,11 @@ func
TestClusterManager_UpdateClusterRebuildsRuntimeCluster(t *testing.T) {
if !assert.NotNil(t, oldRuntime) {
return
}
- assert.Same(t, oldConfig, oldRuntime.Config)
+ assert.True(t, oldRuntime.ConfigIsIdenticalTo(oldConfig))
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
const expectedCursor uint32 = 11
- atomic.StoreUint32(&oldConfig.PrePickEndpointIndex, expectedCursor)
+ oldRuntime.RoundRobinCursor().Store(expectedCursor)
newConfig := testCluster(oldConfig.Name, model.LoadBalancerRoundRobin,
[]*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19301),
@@ -231,9 +237,9 @@ func
TestClusterManager_UpdateClusterRebuildsRuntimeCluster(t *testing.T) {
return
}
assert.NotSame(t, oldRuntime, newRuntime)
- assert.Same(t, newConfig, newRuntime.Config)
+ assert.True(t, newRuntime.ConfigIsIdenticalTo(newConfig))
assert.Same(t, newConfig, cm.store.Config[0])
- assert.Equal(t, expectedCursor,
atomic.LoadUint32(&newConfig.PrePickEndpointIndex))
+ assert.Equal(t, expectedCursor, newRuntime.RoundRobinCursor().Load())
assert.Equal(t, 0, healthCheckersLen(oldRuntime))
assert.Greater(t, healthCheckersLen(newRuntime), 0)
}
@@ -279,7 +285,7 @@ func
TestClusterManager_CompareAndSetStoreEnsuresRuntimeAndStopsOld(t *testing.T
assert.Greater(t, healthCheckersLen(oldRuntime), 0)
const expectedCursor uint32 = 17
- atomic.StoreUint32(&oldConfig.PrePickEndpointIndex, expectedCursor)
+ oldRuntime.RoundRobinCursor().Store(expectedCursor)
newConfig := testCluster(oldConfig.Name, model.LoadBalancerRoundRobin,
[]*model.Endpoint{
testEndpoint("ep-2", "127.0.0.1", 19321),
@@ -298,8 +304,8 @@ func
TestClusterManager_CompareAndSetStoreEnsuresRuntimeAndStopsOld(t *testing.T
}
assert.Same(t, candidate, cm.store)
assert.NotSame(t, oldRuntime, newRuntime)
- assert.Same(t, newConfig, newRuntime.Config)
- assert.Equal(t, expectedCursor,
atomic.LoadUint32(&newConfig.PrePickEndpointIndex))
+ assert.True(t, newRuntime.ConfigIsIdenticalTo(newConfig))
+ assert.Equal(t, expectedCursor, newRuntime.RoundRobinCursor().Load())
assert.Equal(t, 0, healthCheckersLen(oldRuntime))
assert.Greater(t, healthCheckersLen(newRuntime), 0)
}
@@ -339,7 +345,7 @@ func
TestClusterStore_EnsureRuntimeClustersRepairsRuntimeMap(t *testing.T) {
assert.Empty(t, replaced)
if assert.NotNil(t, store.clustersMap[config.Name]) {
- assert.Same(t, config,
store.clustersMap[config.Name].Config)
+ assert.True(t,
store.clustersMap[config.Name].ConfigIsIdenticalTo(config))
}
})
@@ -380,13 +386,13 @@ func
TestClusterStore_EnsureRuntimeClustersRepairsRuntimeMap(t *testing.T) {
replaced := store.ensureRuntimeClusters()
stopClusters(replaced)
- assert.Same(t, correctRuntime,
store.clustersMap[correctConfig.Name])
+ assert.True(t,
store.clustersMap[correctConfig.Name].ConfigIsIdenticalTo(correctConfig))
if assert.NotNil(t, store.clustersMap[missingConfig.Name]) {
- assert.Same(t, missingConfig,
store.clustersMap[missingConfig.Name].Config)
+ assert.True(t,
store.clustersMap[missingConfig.Name].ConfigIsIdenticalTo(missingConfig))
}
if assert.NotNil(t,
store.clustersMap[newMismatchedConfig.Name]) {
assert.NotSame(t, mismatchedRuntime,
store.clustersMap[newMismatchedConfig.Name])
- assert.Same(t, newMismatchedConfig,
store.clustersMap[newMismatchedConfig.Name].Config)
+ assert.True(t,
store.clustersMap[newMismatchedConfig.Name].ConfigIsIdenticalTo(newMismatchedConfig))
}
assert.NotContains(t, store.clustersMap, staleConfig.Name)
assert.Contains(t, replaced, mismatchedRuntime)
@@ -478,7 +484,7 @@ func
TestClusterManager_DeleteEndpointRepairsRuntimeAndConsistentHash(t *testing
return
}
assert.NotSame(t, staleRuntime, runtime)
- assert.Same(t, config, runtime.Config)
+ assert.True(t, runtime.ConfigIsIdenticalTo(config))
if assert.Len(t, config.Endpoints, 1) {
assert.Equal(t, remainingEndpoint, config.Endpoints[0])
assert.NotSame(t, remainingEndpoint, config.Endpoints[0])
@@ -561,6 +567,70 @@ func
TestClusterManager_PrepareClusterConfigPreservesCustomHashWithoutFactory(t
assert.Same(t, customHash, cm.store.Config[0].ConsistentHash.Hash)
}
+func TestClusterManager_RuntimePreservesProgrammaticCustomHash(t *testing.T) {
+ const customPolicy model.LbPolicyType = "ProgrammaticCustomHashRuntime"
+ previousBalancer, hadPreviousBalancer :=
loadbalancer.LoadBalancerStrategy[customPolicy]
+ loadbalancer.LoadBalancerStrategy[customPolicy] =
programmaticHashSnapshotBalancer{}
+ defer func() {
+ if hadPreviousBalancer {
+ loadbalancer.LoadBalancerStrategy[customPolicy] =
previousBalancer
+ } else {
+ delete(loadbalancer.LoadBalancerStrategy, customPolicy)
+ }
+ }()
+
+ endpoint := testEndpoint("custom-hash-ep", "127.0.0.1", 19372)
+ config := testCluster("custom-hash-runtime", customPolicy,
[]*model.Endpoint{endpoint})
+ config.ConsistentHash.Hash = fixedEndpointHash{host: endpoint.GetHost()}
+
+ cm := testClusterManager(config)
+ defer stopStoreRuntimes(cm.store)
+
+ picked := cm.PickEndpoint(config.Name, nil)
+ require.NotNil(t, picked)
+ assert.Equal(t, endpoint.ID, picked.ID)
+}
+
+type fixedEndpointHash struct {
+ host string
+}
+
+func (fixedEndpointHash) Hash(string) uint32 { return 0 }
+func (h fixedEndpointHash) Get(string) (string, error) { return h.host,
nil }
+func (h fixedEndpointHash) GetHash(uint32) (string, error) { return h.host,
nil }
+func (fixedEndpointHash) Add(string) {}
+func (fixedEndpointHash) Remove(string) bool { return false }
+
+type programmaticHashSnapshotBalancer struct{}
+
+func (programmaticHashSnapshotBalancer) Handler(*model.ClusterConfig,
model.LbPolicy) *model.Endpoint {
+ return nil
+}
+
+func (programmaticHashSnapshotBalancer) HandlerWithSnapshot(
+ context loadbalancer.PickContext,
+ _ model.LbPolicy,
+) *model.Endpoint {
+ hash := loadbalancer.ConsistentHashForHealthyEndpoints(context)
+ if hash == nil {
+ return nil
+ }
+ host, err := hash.Get("reviewer-regression")
+ if err != nil {
+ return nil
+ }
+ for _, endpoint := range context.HealthyEndpoints {
+ if endpoint != nil && endpoint.GetHost() == host {
+ return endpoint
+ }
+ }
+ return nil
+}
+
+func (programmaticHashSnapshotBalancer) UseHealthyEndpointsOnly() bool {
+ return true
+}
+
func TestClusterManager_Race_RoundRobinPickEndpoint(t *testing.T) {
cluster := testCluster("race-round-robin",
model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19100),