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

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new d971ca5434 fix(loadbalancer): preserve Dubbo upstream group and 
version identity (#7625)
d971ca5434 is described below

commit d971ca54347fa4800ac5704c63d9986598e2c4c0
Author: lymerin <[email protected]>
AuthorDate: Sat Oct 17 20:47:47 2026 +0800

    fix(loadbalancer): preserve Dubbo upstream group and version identity 
(#7625)
---
 .../loadbalancer/cache/UpstreamCacheManager.java   | 29 +++-----
 .../shenyu/loadbalancer/entity/Upstream.java       | 21 +++++-
 .../loadbalancer/entity/UpstreamIdentity.java      | 86 ++++++++++++++++++++++
 .../shenyu/loadbalancer/spi/HashLoadBalancer.java  |  2 +-
 .../loadbalancer/spi/LeastActiveLoadBalance.java   |  2 +-
 .../loadbalancer/spi/RoundRobinLoadBalancer.java   | 10 +--
 .../cache/UpstreamCacheManagerTest.java            | 80 ++++++++++++++++++++
 .../shenyu/loadbalancer/entity/UpstreamTest.java   | 51 ++++++++++++-
 .../loadbalancer/spi/HashLoadBalancerTest.java     | 28 +++++++
 .../spi/LeastActiveLoadBalanceTest.java            | 16 ++++
 .../handler/ApacheDubboPluginDataHandlerTest.java  | 57 ++++++++++++++
 .../proxy/ApacheDubboGrayLoadBalanceTest.java      | 11 ++-
 12 files changed, 365 insertions(+), 28 deletions(-)

diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
index 2c80c83fa4..d6e430adb3 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
@@ -25,9 +25,9 @@ import 
org.apache.shenyu.common.config.ShenyuConfig.UpstreamCheck;
 import org.apache.shenyu.common.utils.MapUtils;
 import org.apache.shenyu.common.utils.Singleton;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.loadbalancer.entity.UpstreamIdentity;
 
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -181,13 +181,12 @@ public final class UpstreamCacheManager {
 
     private void processOfflineUpstreams(final String selectorId, final 
List<Upstream> offlineUpstreamList,
                                          final List<Upstream> 
existUpstreamList) {
-        Map<String, Upstream> currentUnhealthyMap = 
getCurrentUnhealthyMap(selectorId);
-        Set<Upstream> existUpstreamSet = new HashSet<>(existUpstreamList);
+        Map<UpstreamIdentity, Upstream> currentUnhealthyMap = 
getCurrentUnhealthyMap(selectorId);
+        Set<UpstreamIdentity> existUpstreamSet = 
existUpstreamList.stream().map(Upstream::identity).collect(Collectors.toSet());
 
         offlineUpstreamList.forEach(offlineUp -> {
-            String key = upstreamMapKey(offlineUp);
-            if (existUpstreamSet.contains(offlineUp)) {
-                if (currentUnhealthyMap.containsKey(key) && 
offlineUp.isHealthCheckEnabled()) {
+            if (existUpstreamSet.contains(offlineUp.identity())) {
+                if (currentUnhealthyMap.containsKey(offlineUp.identity()) && 
offlineUp.isHealthCheckEnabled()) {
                     task.putToMap(task.getUnhealthyUpstream(), selectorId, 
offlineUp);
                     task.removeFromMap(task.getHealthyUpstream(), selectorId, 
offlineUp);
                 } else {
@@ -210,11 +209,11 @@ public final class UpstreamCacheManager {
     }
 
     private void updateExistingUpstreams(final List<Upstream> 
validUpstreamList, final List<Upstream> existUpstreamList) {
-        Map<String, Upstream> existUpstreamMap = existUpstreamList.stream()
-            .collect(Collectors.toMap(this::upstreamMapKey, existUp -> 
existUp, (existing, replacement) -> existing));
+        Map<UpstreamIdentity, Upstream> existUpstreamMap = 
existUpstreamList.stream()
+            .collect(Collectors.toMap(Upstream::identity, existUp -> existUp, 
(existing, replacement) -> existing));
 
         validUpstreamList.forEach(validUp -> {
-            Upstream matchedExistUp = 
existUpstreamMap.get(upstreamMapKey(validUp));
+            Upstream matchedExistUp = existUpstreamMap.get(validUp.identity());
             if (Objects.nonNull(matchedExistUp)) {
                 matchedExistUp.setWeight(validUp.getWeight());
                 
matchedExistUp.setHealthCheckEnabled(validUp.isHealthCheckEnabled());
@@ -227,12 +226,12 @@ public final class UpstreamCacheManager {
 
     private void addNewUpstreams(final String selectorId, final List<Upstream> 
validUpstreamList,
                                  final List<Upstream> existUpstreamList) {
-        Map<String, Upstream> currentUnhealthyMap = 
getCurrentUnhealthyMap(selectorId);
+        Map<UpstreamIdentity, Upstream> currentUnhealthyMap = 
getCurrentUnhealthyMap(selectorId);
 
         validUpstreamList.stream()
             .filter(validUp -> !existUpstreamList.contains(validUp))
             .forEach(up -> {
-                Upstream prevUnhealthy = 
currentUnhealthyMap.get(upstreamMapKey(up));
+                Upstream prevUnhealthy = 
currentUnhealthyMap.get(up.identity());
                 if (Objects.nonNull(prevUnhealthy)) {
                     task.putToMap(task.getUnhealthyUpstream(), selectorId, up);
                 } else {
@@ -241,14 +240,10 @@ public final class UpstreamCacheManager {
             });
     }
 
-    private Map<String, Upstream> getCurrentUnhealthyMap(final String 
selectorId) {
+    private Map<UpstreamIdentity, Upstream> getCurrentUnhealthyMap(final 
String selectorId) {
         List<Upstream> currentUnhealthy = 
task.getUnhealthyUpstream().get(selectorId);
         return Objects.isNull(currentUnhealthy)
             ? Maps.newConcurrentMap()
-            : 
currentUnhealthy.stream().collect(Collectors.toMap(this::upstreamMapKey, u -> 
u, (a, b) -> a));
-    }
-
-    private String upstreamMapKey(final Upstream upstream) {
-        return String.join("_", upstream.getProtocol(), upstream.getUrl());
+            : 
currentUnhealthy.stream().collect(Collectors.toMap(Upstream::identity, u -> u, 
(a, b) -> a));
     }
 }
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
index d832dc28ff..d8788de44a 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
@@ -85,6 +85,8 @@ public final class Upstream {
      */
     private String version;
 
+    private transient UpstreamIdentity identity;
+
     /**
      * ewma value.
      */
@@ -196,6 +198,7 @@ public final class Upstream {
      */
     public void setUrl(final String url) {
         this.url = url;
+        this.identity = null;
     }
     
     /**
@@ -304,6 +307,7 @@ public final class Upstream {
      */
     public void setGroup(final String group) {
         this.group = group;
+        this.identity = null;
     }
     
     /**
@@ -322,6 +326,7 @@ public final class Upstream {
      */
     public void setVersion(final String version) {
         this.version = version;
+        this.identity = null;
     }
     
     /**
@@ -495,6 +500,18 @@ public final class Upstream {
         return new Builder();
     }
 
+    /**
+     * Get the current immutable logical target identity.
+     *
+     * @return the logical target identity
+     */
+    public UpstreamIdentity identity() {
+        if (Objects.isNull(identity)) {
+            identity = new UpstreamIdentity(protocol, url, group, version, 
buildDomain());
+        }
+        return identity;
+    }
+
     @Override
     public boolean equals(final Object o) {
         if (this == o) {
@@ -504,12 +521,12 @@ public final class Upstream {
             return false;
         }
         Upstream that = (Upstream) o;
-        return Objects.equals(url, that.url) && Objects.equals(protocol, 
that.protocol);
+        return identity().equals(that.identity());
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(protocol, url);
+        return identity().hashCode();
     }
 
     @Override
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/UpstreamIdentity.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/UpstreamIdentity.java
new file mode 100644
index 0000000000..49589feeb3
--- /dev/null
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/UpstreamIdentity.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.loadbalancer.entity;
+
+import org.apache.shenyu.common.utils.GsonUtils;
+
+import java.util.Objects;
+
+/**
+ * Immutable identity of a logical upstream target.
+ */
+public final class UpstreamIdentity {
+
+    private final String protocol;
+
+    private final String url;
+
+    private final String group;
+
+    private final String version;
+
+    private final String hashKey;
+
+    private final String leastActiveKey;
+
+    UpstreamIdentity(final String protocol, final String url, final String 
group, final String version, final String domain) {
+        this.protocol = protocol;
+        this.url = url;
+        this.group = group;
+        this.version = version;
+        boolean hasVariant = Objects.nonNull(group) || 
Objects.nonNull(version);
+        this.hashKey = hasVariant ? GsonUtils.getInstance().toJson(new 
String[]{protocol, url, group, version}) : url;
+        this.leastActiveKey = hasVariant ? GsonUtils.getInstance().toJson(new 
String[]{domain, group, version}) : domain;
+    }
+
+    /**
+     * Get the hash ring key, preserving the address key for targets without a 
group or version.
+     *
+     * @return the hash ring key
+     */
+    public String hashKey() {
+        return hashKey;
+    }
+
+    /**
+     * Get the least active key, preserving the domain key for targets without 
a group or version.
+     *
+     * @return the least active key
+     */
+    public String leastActiveKey() {
+        return leastActiveKey;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        UpstreamIdentity that = (UpstreamIdentity) o;
+        return Objects.equals(protocol, that.protocol) && Objects.equals(url, 
that.url)
+                && Objects.equals(group, that.group) && 
Objects.equals(version, that.version);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(protocol, url, group, version);
+    }
+}
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancer.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancer.java
index 06f5380d93..d6d419193b 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancer.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancer.java
@@ -51,7 +51,7 @@ public class HashLoadBalancer extends AbstractLoadBalancer {
     @Override
     public Upstream doSelect(final List<Upstream> upstreamList, final 
LoadBalanceData data) {
         final List<String> ringKey = new ArrayList<>(upstreamList.size());
-        upstreamList.forEach(upstream -> ringKey.add(upstream.getUrl()));
+        upstreamList.forEach(upstream -> 
ringKey.add(upstream.identity().hashKey()));
         final SortedMap<Long, Integer> treeMap = 
ringCache.computeIfAbsent(List.copyOf(ringKey), this::buildRing);
         long hash = hash(data.getIp());
         SortedMap<Long, Integer> lastRing = treeMap.tailMap(hash);
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
index aa6eb32199..11af52eda1 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
@@ -49,7 +49,7 @@ public class LeastActiveLoadBalance extends 
AbstractLoadBalancer {
     protected Upstream doSelect(final List<Upstream> upstreamList, final 
LoadBalanceData data) {
         long now = System.currentTimeMillis();
         Map<String, Upstream> domainMap = upstreamList.stream()
-                .collect(Collectors.toConcurrentMap(Upstream::buildDomain, 
upstream -> upstream));
+                .collect(Collectors.toConcurrentMap(upstream -> 
upstream.identity().leastActiveKey(), upstream -> upstream));
 
         domainMap.keySet().forEach(domain -> {
             ActiveCount activeCount = countMap.computeIfAbsent(domain, key -> 
new ActiveCount(now));
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/RoundRobinLoadBalancer.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/RoundRobinLoadBalancer.java
index 8e3a1c3c0b..85015537df 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/RoundRobinLoadBalancer.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/RoundRobinLoadBalancer.java
@@ -20,6 +20,7 @@ package org.apache.shenyu.loadbalancer.spi;
 import org.apache.shenyu.common.utils.MapUtils;
 import org.apache.shenyu.loadbalancer.entity.LoadBalanceData;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.loadbalancer.entity.UpstreamIdentity;
 import org.apache.shenyu.spi.Join;
 
 import java.util.List;
@@ -37,23 +38,22 @@ public class RoundRobinLoadBalancer extends 
AbstractLoadBalancer {
 
     private final int recyclePeriod = 60000;
 
-    private final ConcurrentMap<String, ConcurrentMap<String, 
WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<>(16);
+    private final ConcurrentMap<String, ConcurrentMap<UpstreamIdentity, 
WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<>(16);
 
     private final AtomicBoolean updateLock = new AtomicBoolean();
 
     @Override
     public Upstream doSelect(final List<Upstream> upstreamList, final 
LoadBalanceData data) {
         String key = upstreamList.get(0).getUrl();
-        ConcurrentMap<String, WeightedRoundRobin> map = 
MapUtils.computeIfAbsent(methodWeightMap, key, k -> new 
ConcurrentHashMap<>(16));
+        ConcurrentMap<UpstreamIdentity, WeightedRoundRobin> map = 
MapUtils.computeIfAbsent(methodWeightMap, key, k -> new 
ConcurrentHashMap<>(16));
         int totalWeight = 0;
         long maxCurrent = Long.MIN_VALUE;
         long now = System.currentTimeMillis();
         Upstream selectedInvoker = null;
         WeightedRoundRobin selectedWeightedRoundRobin = null;
         for (Upstream upstream : upstreamList) {
-            String rKey = upstream.getUrl();
             int weight = getWeight(upstream);
-            WeightedRoundRobin weightedRoundRobin = 
MapUtils.computeIfAbsent(map, rKey, k -> {
+            WeightedRoundRobin weightedRoundRobin = 
MapUtils.computeIfAbsent(map, upstream.identity(), k -> {
                 WeightedRoundRobin roundRobin = new WeightedRoundRobin();
                 roundRobin.setWeight(weight);
                 return roundRobin;
@@ -74,7 +74,7 @@ public class RoundRobinLoadBalancer extends 
AbstractLoadBalancer {
         if (!updateLock.get() && upstreamList.size() != map.size() && 
updateLock.compareAndSet(false, true)) {
             try {
                 // copy -> modify -> update reference.
-                ConcurrentMap<String, WeightedRoundRobin> newMap = new 
ConcurrentHashMap<>(map);
+                ConcurrentMap<UpstreamIdentity, WeightedRoundRobin> newMap = 
new ConcurrentHashMap<>(map);
                 newMap.entrySet().removeIf(item -> now - 
item.getValue().getLastUpdate() > recyclePeriod);
                 methodWeightMap.put(key, newMap);
             } finally {
diff --git 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManagerTest.java
 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManagerTest.java
index da6a508d21..21a47d9ff6 100644
--- 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManagerTest.java
+++ 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManagerTest.java
@@ -25,6 +25,8 @@ import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -379,6 +381,84 @@ public class UpstreamCacheManagerTest {
         upstreamCacheManager.removeByKey(testSelectorId);
     }
 
+    @ParameterizedTest
+    @CsvSource({"green, v1", "blue, v2"})
+    @Order(12)
+    public void testSubmitPreservesLogicalUpstreamIdentity(final String 
secondGroup, final String secondVersion) {
+        final UpstreamCacheManager manager = 
UpstreamCacheManager.getInstance();
+        final String selectorId = "LOGICAL_IDENTITY_" + secondGroup + 
secondVersion;
+        final Upstream first = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                
.group("blue").version("v1").weight(10).healthCheckEnabled(false).build();
+        final Upstream second = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                
.group(secondGroup).version(secondVersion).weight(20).healthCheckEnabled(false).build();
+        try {
+            manager.submit(selectorId, List.of(first, second));
+            List<Upstream> initial = 
manager.findUpstreamListBySelectorId(selectorId);
+            Assertions.assertNotNull(initial);
+            Assertions.assertEquals(2, initial.size());
+            Assertions.assertSame(first, initial.get(0));
+            Assertions.assertSame(second, initial.get(1));
+
+            final Upstream updatedFirst = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                    
.group("blue").version("v1").weight(30).healthCheckEnabled(false).build();
+            final Upstream updatedSecond = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                    
.group(secondGroup).version(secondVersion).weight(40).healthCheckEnabled(false).build();
+            manager.submit(selectorId, List.of(updatedFirst, updatedSecond));
+            List<Upstream> updated = 
manager.findUpstreamListBySelectorId(selectorId);
+            Assertions.assertNotNull(updated);
+            Assertions.assertEquals(2, updated.size());
+            Assertions.assertSame(first, updated.get(0));
+            Assertions.assertSame(second, updated.get(1));
+            Assertions.assertEquals(30, first.getWeight());
+            Assertions.assertEquals(40, second.getWeight());
+
+            final Upstream offlineFirst = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                    
.group("blue").version("v1").status(false).healthCheckEnabled(true).build();
+            manager.submit(selectorId, List.of(offlineFirst, updatedSecond));
+            List<Upstream> remaining = 
manager.findUpstreamListBySelectorId(selectorId);
+            Assertions.assertNotNull(remaining);
+            Assertions.assertEquals(1, remaining.size());
+            Assertions.assertSame(second, remaining.get(0));
+            Assertions.assertEquals(40, second.getWeight());
+        } finally {
+            manager.removeByKey(selectorId);
+        }
+    }
+
+    @Test
+    @Order(13)
+    public void testSubmitKeepsUnhealthyLogicalUpstreamsSeparate() throws 
ReflectiveOperationException {
+        final UpstreamCacheManager manager = 
UpstreamCacheManager.getInstance();
+        final UpstreamCheckTask task = getUpstreamCheckTask(manager);
+        Assertions.assertNotNull(task);
+        final String selectorId = "LOGICAL_UNHEALTHY_IDENTITY";
+        final Upstream first = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                
.group("blue").version("v1").status(false).healthCheckEnabled(true).build();
+        final Upstream second = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                
.group("green").version("v2").status(false).healthCheckEnabled(true).build();
+        final Upstream healthy = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                
.group("yellow").version("v3").healthCheckEnabled(false).build();
+        final java.lang.reflect.Field lockField = 
UpstreamCheckTask.class.getDeclaredField("lock");
+        lockField.setAccessible(true);
+        synchronized (lockField.get(task)) {
+            try {
+                manager.submit(selectorId, List.of(first, second, healthy));
+                List<Upstream> healthyUpstreams = 
manager.findUpstreamListBySelectorId(selectorId);
+                Assertions.assertNotNull(healthyUpstreams);
+                Assertions.assertEquals(1, healthyUpstreams.size());
+                Assertions.assertSame(healthy, healthyUpstreams.get(0));
+                List<Upstream> unhealthyUpstreams = 
task.getUnhealthyUpstream().get(selectorId);
+                Assertions.assertNotNull(unhealthyUpstreams);
+                Assertions.assertEquals(2, unhealthyUpstreams.size());
+                Assertions.assertSame(first, unhealthyUpstreams.get(0));
+                Assertions.assertSame(second, unhealthyUpstreams.get(1));
+                Assertions.assertFalse(unhealthyUpstreams.contains(healthy));
+            } finally {
+                manager.removeByKey(selectorId);
+            }
+        }
+    }
+
     /**
      * Helper method to get the UpstreamCheckTask using reflection.
      */
diff --git 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/entity/UpstreamTest.java
 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/entity/UpstreamTest.java
index ab7d009707..1a3e4d99b8 100644
--- 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/entity/UpstreamTest.java
+++ 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/entity/UpstreamTest.java
@@ -17,9 +17,15 @@
 
 package org.apache.shenyu.loadbalancer.entity;
 
+import com.google.gson.JsonObject;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.common.utils.JsonUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.HashMap;
+import java.util.Map;
+
 /**
  * The type Upstream check task test.
  */
@@ -83,6 +89,49 @@ public class UpstreamTest {
         Assertions.assertEquals(upstream2, upstream4);
         Assertions.assertEquals(upstream2.hashCode(), upstream4.hashCode());
         Assertions.assertNotNull(upstream2.toString());
-        Assertions.assertTrue(upstream2.hashCode() >= 0);
+    }
+
+    @Test
+    public void identityShouldRemainAnImmutableSnapshot() {
+        final Upstream upstream = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                .group("blue").version("v1").weight(10).build();
+        final UpstreamIdentity original = upstream.identity();
+        final String originalHashKey = original.hashKey();
+        final String originalLeastActiveKey = original.leastActiveKey();
+        final Map<UpstreamIdentity, String> cached = new HashMap<>();
+        cached.put(original, "original");
+
+        upstream.setWeight(20);
+        upstream.setStatus(false);
+        Assertions.assertSame(original, upstream.identity());
+        upstream.setUrl("127.0.0.2:20880");
+        final UpstreamIdentity changedUrl = upstream.identity();
+        Assertions.assertNotEquals(original, changedUrl);
+        upstream.setGroup("green");
+        final UpstreamIdentity changedGroup = upstream.identity();
+        Assertions.assertNotEquals(changedUrl, changedGroup);
+        upstream.setVersion("v2");
+        Assertions.assertNotEquals(changedGroup, upstream.identity());
+        Assertions.assertEquals(originalHashKey, original.hashKey());
+        Assertions.assertEquals(originalLeastActiveKey, 
original.leastActiveKey());
+        Assertions.assertEquals("original", cached.get(original));
+        final Upstream originalTarget = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
+                .group("blue").version("v1").build();
+        Assertions.assertEquals("original", 
cached.get(originalTarget.identity()));
+        final Upstream currentTarget = 
Upstream.builder().protocol("dubbo://").url("127.0.0.2:20880")
+                .group("green").version("v2").build();
+        Assertions.assertEquals(currentTarget, upstream);
+        Assertions.assertEquals(currentTarget.hashCode(), upstream.hashCode());
+
+        final JsonObject gsonFields = 
GsonUtils.getInstance().fromJson(GsonUtils.getInstance().toJson(upstream), 
JsonObject.class);
+        Assertions.assertFalse(gsonFields.has("identity"));
+        Assertions.assertEquals("127.0.0.2:20880", 
gsonFields.get("url").getAsString());
+        Assertions.assertEquals("green", 
gsonFields.get("group").getAsString());
+        Assertions.assertEquals("v2", gsonFields.get("version").getAsString());
+        final Map<String, Object> jacksonFields = JsonUtils.toMap(upstream);
+        Assertions.assertFalse(jacksonFields.containsKey("identity"));
+        Assertions.assertEquals("127.0.0.2:20880", jacksonFields.get("url"));
+        Assertions.assertEquals("green", jacksonFields.get("group"));
+        Assertions.assertEquals("v2", jacksonFields.get("version"));
     }
 }
diff --git 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancerTest.java
 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancerTest.java
index 13db6be6c9..b77bbf7ed0 100644
--- 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancerTest.java
+++ 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/HashLoadBalancerTest.java
@@ -23,8 +23,11 @@ import org.junit.jupiter.api.Test;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
@@ -64,6 +67,31 @@ class HashLoadBalancerTest {
         assertEquals(1, ((Map<?, ?>) 
ringCacheField.get(hashLoadBalancer)).size());
     }
 
+    @Test
+    void shouldKeepSameAddressVariantsOnSeparateRingNodes() throws Exception {
+        final HashLoadBalancer hashLoadBalancer = new HashLoadBalancer();
+        final Upstream first = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("blue").version("v1").build();
+        final Upstream second = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("blue").version("v2").build();
+        final Upstream third = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("green").version("v2").build();
+        final List<Upstream> upstreamList = List.of(first, second, third);
+        final LoadBalanceData data = new LoadBalanceData();
+        final Set<Upstream> selected = new HashSet<>();
+        for (int i = 0; i < 256 && selected.size() < upstreamList.size(); i++) 
{
+            data.setIp("192.0.2." + i);
+            selected.add(hashLoadBalancer.select(upstreamList, data));
+        }
+        assertEquals(Set.copyOf(upstreamList), selected);
+
+        final Field ringCacheField = 
HashLoadBalancer.class.getDeclaredField("ringCache");
+        ringCacheField.setAccessible(true);
+        final Map<?, ?> ringCache = (Map<?, ?>) 
ringCacheField.get(hashLoadBalancer);
+        assertEquals(Set.of(0, 1, 2), Set.copyOf(((SortedMap<?, ?>) 
ringCache.values().iterator().next()).values()));
+
+        final Upstream changed = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("blue").version("v3").build();
+        hashLoadBalancer.select(List.of(changed, second, third), data);
+        assertEquals(2, ringCache.size());
+    }
+
     private List<Upstream> upstreams() {
         final List<Upstream> result = new ArrayList<>();
         result.add(Upstream.builder().url("http://1.1.1.1/api";).build());
diff --git 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
index 55ad640b29..21fe11361b 100644
--- 
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
+++ 
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
@@ -59,6 +59,22 @@ public class LeastActiveLoadBalanceTest {
                 || upstream1.getUrl().equals("baidu.com") && 
upstream.getUrl().equals("pro.jd.com"));
     }
 
+    @Test
+    public void testSameAddressVariantsKeepIndependentCounts() throws 
Exception {
+        Upstream first = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("blue").version("v1").build();
+        Upstream second = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("green").version("v2").build();
+        List<Upstream> upstreamList = List.of(first, second);
+        LeastActiveLoadBalance loadBalance = new LeastActiveLoadBalance();
+
+        Upstream firstSelection = loadBalance.doSelect(upstreamList, new 
LoadBalanceData());
+        Upstream secondSelection = loadBalance.doSelect(upstreamList, new 
LoadBalanceData());
+
+        Assertions.assertNotNull(firstSelection);
+        Assertions.assertNotNull(secondSelection);
+        Assertions.assertNotSame(firstSelection, secondSelection);
+        Assertions.assertEquals(2, getCountMap(loadBalance).size());
+    }
+
     @Test
     public void testRemoveStaleCountMapEntries() throws Exception {
         buildUpstreamList();
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/handler/ApacheDubboPluginDataHandlerTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/handler/ApacheDubboPluginDataHandlerTest.java
index 96af1adfc6..0d706ddc7f 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/handler/ApacheDubboPluginDataHandlerTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/handler/ApacheDubboPluginDataHandlerTest.java
@@ -18,25 +18,39 @@
 package org.apache.shenyu.plugin.apache.dubbo.handler;
 
 import com.google.common.cache.LoadingCache;
+import org.apache.dubbo.common.URL;
 import org.apache.dubbo.config.ConsumerConfig;
 import org.apache.dubbo.config.ReferenceConfig;
 import org.apache.dubbo.config.RegistryConfig;
+import org.apache.dubbo.rpc.Invoker;
+import org.apache.dubbo.rpc.RpcInvocation;
 import org.apache.dubbo.rpc.service.GenericService;
+import org.apache.dubbo.rpc.support.MockInvoker;
+import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.dto.convert.plugin.DubboRegisterConfig;
 import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
 import org.apache.shenyu.common.enums.PluginEnum;
 import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
 import org.apache.shenyu.plugin.apache.dubbo.cache.ApacheDubboConfigCache;
+import org.apache.shenyu.plugin.apache.dubbo.proxy.ApacheDubboGrayLoadBalance;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.springframework.test.util.ReflectionTestUtils;
 
+import java.util.List;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
 
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
@@ -72,6 +86,49 @@ public final class ApacheDubboPluginDataHandlerTest {
         assertThat(apacheDubboPluginDataHandler.pluginNamed(), 
is(PluginEnum.DUBBO.getName()));
     }
 
+    @Test
+    public void grayRoutingShouldPreserveSameAddressGroupsVersionsAndWeights() 
{
+        SelectorData selectorData = new SelectorData();
+        selectorData.setId("gray-logical-identity-selector");
+        selectorData.setContinued(true);
+        
selectorData.setHandle("[{\"protocol\":\"dubbo://\",\"upstreamUrl\":\"127.0.0.1:37438\",\"group\":\"blue\",\"version\":\"v1\","
+                + 
"\"weight\":1,\"gray\":true,\"status\":true,\"healthCheckEnabled\":false,\"warmup\":0},"
+                + 
"{\"protocol\":\"dubbo://\",\"upstreamUrl\":\"127.0.0.1:37438\",\"group\":\"blue\",\"version\":\"v2\","
+                + 
"\"weight\":2,\"gray\":true,\"status\":true,\"healthCheckEnabled\":false,\"warmup\":0},"
+                + 
"{\"protocol\":\"dubbo://\",\"upstreamUrl\":\"127.0.0.1:37438\",\"group\":\"green\",\"version\":\"v2\","
+                + 
"\"weight\":3,\"gray\":true,\"status\":true,\"healthCheckEnabled\":false,\"warmup\":0}]");
+        RuleData ruleData = new RuleData();
+        ruleData.setId("gray-logical-identity-rule");
+        ruleData.setSelectorId(selectorData.getId());
+        ruleData.setHandle("{\"loadBalance\":\"roundRobin\"}");
+        try {
+            apacheDubboPluginDataHandler.handlerSelector(selectorData);
+            apacheDubboPluginDataHandler.handlerRule(ruleData);
+            List<Upstream> cached = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selectorData.getId());
+            assertEquals(3, cached.size());
+            assertEquals(Map.of("blue:v1", 1, "blue:v2", 2, "green:v2", 3), 
cached.stream()
+                    .collect(Collectors.toMap(upstream -> upstream.getGroup() 
+ ":" + upstream.getVersion(), Upstream::getWeight)));
+
+            MockInvoker<Object> blueV1 = new 
MockInvoker<>(URL.valueOf("dubbo://127.0.0.1:37438/TestService?group=blue&version=v1"),
 Object.class);
+            MockInvoker<Object> blueV2 = new 
MockInvoker<>(URL.valueOf("dubbo://127.0.0.1:37438/TestService?group=blue&version=v2"),
 Object.class);
+            MockInvoker<Object> greenV2 = new 
MockInvoker<>(URL.valueOf("dubbo://127.0.0.1:37438/TestService?group=green&version=v2"),
 Object.class);
+            List<Invoker<Object>> invokers = List.of(blueV1, blueV2, greenV2);
+            RpcInvocation invocation = new RpcInvocation();
+            invocation.setMethodName("test");
+            invocation.setAttachment(Constants.DUBBO_SELECTOR_ID, 
selectorData.getId());
+            invocation.setAttachment(Constants.DUBBO_RULE_ID, 
ruleData.getId());
+            invocation.setAttachment(Constants.DUBBO_REMOTE_ADDRESS, 
"127.0.0.1");
+            ApacheDubboGrayLoadBalance loadBalance = new 
ApacheDubboGrayLoadBalance();
+            // Attachments identify the configuration; each call advances 
weighted round robin's internal state.
+            for (Invoker<Object> expected : List.of(greenV2, blueV2, blueV1, 
greenV2, blueV2, greenV2)) {
+                assertSame(expected, loadBalance.select(invokers, 
blueV1.getUrl(), invocation));
+            }
+        } finally {
+            apacheDubboPluginDataHandler.removeRule(ruleData);
+            apacheDubboPluginDataHandler.removeSelector(selectorData);
+        }
+    }
+
     @Test
     @SuppressWarnings("unchecked")
     public void configUpdateShouldApplyBeforeDestroyingReferences() {
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/proxy/ApacheDubboGrayLoadBalanceTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/proxy/ApacheDubboGrayLoadBalanceTest.java
index 111e7d0950..06627ece9a 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/proxy/ApacheDubboGrayLoadBalanceTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/test/java/org/apache/shenyu/plugin/apache/dubbo/proxy/ApacheDubboGrayLoadBalanceTest.java
@@ -27,6 +27,7 @@ import 
org.apache.shenyu.common.dto.convert.selector.DubboUpstream;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
 import org.apache.shenyu.loadbalancer.factory.LoadBalancerFactory;
 import 
org.apache.shenyu.plugin.apache.dubbo.handler.ApacheDubboPluginDataHandler;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -53,6 +54,8 @@ public class ApacheDubboGrayLoadBalanceTest {
 
     private ApacheDubboGrayLoadBalance apacheDubboGrayLoadBalance;
 
+    private MockedStatic<LoadBalancerFactory> loadBalancerFactoryMockedStatic;
+
     @Mock
     private Invocation invocation;
 
@@ -84,7 +87,7 @@ public class ApacheDubboGrayLoadBalanceTest {
         when(dubboRuleHandle.getLoadBalance()).thenReturn("random");
         mockInvoker = new MockInvoker<>(URL.valueOf("localhost"), 
Object.class);
         invokers = Collections.singletonList(mockInvoker);
-        MockedStatic<LoadBalancerFactory> loadBalancerFactoryMockedStatic = 
mockStatic(LoadBalancerFactory.class);
+        loadBalancerFactoryMockedStatic = 
mockStatic(LoadBalancerFactory.class);
         loadBalancerFactoryMockedStatic.when(() -> 
LoadBalancerFactory.selector(any(), any(), any()))
                 .thenReturn(upstream);
         
when(invocation.getAttachment(Constants.DUBBO_SELECTOR_ID)).thenReturn(Constants.DUBBO_SELECTOR_ID);
@@ -92,6 +95,12 @@ public class ApacheDubboGrayLoadBalanceTest {
         
when(invocation.getAttachment(Constants.DUBBO_REMOTE_ADDRESS)).thenReturn("localhost");
     }
 
+    @AfterEach
+    public void tearDown() {
+        // Release the thread-local static mock so later tests use the real 
selector.
+        loadBalancerFactoryMockedStatic.close();
+    }
+
     @Test
     public void apacheDubboLoadBalanceTest() {
         assertEquals(apacheDubboGrayLoadBalance.select(invokers, url, 
invocation), mockInvoker);

Reply via email to