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 cf39582215 OSPP: feat(divide): propagate discovery upstream labels 
(#7847)
cf39582215 is described below

commit cf3958221560274e684b0818209ddcb828b07776
Author: SouthwestAsiaFloat <[email protected]>
AuthorDate: Wed Oct 21 10:39:24 2026 +0800

    OSPP: feat(divide): propagate discovery upstream labels (#7847)
    
    * feat(divide): propagate discovery upstream labels
    
    Validate labels independently of Canary configuration, preserve submitted 
discovery props, and refresh immutable labels and metadata on cached upstreams. 
Retain existing routing and gray filtering.
    
    Validation: 203 tests passed across 20 classes; Checkstyle, Apache RAT and 
diff checks passed.
    
    * docs(divide): remove upstream labels guide
---
 .../service/impl/DiscoveryUpstreamServiceImpl.java |   9 ++
 .../service/impl/ProxySelectorServiceImpl.java     |  10 ++
 .../shenyu/admin/transfer/DiscoveryTransfer.java   |  16 ++-
 .../utils/DiscoveryUpstreamPropsValidator.java     |  62 +++++++++
 .../service/DiscoveryUpstreamServiceTest.java      | 141 ++++++++++++++++++++-
 .../admin/service/ProxySelectorServiceTest.java    |  69 +++++++++-
 .../admin/transfer/DiscoveryTransferTest.java      |  51 +++++++-
 .../utils/DiscoveryUpstreamPropsValidatorTest.java |  48 +++++++
 .../shenyu/common/utils/UpstreamLabelsUtils.java   |  66 ++++++++++
 .../common/utils/UpstreamLabelsUtilsTest.java      |  62 +++++++++
 .../loadbalancer/cache/UpstreamCacheManager.java   |  15 +++
 .../shenyu/loadbalancer/entity/Upstream.java       |  23 +++-
 .../cache/UpstreamCacheManagerTest.java            |   9 ++
 .../shenyu/loadbalancer/entity/UpstreamTest.java   |  17 +++
 .../shenyu/plugin/base/utils/UpstreamProps.java    |  32 ++++-
 .../plugin/base/utils/UpstreamPropsTest.java       |  40 ++++++
 .../divide/handler/DivideUpstreamDataHandler.java  |   1 +
 .../handler/DivideUpstreamDataHandlerTest.java     | 138 ++++++++++++++++++++
 18 files changed, 789 insertions(+), 20 deletions(-)

diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/DiscoveryUpstreamServiceImpl.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/DiscoveryUpstreamServiceImpl.java
index 792ac17d67..6919c2d843 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/DiscoveryUpstreamServiceImpl.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/DiscoveryUpstreamServiceImpl.java
@@ -42,6 +42,7 @@ import org.apache.shenyu.admin.model.vo.DiscoveryUpstreamVO;
 import org.apache.shenyu.admin.service.DiscoveryUpstreamService;
 import org.apache.shenyu.admin.service.configs.ConfigsImportContext;
 import org.apache.shenyu.admin.transfer.DiscoveryTransfer;
+import org.apache.shenyu.admin.utils.DiscoveryUpstreamPropsValidator;
 import org.apache.shenyu.admin.utils.Assert;
 import org.apache.shenyu.admin.utils.ShenyuResultMessage;
 import org.apache.shenyu.common.dto.DiscoverySyncData;
@@ -127,6 +128,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
         if (CollectionUtils.isEmpty(upstreams)) {
             return;
         }
+        upstreams.forEach(upstream -> 
DiscoveryUpstreamPropsValidator.validate(upstream.getProps()));
         Set<String> handlerIds = new LinkedHashSet<>();
         for (DiscoveryUpstreamDTO upstream : upstreams) {
             DiscoveryUpstreamDO entity = 
DiscoveryUpstreamDO.buildDiscoveryUpstreamDO(upstream);
@@ -143,6 +145,8 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
     @Override
     @Transactional
     public int updateBatch(final String discoveryHandlerId, final 
List<DiscoveryUpstreamDTO> discoveryUpstreamDTOList) {
+        // Validate the complete replacement before deleting anything or 
emitting a sync event.
+        discoveryUpstreamDTOList.forEach(upstream -> 
DiscoveryUpstreamPropsValidator.validate(upstream.getProps()));
         discoveryUpstreamMapper.deleteByDiscoveryHandlerId(discoveryHandlerId);
         for (DiscoveryUpstreamDTO discoveryUpstreamDTO : 
discoveryUpstreamDTOList) {
             discoveryUpstreamDTO.setId(null);
@@ -169,6 +173,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
 
     @Override
     public void nativeCreateOrUpdate(final DiscoveryUpstreamDTO 
discoveryUpstreamDTO) {
+        
DiscoveryUpstreamPropsValidator.validate(discoveryUpstreamDTO.getProps());
         DiscoveryUpstreamDO discoveryUpstreamDO = 
DiscoveryUpstreamDO.buildDiscoveryUpstreamDO(discoveryUpstreamDTO);
         if (StringUtils.hasLength(discoveryUpstreamDTO.getId())) {
             discoveryUpstreamMapper.updateSelective(discoveryUpstreamDO);
@@ -294,6 +299,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
      * @return the string
      */
     private String create(final DiscoveryUpstreamDTO discoveryUpstreamDTO) {
+        
DiscoveryUpstreamPropsValidator.validate(discoveryUpstreamDTO.getProps());
         DiscoveryUpstreamDO discoveryUpstreamDO = 
DiscoveryUpstreamDO.buildDiscoveryUpstreamDO(discoveryUpstreamDTO);
         discoveryUpstreamMapper.insert(discoveryUpstreamDO);
         fetchAll(discoveryUpstreamDTO.getDiscoveryHandlerId());
@@ -307,6 +313,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
      * @return the string
      */
     private String update(final DiscoveryUpstreamDTO discoveryUpstreamDTO) {
+        
DiscoveryUpstreamPropsValidator.validate(discoveryUpstreamDTO.getProps());
         DiscoveryUpstreamDO discoveryUpstreamDO = 
DiscoveryUpstreamDO.buildDiscoveryUpstreamDO(discoveryUpstreamDTO);
         discoveryUpstreamMapper.update(discoveryUpstreamDO);
         fetchAll(discoveryUpstreamDTO.getDiscoveryHandlerId());
@@ -352,6 +359,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
         if (CollectionUtils.isEmpty(discoveryUpstreamList)) {
             return ConfigImportResult.success();
         }
+        discoveryUpstreamList.forEach(upstream -> 
DiscoveryUpstreamPropsValidator.validate(upstream.getProps()));
         int successCount = 0;
         StringBuilder errorMsgBuilder = new StringBuilder();
         Map<String, List<DiscoveryUpstreamDO>> discoveryHandlerUpstreamMap = 
discoveryUpstreamMapper
@@ -398,6 +406,7 @@ public class DiscoveryUpstreamServiceImpl implements 
DiscoveryUpstreamService {
             return ConfigImportResult.success();
         }
         Map<String, String> discoveryHandlerIdMapping = 
context.getDiscoveryHandlerIdMapping();
+        discoveryUpstreamList.forEach(upstream -> 
DiscoveryUpstreamPropsValidator.validate(upstream.getProps()));
         int successCount = 0;
         StringBuilder errorMsgBuilder = new StringBuilder();
         Map<String, Set<String>> discoveryHandlerUpstreamMap = 
discoveryUpstreamMapper
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ProxySelectorServiceImpl.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ProxySelectorServiceImpl.java
index 9a8e88ebc4..27e98d60a5 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ProxySelectorServiceImpl.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ProxySelectorServiceImpl.java
@@ -49,6 +49,7 @@ import org.apache.shenyu.admin.model.vo.ProxySelectorVO;
 import org.apache.shenyu.admin.service.ProxySelectorService;
 import org.apache.shenyu.admin.service.configs.ConfigsImportContext;
 import org.apache.shenyu.admin.transfer.DiscoveryTransfer;
+import org.apache.shenyu.admin.utils.DiscoveryUpstreamPropsValidator;
 import org.apache.shenyu.admin.utils.ShenyuResultMessage;
 import org.apache.shenyu.admin.utils.Assert;
 import org.apache.shenyu.common.dto.ProxySelectorData;
@@ -239,6 +240,7 @@ public class ProxySelectorServiceImpl implements 
ProxySelectorService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public String create(final ProxySelectorAddDTO proxySelectorAddDTO) {
+        validateUpstreamProps(proxySelectorAddDTO);
         Timestamp currentTime = new Timestamp(System.currentTimeMillis());
         ProxySelectorDO proxySelectorDO = 
ProxySelectorDO.buildProxySelectorDO(proxySelectorAddDTO);
         String proxySelectorId = proxySelectorDO.getId();
@@ -341,6 +343,7 @@ public class ProxySelectorServiceImpl implements 
ProxySelectorService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public String bindingDiscoveryHandler(final ProxySelectorAddDTO 
proxySelectorAddDTO) {
+        validateUpstreamProps(proxySelectorAddDTO);
         Timestamp currentTime = new Timestamp(System.currentTimeMillis());
         String selectorId = proxySelectorAddDTO.getSelectorId();
         final ProxySelectorAddDTO.Discovery discovery = 
proxySelectorAddDTO.getDiscovery();
@@ -392,6 +395,7 @@ public class ProxySelectorServiceImpl implements 
ProxySelectorService {
      */
     @Transactional(rollbackFor = Exception.class)
     public String update(final ProxySelectorAddDTO proxySelectorAddDTO) {
+        validateUpstreamProps(proxySelectorAddDTO);
         ProxySelectorAddDTO.Discovery discovery = 
proxySelectorAddDTO.getDiscovery();
         Assert.notNull(discovery, "Discovery configuration is required");
         ProxySelectorDO proxySelectorDO = 
ProxySelectorDO.buildProxySelectorDO(proxySelectorAddDTO);
@@ -461,6 +465,12 @@ public class ProxySelectorServiceImpl implements 
ProxySelectorService {
         return ShenyuResultMessage.UPDATE_SUCCESS;
     }
 
+    private void validateUpstreamProps(final ProxySelectorAddDTO dto) {
+        if (Objects.nonNull(dto.getDiscoveryUpstreams())) {
+            dto.getDiscoveryUpstreams().forEach(upstream -> 
DiscoveryUpstreamPropsValidator.validate(upstream.getProps()));
+        }
+    }
+
     @Override
     public void fetchData(final String discoveryHandlerId) {
         DiscoveryHandlerDO discoveryHandlerDO = 
discoveryHandlerMapper.selectById(discoveryHandlerId);
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/transfer/DiscoveryTransfer.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/transfer/DiscoveryTransfer.java
index fc8fea656c..203e0dd8fa 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/transfer/DiscoveryTransfer.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/transfer/DiscoveryTransfer.java
@@ -17,6 +17,7 @@
 
 package org.apache.shenyu.admin.transfer;
 
+import com.google.gson.JsonElement;
 import org.apache.shenyu.admin.model.dto.DiscoveryDTO;
 import org.apache.shenyu.admin.model.dto.DiscoveryHandlerDTO;
 import org.apache.shenyu.admin.model.dto.DiscoveryRelDTO;
@@ -80,11 +81,16 @@ public enum DiscoveryTransfer {
             String url = data.getUrl();
             CommonUpstream commonUpstream = new 
CommonUpstream(data.getProtocol(), url.split(":")[0], url, false,
                     data.getDateCreated().getTime());
-            Properties properties = Optional.ofNullable(data.getProps())
-                    .map(props -> GsonUtils.getInstance().fromJson(props, 
Properties.class))
-                    .orElse(new Properties());
-            commonUpstream
-                    
.setHealthCheckEnabled(Boolean.parseBoolean(properties.getProperty("healthCheckEnabled",
 "true")));
+            // Other properties, such as labels, may contain nested JSON 
objects.
+            boolean healthCheckEnabled = Optional.ofNullable(data.getProps())
+                    .map(props -> GsonUtils.getInstance().fromJson(props, 
JsonElement.class))
+                    .filter(props -> !props.isJsonNull())
+                    .map(JsonElement::getAsJsonObject)
+                    .map(props -> props.get("healthCheckEnabled"))
+                    .filter(value -> !value.isJsonNull())
+                    .map(value -> Boolean.parseBoolean(value.getAsString()))
+                    .orElse(true);
+            commonUpstream.setHealthCheckEnabled(healthCheckEnabled);
             return commonUpstream;
         }).orElse(null);
     }
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidator.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidator.java
new file mode 100644
index 0000000000..82cfe6cd7d
--- /dev/null
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidator.java
@@ -0,0 +1,62 @@
+/*
+ * 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.admin.utils;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.admin.exception.ShenyuAdminException;
+import org.apache.shenyu.common.utils.UpstreamLabelsUtils;
+import org.apache.shenyu.common.utils.GsonUtils;
+
+import java.util.Objects;
+
+/**
+ * Validation of submitted discovery upstream properties without changing 
replacement semantics.
+ */
+public final class DiscoveryUpstreamPropsValidator {
+
+    private DiscoveryUpstreamPropsValidator() {
+    }
+
+    /**
+     * Validate the submitted properties without merging or rewriting them.
+     * Omitted labels and an empty labels object are both valid; null labels 
are invalid.
+     * Empty properties retain the existing API behavior for optional 
properties.
+     *
+     * @param props the complete submitted properties
+     */
+    public static void validate(final String props) {
+        if (StringUtils.isBlank(props)) {
+            return;
+        }
+        try {
+            JsonElement parsed = GsonUtils.getInstance().fromJson(props, 
JsonElement.class);
+            if (Objects.isNull(parsed) || !parsed.isJsonObject()) {
+                throw new IllegalArgumentException("props must be a JSON 
object");
+            }
+            JsonObject object = parsed.getAsJsonObject();
+            if (object.has("labels")) {
+                UpstreamLabelsUtils.parse(object.get("labels"));
+            }
+        } catch (IllegalArgumentException | JsonParseException ex) {
+            throw new ShenyuAdminException("Invalid upstream props: " + 
ex.getMessage(), ex);
+        }
+    }
+}
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java
index 58628740da..448d1a35a6 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java
@@ -22,6 +22,7 @@ import 
org.springframework.transaction.support.TransactionSynchronizationManager
 
 import org.apache.shenyu.admin.discovery.DiscoveryProcessor;
 import org.apache.shenyu.admin.discovery.DiscoveryProcessorHolder;
+import org.apache.shenyu.admin.exception.ShenyuAdminException;
 import org.apache.shenyu.admin.exception.ValidFailException;
 import org.apache.shenyu.admin.mapper.DiscoveryHandlerMapper;
 import org.apache.shenyu.admin.mapper.DiscoveryMapper;
@@ -50,6 +51,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Mockito;
@@ -57,25 +59,29 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.sql.Timestamp;
 import java.time.LocalDateTime;
-import java.util.Collections;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
-import java.util.Locale;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.when;
-import org.springframework.test.util.ReflectionTestUtils;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import org.springframework.test.util.ReflectionTestUtils;
 
 /**
  * Test cases for DiscoveryUpstreamService.
@@ -528,6 +534,131 @@ public final class DiscoveryUpstreamServiceTest {
         }
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"labels\":{}}", 
"{\"labels\":{\"release\":\"canary\"}}", "{\"warmup\":20}", "{}", 
"{\"warmup\":20,\"extension\":{\"x\":1},\"labels\":{}}"})
+    public void testSingleUpdateReplacesPropsAndSynchronizesLabels(final 
String props) {
+        AtomicReference<DiscoveryUpstreamDO> stored = new 
AtomicReference<>(existingLabeledUpstream());
+        configureSync();
+        when(discoveryUpstreamMapper.update(any())).thenAnswer(invocation -> {
+            stored.set(invocation.getArgument(0));
+            return 1;
+        });
+        
when(discoveryUpstreamMapper.selectByDiscoveryHandlerId("123")).thenAnswer(invocation
 -> Collections.singletonList(stored.get()));
+        DiscoveryUpstreamDTO dto = buildDiscoveryUpstreamDTO("123", "123", 
"localhost:8080");
+        dto.setProtocol("http://";);
+        dto.setProps(props);
+        discoveryUpstreamService.createOrUpdate(dto);
+        assertEquals(props, stored.get().getProps());
+        ArgumentCaptor<List<DiscoveryUpstreamDTO>> synced = 
ArgumentCaptor.forClass(List.class);
+        verify(discoveryProcessor).changeUpstream(any(), synced.capture());
+        assertEquals(stored.get().getProps(), 
synced.getValue().get(0).getProps());
+        
when(discoveryHandlerMapper.selectBySelectorId("selector_1")).thenReturn(buildDiscoveryHandlerDO());
+        assertEquals(stored.get().getProps(), 
discoveryUpstreamService.findBySelectorId("selector_1").get(0).getProps());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"labels\":{}}", 
"{\"labels\":{\"release\":\"canary\"}}", "{\"warmup\":20}", "{}", 
"{\"warmup\":20,\"extension\":{\"x\":1},\"labels\":{}}"})
+    public void testBatchUpdateReplacesPropsAndSynchronizesLabels(final String 
props) {
+        AtomicReference<DiscoveryUpstreamDO> stored = new 
AtomicReference<>(existingLabeledUpstream());
+        configureSync();
+        
when(discoveryUpstreamMapper.selectByDiscoveryHandlerId("123")).thenAnswer(invocation
 -> Collections.singletonList(stored.get()));
+        when(discoveryUpstreamMapper.insert(any())).thenAnswer(invocation -> {
+            stored.set(invocation.getArgument(0));
+            return 1;
+        });
+        DiscoveryUpstreamDTO dto = buildDiscoveryUpstreamDTO("", "123", 
"localhost:8080");
+        dto.setProtocol("http://";);
+        dto.setProps(props);
+        discoveryUpstreamService.updateBatch("123", 
Collections.singletonList(dto));
+        assertEquals(props, stored.get().getProps());
+        ArgumentCaptor<List<DiscoveryUpstreamDTO>> synced = 
ArgumentCaptor.forClass(List.class);
+        verify(discoveryProcessor).changeUpstream(any(), synced.capture());
+        assertEquals(stored.get().getProps(), 
synced.getValue().get(0).getProps());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void testBatchReplacementRemovesOmittedInstances(final boolean 
removeAll) {
+        List<DiscoveryUpstreamDO> stored = new ArrayList<>();
+        stored.add(existingLabeledUpstream());
+        stored.add(buildDiscoveryUpstreamDO("other", "123", "localhost:8081"));
+        configureSync();
+        doAnswer(invocation -> {
+            stored.clear();
+            return 2;
+        }).when(discoveryUpstreamMapper).deleteByDiscoveryHandlerId("123");
+        
when(discoveryUpstreamMapper.selectByDiscoveryHandlerId("123")).thenAnswer(invocation
 -> new ArrayList<>(stored));
+        List<DiscoveryUpstreamDTO> submitted = Collections.emptyList();
+        if (!removeAll) {
+            DiscoveryUpstreamDTO remaining = buildDiscoveryUpstreamDTO("", 
"123", "localhost:8080");
+            remaining.setProtocol("http://";);
+            remaining.setProps("{}");
+            submitted = Collections.singletonList(remaining);
+            when(discoveryUpstreamMapper.insert(any())).thenAnswer(invocation 
-> {
+                stored.add(invocation.getArgument(0));
+                return 1;
+            });
+        }
+        discoveryUpstreamService.updateBatch("123", submitted);
+        assertEquals(removeAll ? 0 : 1, stored.size());
+        ArgumentCaptor<List<DiscoveryUpstreamDTO>> synced = 
ArgumentCaptor.forClass(List.class);
+        verify(discoveryProcessor).changeUpstream(any(), synced.capture());
+        assertEquals(stored.size(), synced.getValue().size());
+        if (!removeAll) {
+            assertEquals("localhost:8080", synced.getValue().get(0).getUrl());
+            assertEquals("{}", synced.getValue().get(0).getProps());
+        } else {
+            verify(discoveryUpstreamMapper, never()).insert(any());
+        }
+    }
+
+    @Test
+    public void testNativeUpdateReplacesSubmittedProps() {
+        DiscoveryUpstreamDTO dto = buildDiscoveryUpstreamDTO("123");
+        dto.setProps("{\"warmup\":20}");
+        discoveryUpstreamService.nativeCreateOrUpdate(dto);
+        ArgumentCaptor<DiscoveryUpstreamDO> saved = 
ArgumentCaptor.forClass(DiscoveryUpstreamDO.class);
+        verify(discoveryUpstreamMapper).updateSelective(saved.capture());
+        assertEquals(dto.getProps(), saved.getValue().getProps());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"labels\":null}", "{\"labels\":[]}", 
"{\"labels\":{\"release\":123}}", "[]"})
+    public void testInvalidPropsNeverWriteOrSynchronize(final String props) {
+        DiscoveryUpstreamDTO dto = buildDiscoveryUpstreamDTO("");
+        dto.setProps(props);
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.createOrUpdate(dto));
+        DiscoveryUpstreamDTO valid = buildDiscoveryUpstreamDTO("");
+        valid.setProps("{\"labels\":{}}");
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.createOrUpdateBatch(Arrays.asList(valid, dto)));
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.updateBatch("123", Arrays.asList(valid, dto)));
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.importData(Arrays.asList(valid, dto)));
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.nativeCreateOrUpdate(dto));
+        dto.setId("123");
+        assertThrows(ShenyuAdminException.class, () -> 
discoveryUpstreamService.createOrUpdate(dto));
+        verify(discoveryUpstreamMapper, never()).insert(any());
+        verify(discoveryUpstreamMapper, never()).update(any());
+        verify(discoveryUpstreamMapper, never()).updateSelective(any());
+        verify(discoveryUpstreamMapper, 
never()).deleteByDiscoveryHandlerId(any());
+        verifyNoInteractions(discoveryProcessor, discoveryProcessorHolder);
+    }
+
+    private DiscoveryUpstreamDO existingLabeledUpstream() {
+        DiscoveryUpstreamDO upstream = buildDiscoveryUpstreamDO("123", "123", 
"localhost:8080");
+        upstream.setProtocol("http://";);
+        
upstream.setProps("{\"warmup\":10,\"gray\":true,\"healthCheckEnabled\":false,\"extension\":{\"x\":1},"
+                + "\"labels\":{\"release\":\"stable\",\"region\":\"east\"}}");
+        return upstream;
+    }
+
+    private void configureSync() {
+        
when(selectorMapper.selectByDiscoveryHandlerId(any())).thenReturn(buildSelectorDO());
+        
when(discoveryHandlerMapper.selectById(any())).thenReturn(buildDiscoveryHandlerDO());
+        when(pluginMapper.selectById(any())).thenReturn(buildPluginDO());
+        when(discoveryMapper.selectById(any())).thenReturn(buildDiscoveryDO());
+        
when(discoveryProcessorHolder.chooseProcessor(anyString())).thenReturn(discoveryProcessor);
+    }
+
     private void testUpdate() {
         when(discoveryUpstreamMapper.update(any())).thenReturn(1);
         DiscoveryUpstreamDTO discoveryUpstreamDTO = 
buildDiscoveryUpstreamDTO("123");
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ProxySelectorServiceTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ProxySelectorServiceTest.java
index 6f6e41f3da..144271c536 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ProxySelectorServiceTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ProxySelectorServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.shenyu.admin.service;
 
 import org.apache.shenyu.admin.discovery.DiscoveryProcessor;
 import org.apache.shenyu.admin.discovery.DiscoveryProcessorHolder;
+import org.apache.shenyu.admin.exception.ShenyuAdminException;
 import org.apache.shenyu.admin.listener.DataChangedEvent;
 import org.apache.shenyu.admin.exception.ValidFailException;
 import org.apache.shenyu.admin.mapper.DiscoveryHandlerMapper;
@@ -27,6 +28,7 @@ import org.apache.shenyu.admin.mapper.DiscoveryRelMapper;
 import org.apache.shenyu.admin.mapper.DiscoveryUpstreamMapper;
 import org.apache.shenyu.admin.mapper.ProxySelectorMapper;
 import org.apache.shenyu.admin.mapper.SelectorMapper;
+import org.apache.shenyu.admin.model.dto.DiscoveryUpstreamDTO;
 import org.apache.shenyu.admin.model.dto.ProxySelectorAddDTO;
 import org.apache.shenyu.admin.model.entity.DiscoveryDO;
 import org.apache.shenyu.admin.model.entity.DiscoveryHandlerDO;
@@ -43,9 +45,10 @@ import org.apache.shenyu.common.dto.ProxySelectorData;
 import org.apache.shenyu.common.enums.ConfigGroupEnum;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
@@ -60,6 +63,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static 
org.apache.shenyu.common.constant.Constants.SYS_DEFAULT_NAMESPACE_ID;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -72,10 +76,10 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.times;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import org.mockito.ArgumentCaptor;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
@@ -449,6 +453,67 @@ class ProxySelectorServiceTest {
         assertEquals(configImportResult.getSuccessCount(), 
proxySelectorDataList.size());
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"labels\":{}}", 
"{\"labels\":{\"release\":\"stable\"}}", "{\"warmup\":20}", "{}", 
"{\"warmup\":20,\"extension\":{\"x\":1},\"labels\":{}}"})
+    void testDiscoveryEditorReplacesPropsAndSynchronizesLabels(final String 
props) {
+        final ProxySelectorAddDTO dto = discoveryEditorRequest(props);
+        
when(discoveryRelMapper.selectByProxySelectorId("123")).thenReturn(buildDiscoveryRelDO());
+        DiscoveryUpstreamDO before = new DiscoveryUpstreamDO();
+        before.setProtocol("http://";);
+        before.setUpstreamUrl("localhost:8080");
+        
before.setProps("{\"warmup\":10,\"gray\":true,\"healthCheckEnabled\":false,\"extension\":{\"x\":1},"
+                + "\"labels\":{\"release\":\"canary\"}}");
+        AtomicReference<DiscoveryUpstreamDO> stored = new 
AtomicReference<>(before);
+        
when(discoveryUpstreamMapper.selectByDiscoveryHandlerId("123")).thenAnswer(invocation
 -> Collections.singletonList(stored.get()));
+        when(discoveryUpstreamMapper.insert(any())).thenAnswer(invocation -> {
+            stored.set(invocation.getArgument(0));
+            return 1;
+        });
+        DiscoveryHandlerDO handler = new DiscoveryHandlerDO();
+        handler.setId("123");
+        handler.setDiscoveryId("discovery");
+        when(discoveryHandlerMapper.selectById("123")).thenReturn(handler);
+        DiscoveryDO discovery = new DiscoveryDO();
+        discovery.setDiscoveryType("local");
+        when(discoveryMapper.selectById("discovery")).thenReturn(discovery);
+        DiscoveryProcessor processor = mock(DiscoveryProcessor.class);
+        
when(discoveryProcessorHolder.chooseProcessor("local")).thenReturn(processor);
+        proxySelectorService.update(dto);
+        assertEquals(props, stored.get().getProps());
+        ArgumentCaptor<List<DiscoveryUpstreamDTO>> synced = 
ArgumentCaptor.forClass(List.class);
+        verify(processor).changeUpstream(any(), synced.capture());
+        assertEquals(props, synced.getValue().get(0).getProps());
+    }
+
+    @Test
+    void testDiscoveryEditorRejectsInvalidLabelsBeforeWriting() {
+        final ProxySelectorAddDTO dto = 
discoveryEditorRequest("{\"labels\":null}");
+        
when(discoveryRelMapper.selectByProxySelectorId("123")).thenReturn(buildDiscoveryRelDO());
+        assertThrows(ShenyuAdminException.class, () -> 
proxySelectorService.update(dto));
+        assertThrows(ShenyuAdminException.class, () -> 
proxySelectorService.create(dto));
+        assertThrows(ShenyuAdminException.class, () -> 
proxySelectorService.bindingDiscoveryHandler(dto));
+        verify(proxySelectorMapper, never()).update(any());
+        verify(proxySelectorMapper, never()).insert(any());
+        verify(discoveryUpstreamMapper, 
never()).deleteByDiscoveryHandlerId(any());
+        verify(discoveryUpstreamMapper, never()).insert(any());
+        verifyNoInteractions(discoveryHandlerMapper, discoveryMapper, 
discoveryProcessorHolder);
+    }
+
+    private ProxySelectorAddDTO discoveryEditorRequest(final String props) {
+        ProxySelectorAddDTO dto = new ProxySelectorAddDTO();
+        dto.setId("123");
+        dto.setName("test");
+        dto.setDiscovery(new ProxySelectorAddDTO.Discovery());
+        ProxySelectorAddDTO.DiscoveryUpstream upstream = new 
ProxySelectorAddDTO.DiscoveryUpstream();
+        upstream.setProtocol("http://";);
+        upstream.setUrl("localhost:8080");
+        upstream.setProps(props);
+        upstream.setStatus(0);
+        upstream.setWeight(50);
+        dto.setDiscoveryUpstreams(Collections.singletonList(upstream));
+        return dto;
+    }
+
     private ProxySelectorDO buildProxySelectorDO() {
         ProxySelectorDO proxySelectorDO = new ProxySelectorDO();
         proxySelectorDO.setId("123");
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/transfer/DiscoveryTransferTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/transfer/DiscoveryTransferTest.java
index e3b809cb1f..af0f383ebd 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/transfer/DiscoveryTransferTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/transfer/DiscoveryTransferTest.java
@@ -20,16 +20,53 @@ package org.apache.shenyu.admin.transfer;
 import org.apache.shenyu.admin.model.dto.DiscoveryDTO;
 import org.apache.shenyu.admin.model.entity.DiscoveryDO;
 import org.apache.shenyu.admin.model.vo.DiscoveryVO;
+import org.apache.shenyu.common.dto.DiscoveryUpstreamData;
+import org.apache.shenyu.common.dto.convert.selector.CommonUpstream;
 import org.junit.jupiter.api.Test;
 
+import java.sql.Timestamp;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * Test cases for DiscoveryTransfer.
+ * Test cases for discovery namespace mapping and upstream properties.
  */
 public final class DiscoveryTransferTest {
 
+    @Test
+    void shouldAcceptNestedLabelsWithoutChangingProps() {
+        DiscoveryUpstreamData data = 
upstream("{\"labels\":{\"release\":\"canary\"},\"custom\":{\"tls\":true},\"warmupTime\":10,\"gray\":\"false\"}");
+        String original = data.getProps();
+        CommonUpstream result = 
DiscoveryTransfer.INSTANCE.mapToCommonUpstream(data);
+        assertTrue(result.isHealthCheckEnabled());
+        assertEquals("127.0.0.1:18882", result.getUpstreamUrl());
+        assertEquals(original, data.getProps());
+    }
+
+    @Test
+    void shouldPreserveBooleanAndStringHealthCheckSettings() {
+        for (String value : new String[]{"false", "\"false\""}) {
+            
assertFalse(DiscoveryTransfer.INSTANCE.mapToCommonUpstream(upstream(
+                    
"{\"labels\":{\"release\":\"canary\"},\"healthCheckEnabled\":" + value + 
"}")).isHealthCheckEnabled());
+        }
+        for (String value : new String[]{"true", "\"true\""}) {
+            assertTrue(DiscoveryTransfer.INSTANCE.mapToCommonUpstream(upstream(
+                    "{\"healthCheckEnabled\":" + value + 
"}")).isHealthCheckEnabled());
+        }
+    }
+
+    @Test
+    void shouldKeepLegacyDefaults() {
+        for (String props : new String[]{null, "{}", "null", 
"{\"labels\":{}}", "{\"healthCheckEnabled\":null}"}) {
+            
assertTrue(DiscoveryTransfer.INSTANCE.mapToCommonUpstream(upstream(props)).isHealthCheckEnabled());
+        }
+        assertNull(DiscoveryTransfer.INSTANCE.mapToCommonUpstream(null));
+    }
+
     @Test
     public void testMapToDTOKeepsNamespaceId() {
         DiscoveryDO discoveryDO = new DiscoveryDO();
@@ -54,8 +91,18 @@ public final class DiscoveryTransferTest {
 
         DiscoveryDTO discoveryDTO = 
DiscoveryTransfer.INSTANCE.mapToDTO(discoveryDO);
 
-        assertEquals(null, discoveryDTO.getNamespaceId());
+        assertNull(discoveryDTO.getNamespaceId());
     }
+
+    private DiscoveryUpstreamData upstream(final String props) {
+        DiscoveryUpstreamData data = new DiscoveryUpstreamData();
+        data.setProtocol("http://";);
+        data.setUrl("127.0.0.1:18882");
+        data.setDateCreated(new Timestamp(0));
+        data.setProps(props);
+        return data;
+    }
+
     @Test
     public void testMapToVoKeepsDiscoveryNameAndTypeSeparate() {
         DiscoveryDO discoveryDO = DiscoveryDO.builder()
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidatorTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidatorTest.java
new file mode 100644
index 0000000000..75ef1ec4a9
--- /dev/null
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/DiscoveryUpstreamPropsValidatorTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.admin.utils;
+
+import org.apache.shenyu.admin.exception.ShenyuAdminException;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests submitted property validation, including omitted and explicitly empty 
labels.
+ */
+class DiscoveryUpstreamPropsValidatorTest {
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"{}", "{\"labels\":{}}", 
"{\"labels\":{\"release\":\"canary\"}}", "{\"warmup\":20}",
+        "{\"extension\":{\"nested\":[1,true,null]}}"})
+    void testValidProps(final String props) {
+        assertDoesNotThrow(() -> 
DiscoveryUpstreamPropsValidator.validate(props));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"null", "[]", "broken", "{\"labels\":null}", 
"{\"labels\":[]}",
+        "{\"labels\":{\"release\":1}}", "{\"labels\":{\"release\":true}}", 
"{\"labels\":{\"release\":null}}",
+        "{\"labels\":{\" \" :\"stable\"}}"})
+    void testInvalidPropsAreRejected(final String props) {
+        assertThrows(ShenyuAdminException.class, () -> 
DiscoveryUpstreamPropsValidator.validate(props));
+    }
+}
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/utils/UpstreamLabelsUtils.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/utils/UpstreamLabelsUtils.java
new file mode 100644
index 0000000000..38eb0bb490
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/utils/UpstreamLabelsUtils.java
@@ -0,0 +1,66 @@
+/*
+ * 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.common.utils;
+
+import com.google.gson.JsonElement;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Shared validation of upstream labels in discovery properties.
+ */
+public final class UpstreamLabelsUtils {
+
+    private UpstreamLabelsUtils() {
+    }
+
+    /**
+     * Parse labels without coercing JSON numbers or booleans to strings.
+     *
+     * @param labels the labels object, or {@code null} when the property is 
omitted
+     * @return an immutable map; omitted or empty labels clear existing labels
+     * @throws IllegalArgumentException if labels are not an object of 
nonblank strings
+     */
+    public static Map<String, String> parse(final JsonElement labels) {
+        if (Objects.isNull(labels)) {
+            return Collections.emptyMap();
+        }
+        if (!labels.isJsonObject()) {
+            throw new IllegalArgumentException("props.labels must be an 
object");
+        }
+        Map<String, String> result = new HashMap<>();
+        for (Map.Entry<String, JsonElement> entry : 
labels.getAsJsonObject().entrySet()) {
+            if (StringUtils.isBlank(entry.getKey())) {
+                throw new IllegalArgumentException("props.labels keys must not 
be blank");
+            }
+            JsonElement value = entry.getValue();
+            if (!value.isJsonPrimitive() || 
!value.getAsJsonPrimitive().isString()) {
+                throw new IllegalArgumentException("props.labels." + 
entry.getKey() + " must be a string");
+            }
+            if (StringUtils.isBlank(value.getAsString())) {
+                throw new IllegalArgumentException("props.labels." + 
entry.getKey() + " must not be blank");
+            }
+            result.put(entry.getKey(), value.getAsString());
+        }
+        return Collections.unmodifiableMap(result);
+    }
+}
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/utils/UpstreamLabelsUtilsTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/utils/UpstreamLabelsUtilsTest.java
new file mode 100644
index 0000000000..68cdc5c27c
--- /dev/null
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/utils/UpstreamLabelsUtilsTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.common.utils;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests strict label types and immutable snapshots shared by Admin and 
gateways.
+ */
+class UpstreamLabelsUtilsTest {
+
+    @Test
+    void testOmittedAndEmptyLabelsClearExistingValues() {
+        assertTrue(UpstreamLabelsUtils.parse(null).isEmpty());
+        assertTrue(UpstreamLabelsUtils.parse(new JsonObject()).isEmpty());
+    }
+
+    @Test
+    void testParsingPreservesStringsAndReturnsIndependentSnapshot() {
+        JsonObject input = new JsonObject();
+        input.addProperty("release", "stable");
+        input.addProperty("region", "上海");
+        Map<String, String> labels = UpstreamLabelsUtils.parse(input);
+        input.addProperty("release", "next");
+        assertEquals(Map.of("release", "stable", "region", "上海"), labels);
+        assertThrows(UnsupportedOperationException.class, () -> 
labels.put("release", "next"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"null", "[]", "1", "true", "\"stable\"", 
"{\"release\":1}", "{\"release\":true}",
+        "{\"release\":null}", "{\"release\":{}}", "{\"release\":[]}", 
"{\"\":\"stable\"}",
+        "{\" \":\"stable\"}", "{\"release\":\"\"}", "{\"release\":\" \"}"})
+    void testInvalidLabelsAreRejectedWithoutCoercion(final String input) {
+        JsonElement labels = GsonUtils.getInstance().fromJson(input, 
JsonElement.class);
+        assertThrows(IllegalArgumentException.class, () -> 
UpstreamLabelsUtils.parse(labels));
+    }
+}
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 d6e430adb3..012aa81e36 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
@@ -28,6 +28,8 @@ import org.apache.shenyu.loadbalancer.entity.Upstream;
 import org.apache.shenyu.loadbalancer.entity.UpstreamIdentity;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -163,6 +165,7 @@ public final class UpstreamCacheManager {
         List<Upstream> offlineUpstreamList = partitionedUpstreams.get(false);
         List<Upstream> existUpstreamList = 
MapUtils.computeIfAbsent(UPSTREAM_MAP, selectorId, k -> Lists.newArrayList());
 
+        updateUpstreamMetadata(selectorId, actualUpstreamList, 
existUpstreamList);
         processOfflineUpstreams(selectorId, offlineUpstreamList, 
existUpstreamList);
         processValidUpstreams(selectorId, validUpstreamList, 
existUpstreamList);
 
@@ -170,6 +173,18 @@ public final class UpstreamCacheManager {
         UPSTREAM_MAP.put(selectorId, Objects.isNull(healthyUpstreamList) ? 
Lists.newArrayList() : healthyUpstreamList);
     }
 
+    private void updateUpstreamMetadata(final String selectorId, final 
List<Upstream> upstreams, final List<Upstream> existingHealthy) {
+        Map<UpstreamIdentity, Upstream> existing = new 
HashMap<>(getCurrentUnhealthyMap(selectorId));
+        existingHealthy.forEach(upstream -> existing.put(upstream.identity(), 
upstream));
+        upstreams.forEach(upstream -> {
+            Upstream cached = existing.get(upstream.identity());
+            if (Objects.nonNull(cached)) {
+                cached.setMetadata(Collections.unmodifiableMap(new 
HashMap<>(upstream.getMetadata())));
+                cached.setLabels(upstream.getLabels());
+            }
+        });
+    }
+
     private void initializeUpstreamHealthStatus(final List<Upstream> 
upstreamList) {
         upstreamList.forEach(upstream -> {
             if (!upstream.isHealthCheckEnabled()) {
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 c458bef4df..1a6d868d1f 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
@@ -19,6 +19,7 @@ package org.apache.shenyu.loadbalancer.entity;
 
 import org.apache.commons.lang3.StringUtils;
 
+import java.util.Collections;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -124,7 +125,9 @@ public final class Upstream {
      */
     private boolean healthCheckEnabled = true;
     
-    private Map<String, String> metadata = new ConcurrentHashMap<>();
+    private volatile Map<String, String> labels = Collections.emptyMap();
+
+    private volatile Map<String, String> metadata = new ConcurrentHashMap<>();
     
     
     /**
@@ -150,6 +153,24 @@ public final class Upstream {
         this.healthCheckEnabled = builder.healthCheckEnabled;
     }
     
+    /**
+     * Gets upstream labels independently of scalar metadata.
+     *
+     * @return immutable upstream labels
+     */
+    public Map<String, String> getLabels() {
+        return labels;
+    }
+
+    /**
+     * Sets an immutable snapshot of upstream labels.
+     *
+     * @param labels upstream labels
+     */
+    public void setLabels(final Map<String, String> labels) {
+        this.labels = Map.copyOf(labels);
+    }
+
     /**
      * Gets protocol.
      *
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 21a47d9ff6..3c32f29f32 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
@@ -30,6 +30,7 @@ import org.junit.jupiter.params.provider.CsvSource;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 
 /**
@@ -403,6 +404,10 @@ public class UpstreamCacheManagerTest {
                     
.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();
+            updatedFirst.setLabels(Map.of("release", "canary"));
+            updatedFirst.setMetadata(Map.of("region", "east"));
+            updatedSecond.setLabels(Map.of("release", "stable"));
+            updatedSecond.setMetadata(Map.of("region", "west"));
             manager.submit(selectorId, List.of(updatedFirst, updatedSecond));
             List<Upstream> updated = 
manager.findUpstreamListBySelectorId(selectorId);
             Assertions.assertNotNull(updated);
@@ -411,6 +416,10 @@ public class UpstreamCacheManagerTest {
             Assertions.assertSame(second, updated.get(1));
             Assertions.assertEquals(30, first.getWeight());
             Assertions.assertEquals(40, second.getWeight());
+            Assertions.assertEquals(Map.of("release", "canary"), 
first.getLabels());
+            Assertions.assertEquals(Map.of("region", "east"), 
first.getMetadata());
+            Assertions.assertEquals(Map.of("release", "stable"), 
second.getLabels());
+            Assertions.assertEquals(Map.of("region", "west"), 
second.getMetadata());
 
             final Upstream offlineFirst = 
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880")
                     
.group("blue").version("v1").status(false).healthCheckEnabled(true).build();
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 3036803455..b2517ac0f8 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
@@ -37,6 +37,23 @@ import java.util.concurrent.TimeUnit;
  */
 public class UpstreamTest {
 
+    @Test
+    public void 
testLabelsAreImmutableSnapshotsIndependentOfIdentityAndMetadata() {
+        Upstream upstream = Upstream.builder().url("backend:8080").build();
+        final UpstreamIdentity identity = upstream.identity();
+        Map<String, String> labels = new HashMap<>();
+        labels.put("release", "stable");
+        upstream.setLabels(labels);
+        upstream.setMetadata(Map.of("release", "metadata-value"));
+        labels.put("release", "next");
+        Assertions.assertEquals(Map.of("release", "stable"), 
upstream.getLabels());
+        Assertions.assertEquals("metadata-value", 
upstream.getMetadata().get("release"));
+        Assertions.assertSame(identity, upstream.identity());
+        Assertions.assertThrows(UnsupportedOperationException.class, () -> 
upstream.getLabels().put("release", "next"));
+        upstream.setLabels(Map.of());
+        Assertions.assertTrue(upstream.getLabels().isEmpty());
+    }
+
     @Test
     public void selectorKeyIsReusedAndInvalidatedByItsActualIdentity() {
         Upstream upstream = Upstream.builder().protocol(" http ").url(" 
host:80 ").group("blue").version("v1").build();
diff --git 
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamProps.java
 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamProps.java
index 4161bcc8ed..115ba9da88 100644
--- 
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamProps.java
+++ 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamProps.java
@@ -17,7 +17,10 @@
 
 package org.apache.shenyu.plugin.base.utils;
 
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
 import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.common.utils.UpstreamLabelsUtils;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -45,8 +48,11 @@ public final class UpstreamProps {
 
     private final Map<String, String> props;
 
-    private UpstreamProps(final Map<String, String> props) {
+    private final Map<String, String> labels;
+
+    private UpstreamProps(final Map<String, String> props, final Map<String, 
String> labels) {
         this.props = props;
+        this.labels = labels;
     }
 
     /**
@@ -56,9 +62,25 @@ public final class UpstreamProps {
      * @return the parsed props
      */
     public static UpstreamProps parse(final String props) {
-        return new UpstreamProps(Optional.ofNullable(props)
-                .map(ps -> GsonUtils.getInstance().toObjectMap(ps, 
String.class))
-                .orElseGet(HashMap::new));
+        JsonObject properties = Optional.ofNullable(props)
+                .map(ps -> GsonUtils.getInstance().fromJson(ps, 
JsonElement.class))
+                .filter(value -> !value.isJsonNull())
+                .map(JsonElement::getAsJsonObject)
+                .orElseGet(JsonObject::new);
+        Map<String, String> labels = 
UpstreamLabelsUtils.parse(properties.get("labels"));
+        Map<String, String> metadata = new HashMap<>();
+        properties.entrySet().stream().filter(entry -> 
entry.getValue().isJsonPrimitive() || entry.getValue().isJsonNull())
+                .forEach(entry -> metadata.put(entry.getKey(), 
entry.getValue().isJsonNull() ? null : entry.getValue().getAsString()));
+        return new UpstreamProps(metadata, labels);
+    }
+
+    /**
+     * Get labels separately from scalar upstream metadata.
+     *
+     * @return immutable upstream labels
+     */
+    public Map<String, String> getLabels() {
+        return labels;
     }
 
     /**
@@ -89,7 +111,7 @@ public final class UpstreamProps {
     }
 
     /**
-     * Get all parsed props as a mutable map.
+     * Get scalar properties as a mutable map, excluding nested objects such 
as labels.
      *
      * @return a copy of the parsed props
      */
diff --git 
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/UpstreamPropsTest.java
 
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/UpstreamPropsTest.java
index 4331e29947..89ac7255e2 100644
--- 
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/UpstreamPropsTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/UpstreamPropsTest.java
@@ -18,11 +18,15 @@
 package org.apache.shenyu.plugin.base.utils;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -60,4 +64,40 @@ class UpstreamPropsTest {
         map.put("az", "az2");
         assertEquals("az1", props.toMap().get("az"));
     }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        
"{\"warmup\":\"20\",\"gray\":\"true\",\"healthCheckEnabled\":\"false\",\"labels\":{\"release\":\"stable\"}}",
+        
"{\"warmup\":20,\"gray\":true,\"healthCheckEnabled\":false,\"labels\":{\"release\":\"stable\"}}"
+    })
+    void testLabelsRemainSeparateFromScalarProperties(final String input) {
+        UpstreamProps props = UpstreamProps.parse(input);
+        assertEquals(20, props.getWarmup());
+        assertTrue(props.isGray());
+        assertFalse(props.isHealthCheckEnabled());
+        assertEquals(Map.of("release", "stable"), props.getLabels());
+        assertEquals(Map.of("warmup", "20", "gray", "true", 
"healthCheckEnabled", "false"), props.toMap());
+        assertThrows(UnsupportedOperationException.class, () -> 
props.getLabels().put("release", "next"));
+    }
+
+    @Test
+    void testNestedExtensionPropertiesDoNotBreakScalarParsing() {
+        UpstreamProps props = 
UpstreamProps.parse("{\"az\":\"az1\",\"custom\":{\"tls\":true},\"items\":[1],\"optional\":null}");
+        assertEquals("az1", props.toMap().get("az"));
+        assertNull(props.toMap().get("optional"));
+        assertTrue(props.toMap().containsKey("optional"));
+        assertFalse(props.toMap().containsKey("custom"));
+        assertFalse(props.toMap().containsKey("items"));
+        assertTrue(props.getLabels().isEmpty());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{}", "null", "{\"labels\":{}}", 
"{\"healthCheckEnabled\":null}"})
+    void testEmptyPropertiesRetainDefaults(final String input) {
+        UpstreamProps props = UpstreamProps.parse(input);
+        assertEquals(10, props.getWarmup());
+        assertFalse(props.isGray());
+        assertTrue(props.isHealthCheckEnabled());
+        assertTrue(props.getLabels().isEmpty());
+    }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
index 1413785cfc..70f80a3121 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
@@ -62,6 +62,7 @@ public class DivideUpstreamDataHandler extends 
AbstractDiscoveryUpstreamDataHand
                     
.timestamp(Optional.ofNullable(u.getDateCreated()).map(Timestamp::getTime).orElse(System.currentTimeMillis()))
                     .build();
             upstream.setMetadata(metadata);
+            upstream.setLabels(props.getLabels());
             return upstream;
         }).collect(Collectors.toList());
     }
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
index a4e39078a0..7e33f12756 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
@@ -24,23 +24,32 @@ import org.apache.shenyu.common.enums.PluginEnum;
 import org.apache.shenyu.common.utils.GsonUtils;
 import org.apache.shenyu.common.utils.UpstreamCheckUtils;
 import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCheckTask;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.MockedStatic;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Map;
 import java.util.Properties;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -143,4 +152,133 @@ public class DivideUpstreamDataHandlerTest {
     public void pluginNamedTest() {
         assertEquals(divideUpstreamDataHandler.pluginName(), 
PluginEnum.DIVIDE.getName());
     }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        
"{\"warmup\":\"12\",\"gray\":\"true\",\"healthCheckEnabled\":\"false\",\"labels\":{\"release\":\"canary\"}}",
+        
"{\"warmup\":12,\"gray\":true,\"healthCheckEnabled\":false,\"labels\":{\"release\":\"canary\"}}"
+    })
+    public void testScalarCompatibilityRetainsLegacyGraySelection(final String 
props) {
+        publish(List.of(instance("canary:8080", props), 
instance("stable:8080", "{\"labels\":{\"release\":\"stable\"}}")));
+        List<Upstream> upstreams = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler");
+        assertEquals(1, upstreams.size());
+        Upstream canary = upstreams.get(0);
+        assertEquals(12, canary.getWarmup());
+        assertTrue(canary.isGray());
+        assertFalse(canary.isHealthCheckEnabled());
+        assertEquals(Map.of("release", "canary"), canary.getLabels());
+    }
+
+    @Test
+    public void testRoutingLabelsDoNotOverwriteFlatMetadata() {
+        publish(List.of(instance("backend:8080", 
"{\"release\":\"metadata-value\",\"labels\":{\"release\":\"canary\"},\"custom\":{\"nested\":true}}")));
+        Upstream original = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        assertEquals("metadata-value", original.getMetadata().get("release"));
+        assertEquals(Map.of("release", "canary"), original.getLabels());
+        publish(List.of(instance("backend:8080", 
"{\"release\":\"new-metadata\"}")));
+        Upstream updated = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        assertSame(original, updated);
+        assertTrue(updated.getLabels().isEmpty());
+        assertEquals("new-metadata", updated.getMetadata().get("release"));
+    }
+
+    @Test
+    public void testLabelUpdatesAndDeletionRetainStatistics() {
+        publish(List.of(instance("backend:8080", 
"{\"labels\":{\"release\":\"canary\",\"region\":\"shanghai\"}}")));
+        Upstream original = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        original.getSucceeded().set(17);
+        original.setLag(42);
+        publish(List.of(instance("backend:8080", 
"{\"labels\":{\"release\":\"stable\"}}")));
+        Upstream updated = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        assertSame(original, updated);
+        assertEquals(17, updated.getSucceeded().get());
+        assertEquals(42, updated.getLag());
+        assertFalse(updated.isGray());
+        assertEquals(Map.of("release", "stable"), updated.getLabels());
+        publish(List.of(instance("backend:8080", "{\"labels\":{}}")));
+        assertTrue(original.getLabels().isEmpty());
+        publish(List.of(instance("backend:8080", 
"{\"labels\":{\"release\":\"canary\"}}")));
+        publish(List.of(instance("backend:8080", "{}")));
+        assertTrue(original.getLabels().isEmpty());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"[]", "{\"labels\":[]}", "{\"labels\":null}", 
"{\"labels\":{\"release\":1}}",
+        "{\"labels\":{\"release\":true}}", "{\"labels\":{\"release\":null}}", 
"{\"labels\":{\"release\":{}}}",
+        "{\"labels\":{\"release\":\" \"}}", "{\"warmup\":1.5}"})
+    public void testInvalidUpdateDoesNotPublishPartialState(final String 
invalidProps) {
+        publish(List.of(instance("backend:8080", 
"{\"gray\":true,\"labels\":{\"release\":\"canary\"}}")));
+        final List<Upstream> before = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler");
+        assertThrows(RuntimeException.class, () -> 
publish(List.of(instance("backend:8080", "{}"), instance("bad:8080", 
invalidProps))));
+        List<Upstream> after = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler");
+        assertEquals(before, after);
+        assertSame(before.get(0), after.get(0));
+        assertEquals(1, before.size());
+        assertTrue(before.get(0).isGray());
+        assertEquals(Map.of("release", "canary"), before.get(0).getLabels());
+    }
+
+    @Test
+    public void testMissingPropsKeepLegacyDefaults() {
+        publish(List.of(instance("backend:8080", null)));
+        Upstream upstream = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        assertEquals(10, upstream.getWarmup());
+        assertFalse(upstream.isGray());
+        assertTrue(upstream.isHealthCheckEnabled());
+        assertTrue(upstream.getLabels().isEmpty());
+    }
+
+    @Test
+    public void testLabelsDoNotFilterInstancesWithoutLegacyGray() {
+        publish(List.of(instance("canary:8080", 
"{\"labels\":{\"release\":\"canary\"}}"),
+                instance("stable:8080", 
"{\"labels\":{\"release\":\"stable\"}}")));
+        List<Upstream> upstreams = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler");
+        assertEquals(2, upstreams.size());
+        assertEquals(Map.of("release", "canary"), 
upstreams.get(0).getLabels());
+        assertEquals(Map.of("release", "stable"), 
upstreams.get(1).getLabels());
+    }
+
+    @Test
+    public void testSerializedDiscoveryUpdatePreservesLabelsAndNestedProps() {
+        final String props = 
"{\"az\":\"east\",\"custom\":{\"tls\":true},\"labels\":{\"release\":\"stable\"}}";
+        DiscoverySyncData payload = new DiscoverySyncData();
+        payload.setSelectorId("handler");
+        payload.setUpstreamDataList(List.of(instance("backend:8080", props)));
+        DiscoverySyncData received = 
GsonUtils.getInstance().fromJson(GsonUtils.getInstance().toJson(payload), 
DiscoverySyncData.class);
+        divideUpstreamDataHandler.handlerDiscoveryUpstreamData(received);
+        Upstream upstream = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        assertEquals(props, received.getUpstreamDataList().get(0).getProps());
+        assertEquals(Map.of("release", "stable"), upstream.getLabels());
+        assertEquals("east", upstream.getMetadata().get("az"));
+        assertFalse(upstream.getMetadata().containsKey("labels"));
+        assertFalse(upstream.getMetadata().containsKey("custom"));
+    }
+
+    @Test
+    public void testLabelDeletionAlsoUpdatesUnhealthyInstance() {
+        publish(List.of(instance("backend:8080", 
"{\"labels\":{\"release\":\"canary\"}}")));
+        final Upstream original = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId("handler").get(0);
+        final UpstreamCheckTask task = (UpstreamCheckTask) 
ReflectionTestUtils.getField(UpstreamCacheManager.getInstance(), "task");
+        original.setHealthy(false);
+        original.setLastUnhealthyTimestamp(123);
+        original.getSucceeded().set(17);
+        task.putToMap(task.getUnhealthyUpstream(), "handler", original);
+        task.removeFromMap(task.getHealthyUpstream(), "handler", original);
+        publish(List.of(instance("backend:8080", "{}")));
+        assertSame(original, 
task.getUnhealthyUpstream().get("handler").get(0));
+        assertTrue(original.getLabels().isEmpty());
+        assertFalse(original.isHealthy());
+        assertEquals(123, original.getLastUnhealthyTimestamp());
+        assertEquals(17, original.getSucceeded().get());
+    }
+
+    private DiscoveryUpstreamData instance(final String url, final String 
props) {
+        return 
DiscoveryUpstreamData.builder().protocol("http://";).url(url).status(0).props(props).build();
+    }
+
+    private void publish(final List<DiscoveryUpstreamData> upstreams) {
+        when(discoverySyncData.getUpstreamDataList()).thenReturn(upstreams);
+        
divideUpstreamDataHandler.handlerDiscoveryUpstreamData(discoverySyncData);
+    }
+
 }

Reply via email to