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);
+ }
+
}