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 a599e7b8a1 feature(plugin-divide): add validated label-based canary
routing. (#7963)
a599e7b8a1 is described below
commit a599e7b8a13132f1a703349f286cfe5b54a14d2a
Author: SouthwestAsiaFloat <[email protected]>
AuthorDate: Fri Oct 23 11:22:44 2026 +0800
feature(plugin-divide): add validated label-based canary routing. (#7963)
---
.../shenyu/admin/service/impl/RuleServiceImpl.java | 48 +++-
.../admin/service/ConfigsImportDuplicateTest.java | 3 +-
.../shenyu/admin/service/ConfigsServiceTest.java | 3 +-
.../shenyu/admin/service/RuleServiceTest.java | 159 ++++++++++-
shenyu-common/pom.xml | 5 +
.../dto/convert/rule/canary/CanaryConfig.java | 265 +++++++++++++++++
.../convert/rule/canary/CanaryConfigValidator.java | 208 ++++++++++++++
.../dto/convert/rule/canary/StickyKeyConfig.java | 98 +++++++
.../dto/convert/rule/impl/DivideRuleHandle.java | 32 ++-
.../rule/canary/CanaryConfigValidatorTest.java | 142 +++++++++
.../convert/rule/impl/DivideRuleHandleTest.java | 44 +++
.../loadbalancer/cache/UpstreamCacheManager.java | 21 ++
.../shenyu/loadbalancer/entity/Upstream.java | 2 +-
.../plugin/base/utils/UpstreamLabelUtils.java | 54 ++++
.../apache/shenyu/plugin/divide/DividePlugin.java | 43 ++-
.../plugin/divide/canary/CanaryDecision.java | 49 ++++
.../divide/canary/CanaryDecisionService.java | 37 +++
.../canary/DefaultCanaryDecisionService.java | 102 +++++++
.../divide/handler/DividePluginDataHandler.java | 10 +-
.../divide/handler/DivideUpstreamDataHandler.java | 12 +
.../plugin/divide/DivideCanaryRoutingTest.java | 317 +++++++++++++++++++++
.../canary/DefaultCanaryDecisionServiceTest.java | 248 ++++++++++++++++
.../canary/fixture/AttributeParameterData.java | 35 +++
.../handler/DividePluginDataHandlerTest.java | 84 ++++++
.../handler/DivideUpstreamDataHandlerTest.java | 36 +++
...shenyu.plugin.base.condition.data.ParameterData | 18 ++
.../plugin/divide/DividePluginConfiguration.java | 19 +-
.../divide/DividePluginConfigurationTest.java | 19 ++
28 files changed, 2100 insertions(+), 13 deletions(-)
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
index a7e2838b4e..8dc4524ffe 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
@@ -30,12 +30,14 @@ import org.apache.shenyu.admin.mapper.PluginMapper;
import org.apache.shenyu.admin.mapper.RuleConditionMapper;
import org.apache.shenyu.admin.mapper.RuleMapper;
import org.apache.shenyu.admin.mapper.SelectorMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
import org.apache.shenyu.admin.model.dto.RuleConditionDTO;
import org.apache.shenyu.admin.model.dto.RuleDTO;
import org.apache.shenyu.admin.model.entity.PluginDO;
import org.apache.shenyu.admin.model.entity.RuleConditionDO;
import org.apache.shenyu.admin.model.entity.RuleDO;
import org.apache.shenyu.admin.model.entity.SelectorDO;
+import org.apache.shenyu.admin.model.entity.ShenyuDictDO;
import org.apache.shenyu.admin.model.event.rule.RuleCreatedEvent;
import org.apache.shenyu.admin.model.event.selector.BatchSelectorDeletedEvent;
import org.apache.shenyu.admin.model.page.CommonPager;
@@ -58,6 +60,8 @@ import
org.apache.shenyu.admin.validation.validator.RewriteRuleHandleValidator;
import org.apache.shenyu.common.constant.AdminConstants;
import org.apache.shenyu.common.dto.ConditionData;
import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfigValidator;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
import org.apache.shenyu.common.enums.MatchModeEnum;
import org.apache.shenyu.common.enums.PluginEnum;
import org.apache.shenyu.common.utils.JsonUtils;
@@ -97,16 +101,20 @@ public class RuleServiceImpl implements RuleService {
private final RuleEventPublisher ruleEventPublisher;
+ private final ShenyuDictMapper shenyuDictMapper;
+
public RuleServiceImpl(final RuleMapper ruleMapper,
final RuleConditionMapper ruleConditionMapper,
final SelectorMapper selectorMapper,
final PluginMapper pluginMapper,
- final RuleEventPublisher ruleEventPublisher) {
+ final RuleEventPublisher ruleEventPublisher,
+ final ShenyuDictMapper shenyuDictMapper) {
this.ruleMapper = ruleMapper;
this.ruleConditionMapper = ruleConditionMapper;
this.selectorMapper = selectorMapper;
this.pluginMapper = pluginMapper;
this.ruleEventPublisher = ruleEventPublisher;
+ this.shenyuDictMapper = shenyuDictMapper;
}
@Override
@@ -157,6 +165,7 @@ public class RuleServiceImpl implements RuleService {
return "";
}
validateRuleHandle(ruleDTO);
+ validateCanaryConfig(ruleDTO.getSelectorId(), ruleDTO.getHandle());
RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
if (StringUtils.isEmpty(ruleDTO.getId())) {
ruleMapper.insertSelective(ruleDO);
@@ -182,6 +191,7 @@ public class RuleServiceImpl implements RuleService {
@Override
public int create(final RuleDTO ruleDTO) {
validateRuleHandle(ruleDTO);
+ validateCanaryConfig(ruleDTO.getSelectorId(), ruleDTO.getHandle());
RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
final int ruleCount = ruleMapper.insertSelective(ruleDO);
addCondition(ruleDO, ruleDTO.getRuleConditions());
@@ -196,6 +206,7 @@ public class RuleServiceImpl implements RuleService {
validateRuleHandle(ruleDTO);
final RuleDO before = ruleMapper.selectById(ruleDTO.getId());
Assert.notNull(before, "the updated rule is not found");
+
validateCanaryConfig(StringUtils.defaultIfBlank(ruleDTO.getSelectorId(),
before.getSelectorId()), ruleDTO.getHandle());
RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
final int ruleCount = ruleMapper.updateSelective(ruleDO);
@@ -231,6 +242,38 @@ public class RuleServiceImpl implements RuleService {
return ruleCount;
}
+ private void validateCanaryConfig(final String selectorId, final String
handle) {
+ if (StringUtils.isBlank(handle)) {
+ return;
+ }
+ SelectorDO selector = selectorMapper.selectById(selectorId);
+ if (Objects.isNull(selector)) {
+ return;
+ }
+ PluginDO plugin = pluginMapper.selectById(selector.getPluginId());
+ if (Objects.isNull(plugin) ||
!PluginEnum.DIVIDE.getName().equals(plugin.getName())) {
+ return;
+ }
+ try {
+ DivideRuleHandle divide =
CanaryConfigValidator.parseHandle(handle);
+ if (Objects.nonNull(divide) &&
Objects.nonNull(divide.getCanary())) {
+ CanaryConfigValidator.validateExtensions(divide.getCanary(),
+ name -> validateCanaryDictionary("paramType", name),
name -> validateCanaryDictionary("operator", name));
+ }
+ } catch (IllegalArgumentException ex) {
+ throw new ShenyuAdminException(ex.getMessage(), ex);
+ }
+ }
+
+ private void validateCanaryDictionary(final String type, final String
name) {
+ boolean supported = shenyuDictMapper.findByType(type).stream()
+ .filter(dict -> Boolean.TRUE.equals(dict.getEnabled()))
+ .map(ShenyuDictDO::getDictValue).anyMatch(name::equals);
+ if (!supported) {
+ throw new IllegalArgumentException("Unsupported Canary " + type +
": " + name);
+ }
+ }
+
/**
* find rule by id and namespaceId.
*
@@ -303,6 +346,7 @@ public class RuleServiceImpl implements RuleService {
return ConfigImportResult.success();
}
+ ruleList.forEach(rule -> validateCanaryConfig(rule.getSelectorId(),
rule.getHandle()));
Map<String, List<RuleDO>> selectorRuleMap = ruleMapper
.selectAll()
.stream()
@@ -348,6 +392,8 @@ public class RuleServiceImpl implements RuleService {
return ConfigImportResult.success();
}
Map<String, String> selectorIdMapping = context.getSelectorIdMapping();
+ ruleList.stream().filter(rule ->
selectorIdMapping.containsKey(rule.getSelectorId()))
+ .forEach(rule ->
validateCanaryConfig(selectorIdMapping.get(rule.getSelectorId()),
rule.getHandle()));
Map<String, Set<String>> selectorRuleMap = ruleMapper
.selectAllByNamespaceId(namespace)
.stream()
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
index 7442bffcc9..9528e36049 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
@@ -160,7 +160,8 @@ public final class ConfigsImportDuplicateTest {
@ParameterizedTest
@ValueSource(booleans = {false, true})
public void testRulesDeduplicateAfterSelectorRemapping(final boolean
existing) {
- final RuleServiceImpl service = new RuleServiceImpl(ruleMapper,
mock(RuleConditionMapper.class), selectorMapper, pluginMapper,
mock(RuleEventPublisher.class));
+ final RuleServiceImpl service = new RuleServiceImpl(ruleMapper,
mock(RuleConditionMapper.class), selectorMapper, pluginMapper,
+ mock(RuleEventPublisher.class), dictMapper);
final ConfigsImportContext context = new ConfigsImportContext();
context.getSelectorIdMapping().put("source", "target");
context.getSelectorIdMapping().put("alias", "target");
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
index 47e55830a6..46ef5c4196 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
@@ -22,6 +22,7 @@ import org.apache.shenyu.admin.exception.ShenyuAdminException;
import org.apache.shenyu.admin.mapper.PluginMapper;
import org.apache.shenyu.admin.mapper.RuleConditionMapper;
import org.apache.shenyu.admin.mapper.RuleMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
import org.apache.shenyu.admin.mapper.SelectorMapper;
import org.apache.shenyu.admin.model.dto.RuleDTO;
import org.apache.shenyu.admin.model.entity.PluginDO;
@@ -170,7 +171,7 @@ public final class ConfigsServiceTest {
SelectorMapper selectorMapper = mock(SelectorMapper.class);
PluginMapper pluginMapper = mock(PluginMapper.class);
RuleService ruleServiceImpl = new RuleServiceImpl(ruleMapper,
mock(RuleConditionMapper.class),
- selectorMapper, pluginMapper, mock(RuleEventPublisher.class));
+ selectorMapper, pluginMapper, mock(RuleEventPublisher.class),
mock(ShenyuDictMapper.class));
ConfigsServiceImpl importService = new
ConfigsServiceImpl(appAuthService, pluginService, namespacePluginService,
pluginHandleService, selectorService, ruleServiceImpl,
metaDataService, shenyuDictService,
proxySelectorService, discoveryService,
discoveryUpstreamService, Collections.emptyList());
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
index 1cfc2ad1ae..a75877eda1 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
@@ -21,6 +21,8 @@ import com.github.pagehelper.Page;
import com.github.pagehelper.PageInfo;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import org.apache.commons.lang3.StringUtils;
import org.apache.shenyu.admin.exception.ShenyuAdminException;
import org.apache.shenyu.admin.mapper.DataPermissionMapper;
@@ -28,6 +30,7 @@ import org.apache.shenyu.admin.mapper.PluginMapper;
import org.apache.shenyu.admin.mapper.RuleConditionMapper;
import org.apache.shenyu.admin.mapper.RuleMapper;
import org.apache.shenyu.admin.mapper.SelectorMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
import org.apache.shenyu.admin.model.custom.UserInfo;
import org.apache.shenyu.admin.model.dto.DataPermissionDTO;
import org.apache.shenyu.admin.model.dto.RuleConditionDTO;
@@ -37,6 +40,7 @@ import org.apache.shenyu.admin.model.entity.PluginDO;
import org.apache.shenyu.admin.model.entity.RuleConditionDO;
import org.apache.shenyu.admin.model.entity.RuleDO;
import org.apache.shenyu.admin.model.entity.SelectorDO;
+import org.apache.shenyu.admin.model.entity.ShenyuDictDO;
import org.apache.shenyu.admin.model.page.CommonPager;
import org.apache.shenyu.admin.model.page.PageCondition;
import org.apache.shenyu.admin.model.page.PageParameter;
@@ -54,6 +58,9 @@ import org.apache.shenyu.common.enums.PluginEnum;
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.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
@@ -85,6 +92,7 @@ import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -94,6 +102,9 @@ import static org.mockito.Mockito.when;
@MockitoSettings(strictness = Strictness.LENIENT)
public final class RuleServiceTest {
+ @Mock
+ private ShenyuDictMapper shenyuDictMapper;
+
@InjectMocks
private RuleServiceImpl ruleService;
@@ -118,7 +129,7 @@ public final class RuleServiceTest {
@BeforeEach
public void setUp() {
when(dataPermissionMapper.listByUserId("1")).thenReturn(Collections.singletonList(DataPermissionDO.buildPermissionDO(new
DataPermissionDTO())));
- ruleService = new RuleServiceImpl(ruleMapper, ruleConditionMapper,
selectorMapper, pluginMapper, ruleEventPublisher);
+ ruleService = new RuleServiceImpl(ruleMapper, ruleConditionMapper,
selectorMapper, pluginMapper, ruleEventPublisher, shenyuDictMapper);
}
@Test
@@ -563,6 +574,152 @@ public final class RuleServiceTest {
given(this.ruleConditionMapper.selectByRuleIdSet(Sets.newHashSet("123"))).willReturn(Collections.singletonList(buildRuleConditionDO()));
}
+ @Test
+ public void testCreateRejectsOverlappingCanaryLabelsBeforeSaving() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("");
+
dto.setHandle("{\"canary\":{\"enabled\":false,\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"region\":\"east\"}}}");
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ verify(ruleMapper, never()).insertSelective(any());
+ }
+
+ @Test
+ public void testUpdateRejectsOverlappingCanaryLabelsBeforeSaving() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("123");
+
dto.setHandle("{\"canary\":{\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"release\":\"canary\"}}}");
+ given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ verify(ruleMapper, never()).updateSelective(any());
+ }
+
+ @Test
+ public void testSaveAllowsDisabledCanaryWithDisjointPools() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("");
+
dto.setHandle("{\"canary\":{\"enabled\":false,\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"release\":\"stable\"}}}");
+ given(ruleMapper.insertSelective(any())).willReturn(1);
+ try (MockedStatic<JwtUtils> jwt = mockStatic(JwtUtils.class)) {
+
jwt.when(JwtUtils::getUserInfo).thenReturn(UserInfo.builder().userId("1").userName("admin").build());
+ assertEquals(1, ruleService.createOrUpdate(dto));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"percentage\":101}", "{\"percentage\":20.5}",
"{\"matchMode\":2}",
+ "{\"enabled\":true,\"percentage\":20}",
"{\"stableLabels\":{\"release\":true}}", "{\"fallbackPolicy\":\"TYPO\"}",
+ "{\"stickyKey\":{\"paramType\":\"unregistered\"}}",
+
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"unregistered\",\"paramValue\":\"/\"}]}"})
+ public void testInvalidCanaryNeverWritesOrPublishes(final String patch) {
+ configureDivideSelector();
+
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("uri",
true)));
+ RuleDTO dto = buildRuleDTO("");
+ dto.setHandle(canaryHandle(patch));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ dto.setId("123");
+ given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ verify(ruleMapper, never()).insertSelective(any());
+ verify(ruleMapper, never()).updateSelective(any());
+ verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+ }
+
+ @Test
+ public void testImportsValidateAllRulesBeforeInserting() {
+ configureDivideSelector();
+ RuleDTO valid = buildRuleDTO("");
+ valid.setHandle(canaryHandle("{}"));
+ RuleDTO invalid = buildRuleDTO("");
+ invalid.setHandle(canaryHandle("{\"percentage\":101}"));
+ List<RuleDTO> rules = Arrays.asList(valid, invalid);
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.importData(rules));
+ ConfigsImportContext context = new ConfigsImportContext();
+ context.getSelectorIdMapping().put("456", "456");
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.importData(SYS_DEFAULT_NAMESPACE_ID, rules, context));
+ verify(ruleMapper, never()).insertSelective(any());
+ verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+ }
+
+ @Test
+ public void testRegistrationRejectsInvalidCanaryBeforePublishing() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("");
+ dto.setHandle(canaryHandle("{\"percentage\":101}"));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.registerDefault(dto));
+ verify(ruleMapper, never()).insertSelective(any());
+ verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+ }
+
+ @Test
+ public void
testEnabledDictionaryExtensionIsAcceptedAndDisabledOneRejected() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("");
+
dto.setHandle(canaryHandle("{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"custom-source\"}}"));
+
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("custom-source",
false)));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ verifyNoInteractions(ruleEventPublisher);
+
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("custom-source",
true)));
+ given(ruleMapper.insertSelective(any())).willReturn(1);
+ try (MockedStatic<JwtUtils> jwt = mockStatic(JwtUtils.class)) {
+
jwt.when(JwtUtils::getUserInfo).thenReturn(UserInfo.builder().userId("1").userName("admin").build());
+ assertEquals(1, ruleService.createOrUpdate(dto));
+ }
+ }
+
+ @Test
+ public void testSavingLegacyRulePreservesHandleWithoutCanary() {
+ configureDivideSelector();
+ RuleDTO dto = buildRuleDTO("123");
+ dto.setHandle("{\"timeout\":5000,\"extension\":{\"value\":true}}");
+ given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+ given(ruleMapper.updateSelective(any())).willReturn(1);
+ assertEquals(1, ruleService.createOrUpdate(dto));
+ ArgumentCaptor<RuleDO> saved = ArgumentCaptor.forClass(RuleDO.class);
+ verify(ruleMapper).updateSelective(saved.capture());
+ assertEquals(dto.getHandle(), saved.getValue().getHandle());
+ verifyNoInteractions(shenyuDictMapper);
+ }
+
+ @Test
+ public void testCanaryFailoverNeverWritesOrPublishes() {
+ configureDivideSelector();
+ JsonObject handle =
JsonParser.parseString(canaryHandle("{}")).getAsJsonObject();
+ handle.addProperty("retry", 2);
+ handle.addProperty("retryStrategy", "failover");
+ RuleDTO dto = buildRuleDTO("");
+ dto.setHandle(handle.toString());
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.registerDefault(dto));
+ dto.setId("123");
+ given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.createOrUpdate(dto));
+ assertThrows(ShenyuAdminException.class, () ->
ruleService.importData(Collections.singletonList(dto)));
+ verify(ruleMapper, never()).insertSelective(any());
+ verify(ruleMapper, never()).updateSelective(any());
+ verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+ }
+
+ private ShenyuDictDO dictionary(final String value, final boolean enabled)
{
+ ShenyuDictDO dict = new ShenyuDictDO();
+ dict.setDictValue(value);
+ dict.setEnabled(enabled);
+ return dict;
+ }
+
+ private String canaryHandle(final String patch) {
+ JsonObject config =
JsonParser.parseString("{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}")
+ .getAsJsonObject();
+
JsonParser.parseString(patch).getAsJsonObject().entrySet().forEach(entry ->
config.add(entry.getKey(), entry.getValue()));
+ JsonObject handle = new JsonObject();
+ handle.add("canary", config);
+ return handle.toString();
+ }
+
+ private void configureDivideSelector() {
+ given(selectorMapper.selectById("456")).willReturn(buildSelectorDO());
+
given(pluginMapper.selectById("789")).willReturn(PluginDO.builder().id("789").name("divide").build());
+ }
+
private void testRegisterCreate() {
RuleDTO ruleDTO = buildRuleDTO("");
RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
diff --git a/shenyu-common/pom.xml b/shenyu-common/pom.xml
index 379312c653..6c84ac31ea 100644
--- a/shenyu-common/pom.xml
+++ b/shenyu-common/pom.xml
@@ -37,6 +37,11 @@
<artifactId>gson</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.google.re2j</groupId>
+ <artifactId>re2j</artifactId>
+ </dependency>
+
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
new file mode 100644
index 0000000000..f65d0d85d0
--- /dev/null
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
@@ -0,0 +1,265 @@
+/*
+ * 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.dto.convert.rule.canary;
+
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Canary routing configuration.
+ */
+public class CanaryConfig {
+
+ /**
+ * Whether Canary traffic is enabled. When false, a present configuration
routes to Stable.
+ */
+ private boolean enabled;
+
+ /**
+ * Canary matching conditions. Empty conditions allow all requests to
participate.
+ */
+ private List<ConditionData> conditions;
+
+ /**
+ * Condition match mode: 0 for AND (default), 1 for OR.
+ */
+ private Integer matchMode = MatchModeEnum.AND.getCode();
+
+ /**
+ * Percentage of eligible identifiers assigned to Canary, from 0 to 100.
Defaults to 0.
+ */
+ private int percentage;
+
+ /**
+ * Identifier source for sticky routing. Required when enabled with a
percentage from 1 to 99.
+ */
+ private StickyKeyConfig stickyKey;
+
+ /**
+ * Labels used to select canary upstreams.
+ */
+ private Map<String, String> canaryLabels;
+
+ /**
+ * Labels used to select stable upstreams.
+ */
+ private Map<String, String> stableLabels;
+
+ /**
+ * Initial empty Canary pool policy: STABLE (default) or REJECT.
+ */
+ private String fallbackPolicy = "STABLE";
+
+ /**
+ * Get enabled.
+ *
+ * @return enabled
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Set enabled.
+ *
+ * @param enabled enabled
+ */
+ public void setEnabled(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Get conditions.
+ *
+ * @return conditions
+ */
+ public List<ConditionData> getConditions() {
+ return conditions;
+ }
+
+ /**
+ * Set conditions.
+ *
+ * @param conditions conditions
+ */
+ public void setConditions(final List<ConditionData> conditions) {
+ this.conditions = conditions;
+ }
+
+ /**
+ * Get matchMode.
+ *
+ * @return matchMode
+ */
+ public Integer getMatchMode() {
+ return matchMode;
+ }
+
+ /**
+ * Set matchMode.
+ *
+ * @param matchMode matchMode
+ */
+ public void setMatchMode(final Integer matchMode) {
+ this.matchMode = matchMode;
+ }
+
+ /**
+ * Get percentage.
+ *
+ * @return percentage
+ */
+ public int getPercentage() {
+ return percentage;
+ }
+
+ /**
+ * Set percentage.
+ *
+ * @param percentage percentage
+ */
+ public void setPercentage(final int percentage) {
+ this.percentage = percentage;
+ }
+
+ /**
+ * Get stickyKey.
+ *
+ * @return stickyKey
+ */
+ public StickyKeyConfig getStickyKey() {
+ return stickyKey;
+ }
+
+ /**
+ * Set stickyKey.
+ *
+ * @param stickyKey stickyKey
+ */
+ public void setStickyKey(final StickyKeyConfig stickyKey) {
+ this.stickyKey = stickyKey;
+ }
+
+ /**
+ * Get canaryLabels.
+ *
+ * @return canaryLabels
+ */
+ public Map<String, String> getCanaryLabels() {
+ return canaryLabels;
+ }
+
+ /**
+ * Set canaryLabels.
+ *
+ * @param canaryLabels canaryLabels
+ */
+ public void setCanaryLabels(final Map<String, String> canaryLabels) {
+ this.canaryLabels = canaryLabels;
+ }
+
+ /**
+ * Get stableLabels.
+ *
+ * @return stableLabels
+ */
+ public Map<String, String> getStableLabels() {
+ return stableLabels;
+ }
+
+ /**
+ * Set stableLabels.
+ *
+ * @param stableLabels stableLabels
+ */
+ public void setStableLabels(final Map<String, String> stableLabels) {
+ this.stableLabels = stableLabels;
+ }
+
+ /**
+ * Get fallbackPolicy.
+ *
+ * @return fallbackPolicy
+ */
+ public String getFallbackPolicy() {
+ return fallbackPolicy;
+ }
+
+ /**
+ * Set fallbackPolicy.
+ *
+ * @param fallbackPolicy fallbackPolicy
+ */
+ public void setFallbackPolicy(final String fallbackPolicy) {
+ this.fallbackPolicy = fallbackPolicy;
+ }
+
+ /**
+ * Validate that the two label selectors cannot match the same upstream.
+ *
+ * @throws IllegalArgumentException when labels are missing or selectors
can overlap
+ */
+ public void validatePartitionLabels() {
+ if (Objects.isNull(canaryLabels) || canaryLabels.isEmpty() ||
Objects.isNull(stableLabels) || stableLabels.isEmpty()) {
+ throw new IllegalArgumentException("Canary and Stable labels must
both be configured");
+ }
+ boolean disjoint = canaryLabels.entrySet().stream().anyMatch(entry ->
stableLabels.containsKey(entry.getKey())
+ && !Objects.equals(entry.getValue(),
stableLabels.get(entry.getKey())));
+ if (!disjoint) {
+ throw new IllegalArgumentException("Canary and Stable labels must
have a shared key with different values");
+ }
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (Objects.isNull(o) || getClass() != o.getClass()) {
+ return false;
+ }
+ CanaryConfig that = (CanaryConfig) o;
+ return enabled == that.enabled && percentage == that.percentage
+ && Objects.equals(conditions, that.conditions) &&
Objects.equals(matchMode, that.matchMode)
+ && Objects.equals(stickyKey, that.stickyKey) &&
Objects.equals(canaryLabels, that.canaryLabels)
+ && Objects.equals(stableLabels, that.stableLabels) &&
Objects.equals(fallbackPolicy, that.fallbackPolicy);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(enabled, conditions, matchMode, percentage,
stickyKey, canaryLabels, stableLabels, fallbackPolicy);
+ }
+
+ @Override
+ public String toString() {
+ return "CanaryConfig{"
+ + "enabled=" + enabled
+ + ", conditions=" + conditions
+ + ", matchMode=" + matchMode
+ + ", percentage=" + percentage
+ + ", stickyKey=" + stickyKey
+ + ", canaryLabels=" + canaryLabels
+ + ", stableLabels=" + stableLabels
+ + ", fallbackPolicy='" + fallbackPolicy + '\''
+ + '}';
+ }
+}
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
new file mode 100644
index 0000000000..44f3d9b354
--- /dev/null
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
@@ -0,0 +1,208 @@
+/*
+ * 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.dto.convert.rule.canary;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.re2j.Pattern;
+import com.google.re2j.PatternSyntaxException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.enums.RetryEnum;
+import org.apache.shenyu.common.utils.DateUtils;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.common.utils.UpstreamLabelsUtils;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Shared validation of Canary configuration before persistence or gateway
caching.
+ */
+public final class CanaryConfigValidator {
+
+ private CanaryConfigValidator() {
+ }
+
+ /**
+ * Parse a Divide handle, checking JSON types before Gson can coerce
values.
+ * An absent or null Canary configuration preserves legacy routing.
+ *
+ * @param handle the serialized rule handle
+ * @return the validated handle
+ * @throws IllegalArgumentException when the configuration is invalid
+ */
+ public static DivideRuleHandle parseHandle(final String handle) {
+ try {
+ JsonObject root = object(GsonUtils.getInstance().fromJson(handle,
JsonElement.class), "Divide handle");
+ JsonElement canary = root.get("canary");
+ if (Objects.nonNull(canary) && !canary.isJsonNull()) {
+ validate(object(canary, "canary"));
+ }
+ DivideRuleHandle result = GsonUtils.getInstance().fromJson(root,
DivideRuleHandle.class);
+ if (Objects.nonNull(result.getCanary()) && result.getRetry() > 0
+ &&
!RetryEnum.CURRENT.getName().equals(StringUtils.defaultIfEmpty(result.getRetryStrategy(),
RetryEnum.CURRENT.getName()))) {
+ throw new IllegalArgumentException("Canary routing currently
supports only current-node retries; failover requires partition-aware retry
support");
+ }
+ return result;
+ } catch (JsonParseException ex) {
+ throw new IllegalArgumentException("Invalid Divide handle JSON",
ex);
+ }
+ }
+
+ /**
+ * Check extension names against the registry available at the caller.
+ * Admin uses its dictionaries; gateways use their installed SPI
implementations.
+ *
+ * @param config the validated configuration
+ * @param parameterValidator parameter source validator
+ * @param operatorValidator condition operator validator
+ */
+ public static void validateExtensions(final CanaryConfig config, final
Consumer<String> parameterValidator,
+ final Consumer<String>
operatorValidator) {
+ if (Objects.nonNull(config.getStickyKey())) {
+ parameterValidator.accept(config.getStickyKey().getParamType());
+ }
+ if (Objects.nonNull(config.getConditions())) {
+ for (ConditionData condition : config.getConditions()) {
+ parameterValidator.accept(condition.getParamType());
+ operatorValidator.accept(condition.getOperator());
+ }
+ }
+ }
+
+ private static void validate(final JsonObject config) {
+ if (config.has("enabled") && !(config.get("enabled").isJsonPrimitive()
&& config.getAsJsonPrimitive("enabled").isBoolean())) {
+ throw new IllegalArgumentException("canary.enabled must be a
boolean");
+ }
+ int percentage = integer(config, "percentage", 0);
+ if (percentage < 0 || percentage > 100) {
+ throw new IllegalArgumentException("canary.percentage must be
between 0 and 100");
+ }
+ int matchMode = integer(config, "matchMode", 0);
+ if (matchMode != 0 && matchMode != 1) {
+ throw new IllegalArgumentException("canary.matchMode must be 0
(AND) or 1 (OR)");
+ }
+ if (config.has("fallbackPolicy")) {
+ String policy = string(config, "fallbackPolicy",
"canary.fallbackPolicy", true);
+ if (!"STABLE".equals(policy) && !"REJECT".equals(policy)) {
+ throw new IllegalArgumentException("canary.fallbackPolicy must
be STABLE or REJECT");
+ }
+ }
+ validateLabels(config.get("stableLabels"), "canary.stableLabels");
+ validateLabels(config.get("canaryLabels"), "canary.canaryLabels");
+ if (config.has("stickyKey")) {
+ validateSource(object(config.get("stickyKey"),
"canary.stickyKey"), "canary.stickyKey");
+ } else if (config.has("enabled") &&
config.get("enabled").getAsBoolean() && percentage > 0 && percentage < 100) {
+ throw new IllegalArgumentException("canary.stickyKey is required
when enabled with a percentage between 1 and 99");
+ }
+ if (config.has("conditions")) {
+ JsonElement conditions = config.get("conditions");
+ if (!conditions.isJsonArray()) {
+ throw new IllegalArgumentException("canary.conditions must be
an array");
+ }
+ int index = 0;
+ for (JsonElement element : conditions.getAsJsonArray()) {
+ String path = "canary.conditions[" + index++ + "]";
+ JsonObject condition = object(element, path);
+ validateSource(condition, path);
+ String operator = string(condition, "operator", path +
".operator", true);
+ String value = string(condition, "paramValue", path +
".paramValue", !"isBlank".equals(operator));
+ validateConditionValue(operator, value, path);
+ }
+ }
+ GsonUtils.getInstance().fromJson(config,
CanaryConfig.class).validatePartitionLabels();
+ }
+
+ private static void validateConditionValue(final String operator, final
String value, final String path) {
+ try {
+ if ("regex".equals(operator)) {
+ // Validate with the same regex engine used by the gateway's
RegexPredicateJudge.
+ Pattern.compile(value.trim());
+ } else if ("TimeBefore".equals(operator) ||
"TimeAfter".equals(operator)) {
+ DateUtils.parseLocalDateTime(value.trim());
+ }
+ } catch (PatternSyntaxException | IllegalArgumentException |
java.time.DateTimeException ex) {
+ throw new IllegalArgumentException(path + ".paramValue is invalid
for " + operator, ex);
+ }
+ }
+
+ private static void validateSource(final JsonObject source, final String
path) {
+ String type = string(source, "paramType", path + ".paramType", true);
+ boolean named = "header".equals(type) || "cookie".equals(type) ||
"query".equals(type) || "post".equals(type);
+ string(source, "paramName", path + ".paramName", named);
+ }
+
+ /**
+ * Validate a label map without coercing numbers or booleans to strings.
+ *
+ * @param labels the JSON labels
+ * @param path field name for errors
+ */
+ private static void validateLabels(final JsonElement labels, final String
path) {
+ Map<String, String> map;
+ try {
+ map = UpstreamLabelsUtils.parse(object(labels, path));
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalArgumentException(path + ": " + ex.getMessage(),
ex);
+ }
+ if (map.isEmpty()) {
+ throw new IllegalArgumentException(path + " must not be empty");
+ }
+ }
+
+ private static JsonObject object(final JsonElement value, final String
path) {
+ if (Objects.isNull(value) || !value.isJsonObject()) {
+ throw new IllegalArgumentException(path + " must be an object");
+ }
+ return value.getAsJsonObject();
+ }
+
+ private static int integer(final JsonObject object, final String name,
final int defaultValue) {
+ if (!object.has(name)) {
+ return defaultValue;
+ }
+ JsonElement value = object.get(name);
+ if (!(value.isJsonPrimitive() &&
value.getAsJsonPrimitive().isNumber())) {
+ throw new IllegalArgumentException("canary." + name + " must be an
integer");
+ }
+ try {
+ return value.getAsBigDecimal().intValueExact();
+ } catch (ArithmeticException | NumberFormatException ex) {
+ throw new IllegalArgumentException("canary." + name + " must be an
integer", ex);
+ }
+ }
+
+ private static String string(final JsonObject object, final String name,
final String path, final boolean required) {
+ if (!object.has(name) && !required) {
+ return null;
+ }
+ JsonElement value = object.get(name);
+ if (Objects.isNull(value) || !(value.isJsonPrimitive() &&
value.getAsJsonPrimitive().isString())) {
+ throw new IllegalArgumentException(path + " must be a string");
+ }
+ String result = value.getAsString();
+ if (required && StringUtils.isBlank(result)) {
+ throw new IllegalArgumentException(path + " must not be blank");
+ }
+ return result;
+ }
+}
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
new file mode 100644
index 0000000000..76ca4a1e8b
--- /dev/null
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
@@ -0,0 +1,98 @@
+/*
+ * 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.dto.convert.rule.canary;
+
+import java.util.Objects;
+
+/**
+ * Identifier source for stable canary grouping.
+ */
+public class StickyKeyConfig {
+
+ /**
+ * Registered ParameterData SPI source name, such as header, cookie,
query, ip or a custom extension.
+ */
+ private String paramType;
+
+ /**
+ * Source-specific parameter name, passed unchanged to the selected
ParameterData implementation.
+ * Named built-in sources require a non-blank name; extensions determine
how it is used.
+ */
+ private String paramName;
+
+ /**
+ * Get paramType.
+ *
+ * @return paramType
+ */
+ public String getParamType() {
+ return paramType;
+ }
+
+ /**
+ * Set paramType.
+ *
+ * @param paramType paramType
+ */
+ public void setParamType(final String paramType) {
+ this.paramType = paramType;
+ }
+
+ /**
+ * Get paramName.
+ *
+ * @return paramName
+ */
+ public String getParamName() {
+ return paramName;
+ }
+
+ /**
+ * Set paramName.
+ *
+ * @param paramName paramName
+ */
+ public void setParamName(final String paramName) {
+ this.paramName = paramName;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (Objects.isNull(o) || getClass() != o.getClass()) {
+ return false;
+ }
+ StickyKeyConfig that = (StickyKeyConfig) o;
+ return Objects.equals(paramType, that.paramType) &&
Objects.equals(paramName, that.paramName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(paramType, paramName);
+ }
+
+ @Override
+ public String toString() {
+ return "StickyKeyConfig{"
+ + "paramType='" + paramType + '\''
+ + ", paramName='" + paramName + '\''
+ + '}';
+ }
+}
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
index 84be0832c1..b8670cb0c8 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
@@ -19,6 +19,7 @@ package org.apache.shenyu.common.dto.convert.rule.impl;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.dto.convert.rule.RuleHandle;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
import org.apache.shenyu.common.enums.LoadBalanceEnum;
import org.apache.shenyu.common.enums.RetryEnum;
@@ -63,7 +64,12 @@ public class DivideRuleHandle implements RuleHandle {
* requestMaxSize.
*/
private long requestMaxSize;
-
+
+ /**
+ * Canary routing configuration.
+ */
+ private CanaryConfig canary;
+
/**
* New instance divide rule handle.
*
@@ -181,6 +187,24 @@ public class DivideRuleHandle implements RuleHandle {
this.requestMaxSize = requestMaxSize;
}
+ /**
+ * Get canary configuration.
+ *
+ * @return canary configuration
+ */
+ public CanaryConfig getCanary() {
+ return canary;
+ }
+
+ /**
+ * Set canary configuration.
+ *
+ * @param canary canary configuration
+ */
+ public void setCanary(final CanaryConfig canary) {
+ this.canary = canary;
+ }
+
public String getRetryBackOffSpec() {
return retryBackOffSpec;
}
@@ -201,12 +225,12 @@ public class DivideRuleHandle implements RuleHandle {
return retry == that.retry && timeout == that.timeout && headerMaxSize
== that.headerMaxSize
&& requestMaxSize == that.requestMaxSize &&
Objects.equals(loadBalance, that.loadBalance)
&& Objects.equals(retryStrategy, that.retryStrategy)
- && Objects.equals(retryBackOffSpec, that.retryBackOffSpec);
+ && Objects.equals(retryBackOffSpec, that.retryBackOffSpec) &&
Objects.equals(canary, that.canary);
}
@Override
public int hashCode() {
- return Objects.hash(loadBalance, retryStrategy, retryBackOffSpec,
retry, timeout, headerMaxSize, requestMaxSize);
+ return Objects.hash(loadBalance, retryStrategy, retryBackOffSpec,
retry, timeout, headerMaxSize, requestMaxSize, canary);
}
@Override
@@ -229,6 +253,8 @@ public class DivideRuleHandle implements RuleHandle {
+ headerMaxSize
+ ", requestMaxSize="
+ requestMaxSize
+ + ", canary="
+ + canary
+ '}';
}
}
diff --git
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
new file mode 100644
index 0000000000..5e2560a29a
--- /dev/null
+++
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.dto.convert.rule.canary;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.re2j.PatternSyntaxException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Canary wire contract validation tests.
+ */
+class CanaryConfigValidatorTest {
+
+ @Test
+ void testLegacyHandleDoesNotAcquireCanaryConfiguration() {
+
assertNull(CanaryConfigValidator.parseHandle("{\"timeout\":5000}").getCanary());
+
assertNull(CanaryConfigValidator.parseHandle("{\"canary\":null}").getCanary());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "{\"percentage\":-1}", "{\"percentage\":101}", "{\"percentage\":1.5}",
"{\"percentage\":\"20\"}",
+ "{\"percentage\":null}", "{\"percentage\":1e20}",
"{\"enabled\":\"false\"}", "{\"enabled\":null}",
+ "{\"matchMode\":2}", "{\"matchMode\":0.5}", "{\"matchMode\":null}",
"{\"fallbackPolicy\":\"stable\"}",
+ "{\"fallbackPolicy\":null}", "{\"stickyKey\":null}",
"{\"stickyKey\":{}}", "{\"stickyKey\":{\"paramType\":\" \"}}",
+ "{\"stickyKey\":{\"paramType\":\"header\"}}",
"{\"enabled\":true,\"percentage\":20}",
+ "{\"conditions\":{}}", "{\"conditions\":null}",
"{\"conditions\":[null]}", "{\"conditions\":[{}]}",
+
"{\"conditions\":[{\"paramType\":\"header\",\"operator\":\"=\",\"paramValue\":\"east\"}]}",
+ "{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"=\"}]}",
+
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"regex\",\"paramValue\":\"[\"}]}",
+
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"TimeAfter\",\"paramValue\":\"yesterday\"}]}",
+ "{\"stableLabels\":{}}", "{\"stableLabels\":null}",
"{\"stableLabels\":{\"release\":1}}",
+ "{\"stableLabels\":{\"release\":true}}",
"{\"stableLabels\":{\"release\":null}}",
+ "{\"stableLabels\":{\" \" :\"stable\"}}",
"{\"stableLabels\":{\"release\":\" \"}}",
+ "{\"stableLabels\":{\"release\":\"canary\"}}",
"{\"stableLabels\":{\"region\":\"east\"}}"
+ })
+ void testRejectsInvalidConfigurationBeforeCoercion(final String patch) {
+ assertThrows(IllegalArgumentException.class, () ->
CanaryConfigValidator.parseHandle(handle(patch)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "{}", "{\"enabled\":true,\"percentage\":0}",
"{\"enabled\":true,\"percentage\":100}",
+ "{\"enabled\":false,\"percentage\":20}",
+
"{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"header\",\"paramName\":\"X-User-Id\"}}",
+
"{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"ip\"}}",
+ "{\"stickyKey\":{\"paramType\":\"custom-extension\"}}",
+ "{\"conditions\":[]}",
"{\"matchMode\":1,\"fallbackPolicy\":\"REJECT\"}",
+
"{\"conditions\":[{\"paramType\":\"header\",\"paramName\":\"X-Optional\",\"operator\":\"isBlank\"}]}",
+
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"regex\",\"paramValue\":\"/orders/.*\"}]}"
+ })
+ void testAcceptsValidBoundariesAndExtensionNames(final String patch) {
+ assertDoesNotThrow(() ->
CanaryConfigValidator.parseHandle(handle(patch)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"[", "(?=a)a", "(?<=a)b", "(a)\\1"})
+ void testRejectsRegexUnsupportedByGatewayWithFieldError(final String
regex) {
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () -> CanaryConfigValidator.parseHandle(regexHandle(regex)));
+ assertEquals("canary.conditions[0].paramValue is invalid for regex",
exception.getMessage());
+ assertEquals(PatternSyntaxException.class,
exception.getCause().getClass());
+ }
+
+ @Test
+ void testDefaultsRemainStable() {
+ CanaryConfig config =
CanaryConfigValidator.parseHandle(handle("{}")).getCanary();
+ assertEquals(0, config.getPercentage());
+ assertEquals(0, config.getMatchMode());
+ assertEquals("STABLE", config.getFallbackPolicy());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"default", "fixed", "exponential"})
+ void testCanaryFailoverRequiresPartitionAwareRetries(final String backoff)
{
+ JsonObject root =
JsonParser.parseString(handle("{}")).getAsJsonObject();
+ root.addProperty("retry", 2);
+ root.addProperty("retryStrategy", "failover");
+ root.addProperty("retryBackOffSpec", backoff);
+ assertThrows(IllegalArgumentException.class, () ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.getAsJsonObject("canary").addProperty("enabled", true);
+ root.getAsJsonObject("canary").addProperty("percentage", 100);
+ assertThrows(IllegalArgumentException.class, () ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.addProperty("retry", 0);
+ assertDoesNotThrow(() ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.addProperty("retry", 2);
+ root.addProperty("retryStrategy", "current");
+ assertDoesNotThrow(() ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.remove("retryStrategy");
+ assertDoesNotThrow(() ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.addProperty("retryStrategy", "unknown");
+ assertThrows(IllegalArgumentException.class, () ->
CanaryConfigValidator.parseHandle(root.toString()));
+ root.remove("canary");
+ root.addProperty("retryStrategy", "failover");
+ assertDoesNotThrow(() ->
CanaryConfigValidator.parseHandle(root.toString()));
+ }
+
+ private String regexHandle(final String regex) {
+ JsonObject condition = new JsonObject();
+ condition.addProperty("paramType", "uri");
+ condition.addProperty("operator", "regex");
+ condition.addProperty("paramValue", regex);
+ JsonArray conditions = new JsonArray();
+ conditions.add(condition);
+ JsonObject config = new JsonObject();
+ config.add("conditions", conditions);
+ return handle(config.toString());
+ }
+
+ private String handle(final String patch) {
+ JsonObject config =
JsonParser.parseString("{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}")
+ .getAsJsonObject();
+
JsonParser.parseString(patch).getAsJsonObject().entrySet().forEach(entry ->
config.add(entry.getKey(), entry.getValue()));
+ JsonObject handle = new JsonObject();
+ handle.add("canary", config);
+ return handle.toString();
+ }
+}
diff --git
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
index e705b66269..a4b51518df 100644
---
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
+++
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
@@ -18,11 +18,16 @@
package org.apache.shenyu.common.dto.convert.rule.impl;
import com.google.common.collect.ImmutableSet;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
import org.apache.shenyu.common.enums.LoadBalanceEnum;
import org.apache.shenyu.common.enums.RetryEnum;
import org.junit.jupiter.api.Test;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.core.Is.is;
@@ -61,4 +66,43 @@ public class DivideRuleHandleTest {
assertThat(ImmutableSet.of(handle1, handle2), hasSize(1));
}
+ @Test
+ public void testCanaryConfigurationEquality() {
+ final DivideRuleHandle handle1 = new DivideRuleHandle();
+ final DivideRuleHandle handle2 = new DivideRuleHandle();
+ CanaryConfig canary1 = new CanaryConfig();
+ CanaryConfig canary2 = new CanaryConfig();
+ canary1.setEnabled(true);
+ canary1.setPercentage(10);
+ canary2.setEnabled(true);
+ canary2.setPercentage(10);
+ handle1.setCanary(canary1);
+ handle2.setCanary(canary2);
+
+ assertThat(handle1.getCanary(), is(canary1));
+ assertThat(ImmutableSet.of(handle1, handle2), hasSize(1));
+
+ canary2.setPercentage(20);
+ assertThat(handle1.equals(handle2), is(false));
+ assertThat(ImmutableSet.of(handle1, handle2), hasSize(2));
+
+ handle2.setCanary(null);
+ assertThat(handle1.equals(handle2), is(false));
+ }
+
+ @Test
+ public void testPartitionLabelsMustBeMutuallyExclusive() {
+ CanaryConfig config = new CanaryConfig();
+ assertThrows(IllegalArgumentException.class,
config::validatePartitionLabels);
+ config.setCanaryLabels(Map.of("release", "canary"));
+ config.setStableLabels(Map.of("release", "stable"));
+ assertDoesNotThrow(config::validatePartitionLabels);
+ config.setStableLabels(Map.of("release", "canary"));
+ assertThrows(IllegalArgumentException.class,
config::validatePartitionLabels);
+ config.setStableLabels(Map.of("region", "east"));
+ assertThrows(IllegalArgumentException.class,
config::validatePartitionLabels);
+ config.setStableLabels(Map.of("release", "stable", "region", "east"));
+ assertDoesNotThrow(config::validatePartitionLabels);
+ }
+
}
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 012aa81e36..a82bc939b0 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
@@ -49,6 +49,9 @@ public final class UpstreamCacheManager {
private static final Map<String, List<Upstream>> UPSTREAM_MAP =
Maps.newConcurrentMap();
+ // Include unhealthy configured gray nodes so legacy traffic never falls
back to ordinary nodes.
+ private static final Map<String, Boolean> LEGACY_GRAY_SELECTORS =
Maps.newConcurrentMap();
+
private UpstreamCheckTask task;
/**
@@ -128,6 +131,21 @@ public final class UpstreamCacheManager {
* @return the list
*/
public List<Upstream> findUpstreamListBySelectorId(final String
selectorId) {
+ List<Upstream> upstreamList =
findAllUpstreamListBySelectorId(selectorId);
+ if (Objects.isNull(upstreamList) ||
!Boolean.TRUE.equals(LEGACY_GRAY_SELECTORS.get(selectorId))) {
+ return upstreamList;
+ }
+ return
upstreamList.stream().filter(Upstream::isGray).collect(Collectors.toList());
+ }
+
+ /**
+ * Find all healthy upstreams for rules that select their own label
partition.
+ * The regular lookup retains the legacy gray-exclusive policy, including
for failover.
+ *
+ * @param selectorId selector identifier
+ * @return a snapshot of all healthy upstreams, or null when the selector
has no healthy pool
+ */
+ public List<Upstream> findAllUpstreamListBySelectorId(final String
selectorId) {
List<Upstream> upstreamList =
task.getHealthyUpstream().get(selectorId);
return Objects.isNull(upstreamList) ? null : new
ArrayList<>(upstreamList);
}
@@ -139,6 +157,7 @@ public final class UpstreamCacheManager {
*/
public void removeByKey(final String key) {
UPSTREAM_MAP.remove(key);
+ LEGACY_GRAY_SELECTORS.remove(key);
task.triggerRemoveAll(key);
}
@@ -157,6 +176,7 @@ public final class UpstreamCacheManager {
return;
}
+ LEGACY_GRAY_SELECTORS.put(selectorId,
actualUpstreamList.stream().anyMatch(Upstream::isGray));
initializeUpstreamHealthStatus(actualUpstreamList);
Map<Boolean, List<Upstream>> partitionedUpstreams =
actualUpstreamList.stream()
@@ -181,6 +201,7 @@ public final class UpstreamCacheManager {
if (Objects.nonNull(cached)) {
cached.setMetadata(Collections.unmodifiableMap(new
HashMap<>(upstream.getMetadata())));
cached.setLabels(upstream.getLabels());
+ cached.setGray(upstream.isGray());
}
});
}
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 1a6d868d1f..43f496f5b6 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
@@ -118,7 +118,7 @@ public final class Upstream {
/**
* this is gray.
*/
- private boolean gray;
+ private volatile boolean gray;
/**
* health check enabled.
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
new file mode 100644
index 0000000000..480e768b01
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
@@ -0,0 +1,54 @@
+/*
+ * 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.plugin.base.utils;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.collections4.MapUtils;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Match healthy upstreams against a label partition.
+ */
+public final class UpstreamLabelUtils {
+
+ private UpstreamLabelUtils() {
+ }
+
+ /**
+ * Select upstreams matching every label. Missing partition labels select
no nodes.
+ *
+ * @param upstreams latest healthy upstreams
+ * @param labels partition label constraints
+ * @return matching upstreams without modifying the supplied list or nodes
+ */
+ public static List<Upstream> filter(final List<Upstream> upstreams, final
Map<String, String> labels) {
+ if (CollectionUtils.isEmpty(upstreams) || MapUtils.isEmpty(labels)) {
+ return Collections.emptyList();
+ }
+ return upstreams.stream().filter(upstream -> {
+ Map<String, String> upstreamLabels = upstream.getLabels();
+ return MapUtils.isNotEmpty(upstreamLabels) &&
upstreamLabels.entrySet().containsAll(labels.entrySet());
+ })
+ .collect(Collectors.toList());
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
index 199005dc67..5c39e8701e 100644
---
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
@@ -22,6 +22,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.dto.RuleData;
import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
import org.apache.shenyu.common.enums.LoadBalanceEnum;
@@ -40,9 +41,14 @@ import org.apache.shenyu.plugin.base.AbstractShenyuPlugin;
import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
import org.apache.shenyu.plugin.base.utils.LoadbalancerUtils;
+import org.apache.shenyu.plugin.base.utils.UpstreamLabelUtils;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecision;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@@ -64,6 +70,16 @@ public class DividePlugin extends AbstractShenyuPlugin {
private static final String SHORTEST_RESPONSE = "shortestResponse";
+ private final CanaryDecisionService canaryDecisionService;
+
+ public DividePlugin() {
+ this(new DefaultCanaryDecisionService());
+ }
+
+ public DividePlugin(final CanaryDecisionService canaryDecisionService) {
+ this.canaryDecisionService =
Objects.requireNonNull(canaryDecisionService);
+ }
+
@Override
protected String getRawPath(final ServerWebExchange exchange) {
return RequestUrlUtils.getRewrittenRawPath(exchange);
@@ -97,8 +113,18 @@ public class DividePlugin extends AbstractShenyuPlugin {
return WebFluxResultUtils.result(exchange, error);
}
}
- List<Upstream> upstreamList =
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId());
+ boolean canaryRouting = Objects.nonNull(ruleHandle.getCanary())
+ &&
CollectionUtils.isEmpty(exchange.getRequest().getHeaders().get(Constants.SPECIFY_DOMAIN));
+ List<Upstream> upstreamList = canaryRouting
+ ?
UpstreamCacheManager.getInstance().findAllUpstreamListBySelectorId(selector.getId())
+ :
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId());
+ if (canaryRouting && CollectionUtils.isNotEmpty(upstreamList)) {
+ upstreamList = selectPartition(exchange, rule.getId(),
ruleHandle.getCanary(), upstreamList);
+ }
if (CollectionUtils.isEmpty(upstreamList)) {
+ if (canaryRouting) {
+
exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
+ }
LOG.error("divide upstream configuration error: {}", selector);
Object error = ShenyuResultWrap.error(exchange,
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
return WebFluxResultUtils.result(exchange, error);
@@ -107,6 +133,9 @@ public class DividePlugin extends AbstractShenyuPlugin {
final CircuitBreakerCandidates candidates =
CircuitBreakerCandidates.filter(selector.getId(), upstreamList, requested);
final List<Upstream> breakeredUpstreamList = candidates.getUpstreams();
if (CollectionUtils.isEmpty(breakeredUpstreamList)) {
+ if (canaryRouting) {
+
exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
+ }
LOG.warn("all upstreams of selector {} are blocked by the circuit
breaker, fail fast", selector.getId());
Object error = ShenyuResultWrap.error(exchange,
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
return WebFluxResultUtils.result(exchange, error);
@@ -114,6 +143,18 @@ public class DividePlugin extends AbstractShenyuPlugin {
return candidates.recordProbeOutcome(Mono.defer(() ->
forwardSelected(exchange, chain, selector, ruleHandle, candidates)));
}
+ private List<Upstream> selectPartition(final ServerWebExchange exchange,
final String ruleId,
+ final CanaryConfig config, final
List<Upstream> upstreams) {
+ CanaryDecision decision = canaryDecisionService.decide(exchange,
ruleId, config);
+ List<Upstream> candidates = UpstreamLabelUtils.filter(upstreams,
+ decision == CanaryDecision.CANARY ? config.getCanaryLabels() :
config.getStableLabels());
+ if (candidates.isEmpty() && decision == CanaryDecision.CANARY &&
"STABLE".equals(config.getFallbackPolicy())) {
+ candidates = UpstreamLabelUtils.filter(upstreams,
config.getStableLabels());
+ }
+ // Resolve the pool before circuit-breaker probes and load balancing;
neither may escape it.
+ return candidates;
+ }
+
private Mono<Void> forwardSelected(final ServerWebExchange exchange, final
ShenyuPluginChain chain, final SelectorData selector,
final DivideRuleHandle ruleHandle,
final CircuitBreakerCandidates candidates) {
List<Upstream> breakeredUpstreamList = candidates.getUpstreams();
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
new file mode 100644
index 0000000000..df732c4f9a
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
@@ -0,0 +1,49 @@
+/*
+ * 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.plugin.divide.canary;
+
+/**
+ * Expected request partition, independent of upstream availability.
+ */
+public enum CanaryDecision {
+
+ /**
+ * Production partition.
+ */
+ STABLE("stable"),
+
+ /**
+ * Canary partition.
+ */
+ CANARY("canary");
+
+ private final String name;
+
+ CanaryDecision(final String name) {
+ this.name = name;
+ }
+
+ /**
+ * Get the lowercase partition name stored on the exchange.
+ *
+ * @return partition name
+ */
+ public String getName() {
+ return name;
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
new file mode 100644
index 0000000000..0b3bf16f4b
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
@@ -0,0 +1,37 @@
+/*
+ * 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.plugin.divide.canary;
+
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Assign requests to a partition without selecting or invoking upstreams.
+ */
+public interface CanaryDecisionService {
+
+ /**
+ * Decide the expected partition; a disabled Canary configuration selects
Stable.
+ *
+ * @param exchange current request
+ * @param ruleId stable rule identifier
+ * @param config present Canary configuration
+ * @return expected Stable or Canary partition
+ */
+ CanaryDecision decide(ServerWebExchange exchange, String ruleId,
CanaryConfig config);
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
new file mode 100644
index 0000000000..acf39a4e8a
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
@@ -0,0 +1,102 @@
+/*
+ * 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.plugin.divide.canary;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.canary.StickyKeyConfig;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+import org.apache.shenyu.plugin.base.condition.data.ParameterDataFactory;
+import org.apache.shenyu.plugin.base.condition.strategy.MatchStrategyFactory;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Objects;
+
+/**
+ * Deterministic request partitioning using conditions and SHA-256 buckets.
+ */
+public class DefaultCanaryDecisionService implements CanaryDecisionService {
+
+ @Override
+ public CanaryDecision decide(final ServerWebExchange exchange, final
String ruleId, final CanaryConfig config) {
+ Objects.requireNonNull(config, "Canary configuration is required");
+ if (!config.isEnabled()) {
+ return CanaryDecision.STABLE;
+ }
+ int percentage = config.getPercentage();
+ if (percentage < 0 || percentage > 100) {
+ throw new IllegalArgumentException("Canary percentage must be
between 0 and 100");
+ }
+ if (percentage == 0) {
+ return CanaryDecision.STABLE;
+ }
+ int matchMode = Objects.isNull(config.getMatchMode()) ?
MatchModeEnum.AND.getCode() : config.getMatchMode();
+ if (CollectionUtils.isNotEmpty(config.getConditions())
+ && !MatchStrategyFactory.match(matchMode,
config.getConditions(), exchange)) {
+ return CanaryDecision.STABLE;
+ }
+ if (percentage == 100) {
+ return CanaryDecision.CANARY;
+ }
+ String key = readStickyKey(exchange, config.getStickyKey());
+ if (StringUtils.isBlank(key)) {
+ return CanaryDecision.STABLE;
+ }
+ return hashToBucket(ruleId, key) < percentage * 100 ?
CanaryDecision.CANARY : CanaryDecision.STABLE;
+ }
+
+ private String readStickyKey(final ServerWebExchange exchange, final
StickyKeyConfig stickyKey) {
+ if (Objects.isNull(stickyKey) ||
StringUtils.isBlank(stickyKey.getParamType())) {
+ throw new IllegalArgumentException("Canary sticky key parameter
type is required");
+ }
+ // Pass the parameter name through unchanged; the selected source owns
its extraction semantics.
+ return ParameterDataFactory.builderData(stickyKey.getParamType(),
stickyKey.getParamName(), exchange);
+ }
+
+ /**
+ * Encode each UTF-8 field with a four-byte big-endian byte-length prefix.
+ * Interpret the first four SHA-256 bytes as an unsigned big-endian
integer.
+ * The percentage and upstream topology deliberately do not enter the hash.
+ *
+ * @param ruleId stable rule identifier
+ * @param key request identifier
+ * @return bucket from 0 to 9999
+ */
+ int hashToBucket(final String ruleId, final String key) {
+ Objects.requireNonNull(ruleId, "Canary rule identifier is required");
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ updateField(digest, ruleId);
+ updateField(digest, key);
+ return (int)
(Integer.toUnsignedLong(ByteBuffer.wrap(digest.digest()).getInt()) % 10000);
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 is unavailable", ex);
+ }
+ }
+
+ private void updateField(final MessageDigest digest, final String value) {
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
+ digest.update(bytes);
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
index acd2f36420..95e2929ab1 100644
---
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
@@ -20,12 +20,14 @@ package org.apache.shenyu.plugin.divide.handler;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.dto.RuleData;
import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfigValidator;
import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
import org.apache.shenyu.common.enums.PluginEnum;
-import org.apache.shenyu.common.utils.GsonUtils;
import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
import org.apache.shenyu.plugin.base.cache.CommonHandleCache;
import org.apache.shenyu.plugin.base.cache.MetaDataCache;
+import org.apache.shenyu.plugin.base.condition.data.ParameterDataFactory;
+import org.apache.shenyu.plugin.base.condition.judge.PredicateJudgeFactory;
import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
import org.apache.shenyu.plugin.base.utils.BeanHolder;
import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
@@ -64,7 +66,11 @@ public class DividePluginDataHandler implements
PluginDataHandler {
@Override
public void handlerRule(final RuleData ruleData) {
Optional.ofNullable(ruleData.getHandle()).ifPresent(s -> {
- DivideRuleHandle divideRuleHandle =
GsonUtils.getInstance().fromJson(s, DivideRuleHandle.class);
+ DivideRuleHandle divideRuleHandle =
CanaryConfigValidator.parseHandle(s);
+ if (Objects.nonNull(divideRuleHandle.getCanary())) {
+
CanaryConfigValidator.validateExtensions(divideRuleHandle.getCanary(),
+ ParameterDataFactory::newInstance,
PredicateJudgeFactory::newInstance);
+ }
CACHED_HANDLE.get().cachedHandle(CacheKeyUtils.INST.getKey(ruleData),
divideRuleHandle);
// the update is also need to clean, but there is no way to
// distinguish between crate and update, so it is always clean
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 70f80a3121..623d38091f 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
@@ -17,6 +17,7 @@
package org.apache.shenyu.plugin.divide.handler;
+import org.apache.shenyu.common.dto.DiscoverySyncData;
import org.apache.shenyu.common.dto.DiscoveryUpstreamData;
import org.apache.shenyu.common.enums.PluginEnum;
import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
@@ -30,6 +31,7 @@ import java.sql.Timestamp;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -38,6 +40,16 @@ import java.util.stream.Collectors;
*/
public class DivideUpstreamDataHandler extends
AbstractDiscoveryUpstreamDataHandler<Upstream> {
+ @Override
+ public void handlerDiscoveryUpstreamData(final DiscoverySyncData
discoverySyncData) {
+ if (Objects.isNull(discoverySyncData) ||
Objects.isNull(discoverySyncData.getSelectorId())) {
+ return;
+ }
+ // A selector may serve both Canary and legacy rules; retain both
pools for per-request selection.
+ submitUpstreamData(discoverySyncData.getSelectorId(),
convertUpstreamList(discoverySyncData.getUpstreamDataList()));
+ afterSubmitUpstreamData(discoverySyncData.getSelectorId());
+ }
+
@Override
public String pluginName() {
return PluginEnum.DIVIDE.getName();
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
new file mode 100644
index 0000000000..b9e6c9c5d1
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
@@ -0,0 +1,317 @@
+/*
+ * 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.plugin.divide;
+
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.DiscoverySyncData;
+import org.apache.shenyu.common.dto.DiscoveryUpstreamData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.api.result.DefaultShenyuResult;
+import org.apache.shenyu.plugin.api.result.ShenyuResult;
+import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
+import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecision;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
+import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
+import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies partition selection before load balancing, empty pools and legacy
compatibility.
+ */
+class DivideCanaryRoutingTest {
+
+ private RuleData rule;
+
+ private SelectorData selector;
+
+ private DivideRuleHandle handle;
+
+ private CanaryConfig config;
+
+ private CanaryDecisionService decisions;
+
+ private DividePlugin plugin;
+
+ @BeforeEach
+ void setUp() {
+ decisions = mock(CanaryDecisionService.class);
+ plugin = new DividePlugin(decisions);
+ rule = new RuleData();
+ rule.setId("canary-routing-rule");
+ rule.setSelectorId("canary-routing-selector");
+ selector = new SelectorData();
+ selector.setId(rule.getSelectorId());
+ rule.setHandle(GsonUtils.getGson().toJson(new DivideRuleHandle()));
+ new DividePluginDataHandler().handlerRule(rule);
+ handle =
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+ config = new CanaryConfig();
+ config.setEnabled(true);
+ config.setCanaryLabels(Map.of("release", "canary", "region", "east"));
+ config.setStableLabels(Map.of("release", "stable"));
+ handle.setCanary(config);
+ when(decisions.decide(any(), anyString(),
any())).thenReturn(CanaryDecision.CANARY);
+ ConfigurableApplicationContext context =
mock(ConfigurableApplicationContext.class);
+ when(context.getBean(ShenyuResult.class)).thenReturn(new
DefaultShenyuResult());
+ SpringBeanUtils.getInstance().setApplicationContext(context);
+ }
+
+ @AfterEach
+ void tearDown() {
+ new DividePluginDataHandler().removeRule(rule);
+ UpstreamCacheManager.getInstance().removeByKey(selector.getId());
+ }
+
+ @Test
+ void testAllLabelsMustMatchAndBackendFailureDoesNotFallback() {
+ Upstream canary = upstream("canary:8080", Map.of("release", "canary",
"region", "east", "extra", "value"));
+ Upstream wrongRegion = upstream("other:8080", Map.of("release",
"canary", "region", "west"));
+ ServerWebExchange exchange = exchange(null);
+ ShenyuPluginChain chain = current -> {
+ assertEquals("http://canary:8080",
current.getAttribute(Constants.HTTP_DOMAIN));
+ return Mono.error(new IllegalStateException("backend failed"));
+ };
+ try (MockedStatic<UpstreamCacheManager> cache =
cache(List.of(wrongRegion, canary, upstream("stable:8080", Map.of("release",
"stable"))))) {
+ StepVerifier.create(plugin.doExecute(exchange, chain, selector,
rule)).expectError(IllegalStateException.class).verify();
+ } finally {
+
UpstreamCircuitBreaker.getInstance().reset(UpstreamCircuitBreaker.buildKey(selector.getId(),
canary));
+ }
+ }
+
+ @Test
+ void testEmptyCanaryPoolFallsBackToStable() {
+ try (MockedStatic<UpstreamCacheManager> cache =
cache(List.of(upstream("stable:8080", Map.of("release", "stable"))))) {
+ assertDomain(exchange(null), "http://stable:8080");
+ }
+ }
+
+ @Test
+ void testRejectPolicyAndEmptyStablePool() {
+ config.setFallbackPolicy("REJECT");
+ assertRejected(List.of(upstream("stable:8080", Map.of("release",
"stable"))));
+ config.setFallbackPolicy("STABLE");
+ assertRejected(List.of(upstream("unlabelled:8080", Map.of())));
+ when(decisions.decide(any(), anyString(),
any())).thenReturn(CanaryDecision.STABLE);
+ assertRejected(List.of(upstream("canary:8080", Map.of("release",
"canary", "region", "east"))));
+ }
+
+ @Test
+ void testMissingLabelsCannotSelectWholePool() {
+ config.setCanaryLabels(null);
+ config.setStableLabels(Map.of());
+ assertRejected(List.of(upstream("unlabelled:8080", Map.of())));
+ }
+
+ @Test
+ void testNoHealthyUpstreamDoesNotDecide() {
+ assertRejected(List.of());
+ verifyNoInteractions(decisions);
+ }
+
+ @Test
+ void testAbsentConfigurationKeepsLegacyGrayPool() {
+ Upstream gray = upstream("gray:8080", Map.of());
+ gray.setGray(true);
+ try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(gray,
upstream("normal:8080", Map.of())))) {
+ handle.setCanary(null);
+ assertDomain(exchange(null), "http://gray:8080");
+ }
+ verifyNoInteractions(decisions);
+ }
+
+ @Test
+ void testNewAndLegacyRulesShareSelectorWithoutLosingStableNodes() {
+ DiscoverySyncData data = new DiscoverySyncData();
+ data.setSelectorId(selector.getId());
+ data.setUpstreamDataList(List.of(
+ instance("gray:8080", true, "canary"), instance("stable:8080",
false, "stable")));
+ new DivideUpstreamDataHandler().handlerDiscoveryUpstreamData(data);
+ plugin = new DividePlugin(new DefaultCanaryDecisionService());
+ config.setEnabled(false);
+ assertDomain(exchange(null), "http://stable:8080");
+ config.setEnabled(true);
+ config.setPercentage(100);
+ assertDomain(exchange(null), "http://gray:8080");
+ handle.setCanary(null);
+ assertDomain(exchange(null), "http://gray:8080");
+ assertEquals(1,
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId()).size());
+ assertEquals(2,
UpstreamCacheManager.getInstance().findAllUpstreamListBySelectorId(selector.getId()).size());
+ }
+
+ @Test
+ void testSpecifyDomainAndLegacyDoNotEscapeUnavailableGrayPool() {
+ UpstreamCacheManager manager = mock(UpstreamCacheManager.class);
+
when(manager.findAllUpstreamListBySelectorId(selector.getId())).thenReturn(List.of(upstream("stable:8080",
Map.of("release", "stable"))));
+
when(manager.findUpstreamListBySelectorId(selector.getId())).thenReturn(List.of());
+ ShenyuPluginChain chain = mock(ShenyuPluginChain.class);
+ try (MockedStatic<UpstreamCacheManager> cache =
mockStatic(UpstreamCacheManager.class)) {
+ cache.when(UpstreamCacheManager::getInstance).thenReturn(manager);
+ ServerWebExchange specified = exchange("stable:8080");
+ StepVerifier.create(plugin.doExecute(specified, chain, selector,
rule)).verifyComplete();
+ assertNull(specified.getAttribute(Constants.HTTP_DOMAIN));
+ handle.setCanary(null);
+ ServerWebExchange legacy = exchange(null);
+ StepVerifier.create(plugin.doExecute(legacy, chain, selector,
rule)).verifyComplete();
+ assertNull(legacy.getAttribute(Constants.HTTP_DOMAIN));
+ }
+ verifyNoInteractions(chain, decisions);
+ }
+
+ @Test
+ void testSpecifyDomainBypassesCanaryWithoutMutatingSharedNode() {
+ Upstream shared = upstream("original:8080", Map.of());
+ Upstream specified = upstream("override:9090", Map.of());
+ try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(shared,
specified))) {
+ assertDomain(exchange("override:9090"), "http://override:9090");
+ assertEquals("original:8080", shared.getUrl());
+ }
+ try (MockedStatic<UpstreamCacheManager> cache =
cache(List.of(shared))) {
+ handle.setCanary(null);
+ assertDomain(exchange("unconfigured:9090"),
"http://original:8080");
+ assertDomain(exchange(null), "http://original:8080");
+ }
+ verifyNoInteractions(decisions);
+ }
+
+ @Test
+ void testCircuitBreakerCannotSelectOutsideCanaryPartition() {
+ Upstream blocked = upstream("blocked-canary:8080", Map.of("release",
"canary", "region", "east"));
+ Upstream available = upstream("available-canary:8080",
Map.of("release", "canary", "region", "east"));
+ Upstream stable = upstream("stable:8080", Map.of("release", "stable"));
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+ String key = UpstreamCircuitBreaker.buildKey(selector.getId(),
blocked);
+ for (int i = 0; i < UpstreamCircuitBreaker.DEFAULT_FAILURE_THRESHOLD;
i++) {
+ breaker.recordFailure(key);
+ }
+ try {
+ try (MockedStatic<UpstreamCacheManager> cache =
cache(List.of(blocked, available, stable))) {
+ assertDomain(exchange(null), "http://available-canary:8080");
+ }
+ assertRejected(List.of(blocked, stable));
+ } finally {
+ breaker.reset(key);
+ }
+ }
+
+ @Test
+ void testHalfOpenProbeStaysInCanaryPool() {
+ Upstream canary = upstream("probe-canary:8080", Map.of("release",
"canary", "region", "east"));
+ Upstream stable = upstream("probe-stable:8080", Map.of("release",
"stable"));
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+ String key = UpstreamCircuitBreaker.buildKey(selector.getId(), canary);
+ for (int i = 0; i < UpstreamCircuitBreaker.DEFAULT_FAILURE_THRESHOLD;
i++) {
+ breaker.recordFailure(key);
+ }
+ Map<?, ?> breakers = (Map<?, ?>) ReflectionTestUtils.getField(breaker,
"breakers");
+ ReflectionTestUtils.setField(breakers.get(key), "openedAtMillis",
System.currentTimeMillis() - 60000L);
+ try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(stable,
canary))) {
+ ServerWebExchange exchange = exchange(null);
+ StepVerifier.create(plugin.doExecute(exchange, current -> {
+ assertEquals("http://probe-canary:8080",
current.getAttribute(Constants.HTTP_DOMAIN));
+
assertTrue(Boolean.TRUE.equals(current.getAttribute(UpstreamCircuitBreaker.HALF_OPEN_PROBE)));
+ return Mono.empty();
+ }, selector, rule)).verifyComplete();
+ assertFalse(breaker.isBlocking(key));
+ } finally {
+ breaker.reset(key);
+ }
+ }
+
+ private void assertRejected(final List<Upstream> upstreams) {
+ ServerWebExchange exchange = exchange(null);
+ ShenyuPluginChain chain = mock(ShenyuPluginChain.class);
+ try (MockedStatic<UpstreamCacheManager> cache = cache(upstreams)) {
+ StepVerifier.create(plugin.doExecute(exchange, chain, selector,
rule)).verifyComplete();
+ }
+ verifyNoInteractions(chain);
+ assertNull(exchange.getAttribute(Constants.HTTP_DOMAIN));
+ assertEquals(HttpStatus.SERVICE_UNAVAILABLE,
exchange.getResponse().getStatusCode());
+ }
+
+ private void assertDomain(final ServerWebExchange exchange, final String
domain) {
+ StepVerifier.create(plugin.doExecute(exchange, current ->
Mono.empty(), selector, rule)).verifyComplete();
+ assertEquals(domain, exchange.getAttribute(Constants.HTTP_DOMAIN));
+ }
+
+ private MockedStatic<UpstreamCacheManager> cache(final List<Upstream>
upstreams) {
+ UpstreamCacheManager manager = mock(UpstreamCacheManager.class);
+
when(manager.findAllUpstreamListBySelectorId(selector.getId())).thenReturn(upstreams);
+ List<Upstream> gray =
upstreams.stream().filter(Upstream::isGray).toList();
+
when(manager.findUpstreamListBySelectorId(selector.getId())).thenReturn(gray.isEmpty()
? upstreams : gray);
+ MockedStatic<UpstreamCacheManager> cache =
mockStatic(UpstreamCacheManager.class);
+ cache.when(UpstreamCacheManager::getInstance).thenReturn(manager);
+ return cache;
+ }
+
+ private ServerWebExchange exchange(final String domain) {
+ MockServerHttpRequest.BaseBuilder<?> request =
MockServerHttpRequest.get("/test");
+ if (Objects.nonNull(domain)) {
+ request.header(Constants.SPECIFY_DOMAIN, domain);
+ }
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+ exchange.getAttributes().put(Constants.CONTEXT, new ShenyuContext());
+ return exchange;
+ }
+
+ private Upstream upstream(final String url, final Map<String, String>
labels) {
+ Upstream upstream =
Upstream.builder().url(url).protocol("http://").weight(100).build();
+ upstream.setLabels(labels);
+ return upstream;
+ }
+
+ private DiscoveryUpstreamData instance(final String url, final boolean
gray, final String release) {
+ Map<String, Object> props = Map.of("gray", gray, "healthCheckEnabled",
false, "labels", Map.of("release", release, "region", "east"));
+ return
DiscoveryUpstreamData.builder().url(url).protocol("http://").status(0).props(GsonUtils.getInstance().toJson(props)).build();
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
new file mode 100644
index 0000000000..0000aa4a89
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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.plugin.divide.canary;
+
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.canary.StickyKeyConfig;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.base.utils.HostAddressUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.http.HttpCookie;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mockStatic;
+
+/**
+ * Tests the partition contract, including portable hash vectors and rollout
stability.
+ */
+class DefaultCanaryDecisionServiceTest {
+
+ private final DefaultCanaryDecisionService service = new
DefaultCanaryDecisionService();
+
+ @Test
+ void testPortableHashVectors() {
+ // Independently calculated SHA-256 vectors, including unsigned values
and UTF-8 byte lengths.
+ assertEquals(3033, service.hashToBucket("rule-1", "user-1"));
+ assertEquals(9925, service.hashToBucket("规则甲", "用户乙"));
+ assertEquals(912, service.hashToBucket("ab", "c"));
+ assertEquals(6726, service.hashToBucket("a", "bc"));
+ }
+
+ @Test
+ void testDisabledConfigurationAlwaysSelectsStable() {
+ CanaryConfig config = config(100, "unknown");
+ config.setEnabled(false);
+ config.setConditions(List.of(condition("user-1")));
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange("user-1"),
"rule-1", config));
+ config.setStickyKey(null);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange("user-1"),
"rule-1", config));
+ }
+
+ @Test
+ void testPercentageBoundariesAndEmptyOrConditions() {
+ CanaryConfig config = config(0, "header");
+ config.setStickyKey(null);
+ ServerWebExchange exchange = exchange("user-1");
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ config.setPercentage(100);
+ config.setMatchMode(1);
+ config.setConditions(List.of());
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1",
config));
+ }
+
+ @Test
+ void testConditionsAreEligibilityBeforePercentage() {
+ CanaryConfig config = config(100, "header");
+ config.setConditions(List.of(condition("user-1"),
condition("another-user")));
+ ServerWebExchange exchange = exchange("user-1");
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ config.setMatchMode(1);
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1",
config));
+ config.setMatchMode(null);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ config.setConditions(List.of(condition("user-1")));
+ config.setPercentage(30);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ config.setPercentage(31);
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1",
config));
+ }
+
+ @Test
+ void testStickySourcesAndMissingIdentifiers() {
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("/?user=user-1")
+ .header("user", "user-1").cookie(new HttpCookie("user",
"user-1")));
+ for (String type : List.of("header", "cookie", "query")) {
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange,
"rule-1", config(31, type)));
+ assertEquals(CanaryDecision.STABLE,
service.decide(MockServerWebExchange.from(MockServerHttpRequest.get("/")),
"rule-1", config(31, type)));
+ }
+ try (MockedStatic<HostAddressUtils> addresses =
mockStatic(HostAddressUtils.class)) {
+ addresses.when(() ->
HostAddressUtils.acquireIp(exchange)).thenReturn("user-1");
+ CanaryConfig config = config(31, "ip");
+ config.getStickyKey().setParamName(null);
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange,
"rule-1", config));
+ }
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange(" "),
"rule-1", config(99, "header")));
+ }
+
+ @Test
+ void testAdditionalBuiltinSourcesWithoutParameterName() {
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("http://example.com/orders?ignored=value"));
+ // Thresholds come from independently calculated buckets:
/orders=9824, GET=4747, example.com=391.
+ assertSourceThreshold(exchange, "uri", null, 99);
+ assertSourceThreshold(exchange, "req_method", null, 48);
+ assertSourceThreshold(exchange, "domain", null, 4);
+ try (MockedStatic<HostAddressUtils> addresses =
mockStatic(HostAddressUtils.class)) {
+ addresses.when(() ->
HostAddressUtils.acquireHost(exchange)).thenReturn("client.example.com");
+ assertSourceThreshold(exchange, "host", null, 55);
+ }
+ }
+
+ @Test
+ void testPostSourceReusesShenyuContextFields() {
+ ServerWebExchange exchange = exchange("ignored");
+ ShenyuContext context = new ShenyuContext();
+ context.setModule("user-1");
+ exchange.getAttributes().put(Constants.CONTEXT, context);
+ assertSourceThreshold(exchange, "post", "module", 31);
+ }
+
+ @Test
+ void testCustomSourceLoadedFromSpiResource() {
+ ServerWebExchange exchange = exchange("ignored");
+ exchange.getAttributes().put("authenticatedUserId", "user-1");
+ exchange.getAttributes().put("tenantUser", "user-2");
+ // Neither the source name nor its optional parameter name is built
into the decision service.
+ assertSourceThreshold(exchange, "test_attribute", null, 31);
+ assertSourceThreshold(exchange, "test_attribute", "tenantUser", 77);
+ }
+
+ @Test
+ void testCustomSourceMissingValueAndExtractionFailure() {
+ ServerWebExchange exchange = exchange("ignored");
+ CanaryConfig config = config(99, "test_attribute");
+ config.getStickyKey().setParamName(null);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ exchange.getAttributes().put("authenticatedUserId", " ");
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ exchange.getAttributes().put("authenticatedUserId", new Object());
+ assertThrows(ClassCastException.class, () -> service.decide(exchange,
"rule-1", config));
+ }
+
+ @Test
+ void testRequiredSourceType() {
+ ServerWebExchange exchange = exchange("user-1");
+ CanaryConfig missingType = config(50, null);
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", missingType));
+ for (String type : List.of("", " ")) {
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", config(50, type)));
+ }
+ }
+
+ @Test
+ void testBlankParameterNamesUseBuiltinExtraction() {
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("/?user=user-1")
+ .header("user", "user-1").cookie(new HttpCookie("user",
"user-1")));
+ ShenyuContext context = new ShenyuContext();
+ context.setModule("user-1");
+ exchange.getAttributes().put(Constants.CONTEXT, context);
+ for (String type : List.of("header", "cookie", "query", "post")) {
+ CanaryConfig missingName = config(50, type);
+ for (String paramName : new String[] {null, "", " "}) {
+ missingName.getStickyKey().setParamName(paramName);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange,
"rule-1", missingName));
+ }
+ }
+ }
+
+ @Test
+ void testRolloutExpansionPreservesUsersAcrossInstances() {
+ CanaryConfig tenPercent = config(10, "header");
+ CanaryConfig twentyPercent = config(20, "header");
+ DefaultCanaryDecisionService otherInstance = new
DefaultCanaryDecisionService();
+ int originalCanaries = 0;
+ int newCanaries = 0;
+ for (int i = 0; i < 1000; i++) {
+ ServerWebExchange exchange = exchange("user-" + i);
+ CanaryDecision before = service.decide(exchange, "rule-1",
tenPercent);
+ assertEquals(before, otherInstance.decide(exchange, "rule-1",
tenPercent));
+ CanaryDecision after = otherInstance.decide(exchange, "rule-1",
twentyPercent);
+ if (before == CanaryDecision.CANARY) {
+ originalCanaries++;
+ assertEquals(CanaryDecision.CANARY, after);
+ }
+ if (after == CanaryDecision.CANARY) {
+ newCanaries++;
+ }
+ }
+ assertTrue(originalCanaries > 0);
+ assertTrue(newCanaries > originalCanaries);
+ }
+
+ @Test
+ void testInvalidConfigurationIsNotARequestKeyMiss() {
+ ServerWebExchange exchange = exchange("user-1");
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", config(-1, "header")));
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", config(101, "header")));
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", config(50, "unknown")));
+ CanaryConfig missing = config(50, "header");
+ missing.setStickyKey(null);
+ assertThrows(IllegalArgumentException.class, () ->
service.decide(exchange, "rule-1", missing));
+ }
+
+ private void assertSourceThreshold(final ServerWebExchange exchange, final
String type, final String paramName, final int percentage) {
+ CanaryConfig config = config(percentage, type);
+ config.getStickyKey().setParamName(paramName);
+ assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1",
config));
+ config.setPercentage(percentage - 1);
+ assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1",
config));
+ }
+
+ private CanaryConfig config(final int percentage, final String type) {
+ CanaryConfig config = new CanaryConfig();
+ config.setEnabled(true);
+ config.setPercentage(percentage);
+ StickyKeyConfig stickyKey = new StickyKeyConfig();
+ stickyKey.setParamType(type);
+ stickyKey.setParamName("user");
+ config.setStickyKey(stickyKey);
+ return config;
+ }
+
+ private ServerWebExchange exchange(final String user) {
+ return
MockServerWebExchange.from(MockServerHttpRequest.get("/").header("user", user));
+ }
+
+ private ConditionData condition(final String value) {
+ ConditionData condition = new ConditionData();
+ condition.setParamType("header");
+ condition.setParamName("user");
+ condition.setOperator("equals");
+ condition.setParamValue(value);
+ return condition;
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.java
new file mode 100644
index 0000000000..f8ef8be370
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.java
@@ -0,0 +1,35 @@
+/*
+ * 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.plugin.divide.canary.fixture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.plugin.base.condition.data.ParameterData;
+import org.apache.shenyu.spi.Join;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Test-only extension that reads a string attribute and defines its own
default parameter name.
+ */
+@Join
+public class AttributeParameterData implements ParameterData {
+
+ @Override
+ public String builder(final String paramName, final ServerWebExchange
exchange) {
+ return exchange.getAttribute(StringUtils.defaultIfBlank(paramName,
"authenticatedUserId"));
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
index 70e496b9f4..26316ea20d 100644
---
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
@@ -18,6 +18,8 @@
package org.apache.shenyu.plugin.divide.handler;
import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
import org.apache.shenyu.common.dto.SelectorData;
import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
import org.apache.shenyu.common.enums.PluginEnum;
@@ -25,9 +27,12 @@ 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.entity.Upstream;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
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.Mock;
import org.mockito.MockedStatic;
@@ -36,11 +41,14 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
+import java.util.Map;
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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -111,4 +119,80 @@ public final class DividePluginDataHandlerTest {
public void removeRuleTest() {
dividePluginDataHandler.removeRule(ruleData);
}
+
+ @ParameterizedTest
+ @ValueSource(strings = {"\"percentage\":101", "\"percentage\":20.5",
"\"enabled\":true,\"percentage\":20",
+ "\"fallbackPolicy\":\"TYPO\"",
"\"stickyKey\":{\"paramType\":\"missing-extension\"}"})
+ public void testInvalidConfigurationDoesNotReplaceCachedRule(final String
fields) {
+ RuleData rule = new RuleData();
+ rule.setId("invalid-config-rule");
+ rule.setSelectorId("invalid-config-selector");
+ rule.setHandle("{\"timeout\":5000}");
+ dividePluginDataHandler.handlerRule(rule);
+ DivideRuleHandle previous =
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+
rule.setHandle("{\"canary\":{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"},"
+ fields + "}}");
+ try {
+ assertThrows(IllegalArgumentException.class, () ->
dividePluginDataHandler.handlerRule(rule));
+ assertSame(previous,
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+ } finally {
+ dividePluginDataHandler.removeRule(rule);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"default", "fixed", "exponential"})
+ public void testCanaryFailoverDoesNotReplaceCachedRule(final String
backoff) {
+ RuleData rule = new RuleData();
+ rule.setId("failover-config-rule");
+ rule.setSelectorId("failover-config-selector");
+ rule.setHandle("{\"timeout\":5000}");
+ dividePluginDataHandler.handlerRule(rule);
+ DivideRuleHandle previous =
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+
rule.setHandle("{\"retry\":2,\"retryStrategy\":\"failover\",\"retryBackOffSpec\":\""
+ backoff + "\","
+ +
"\"canary\":{\"enabled\":false,\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}}");
+ try {
+ assertThrows(IllegalArgumentException.class, () ->
dividePluginDataHandler.handlerRule(rule));
+ assertSame(previous,
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+ } finally {
+ dividePluginDataHandler.removeRule(rule);
+ }
+ }
+
+ @Test
+ public void testInstalledCustomParameterSourceCanBeCached() {
+ RuleData rule = new RuleData();
+ rule.setId("custom-source-rule");
+ rule.setSelectorId("custom-source-selector");
+
rule.setHandle("{\"canary\":{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"test_attribute\"},"
+ +
"\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}}");
+ try {
+ dividePluginDataHandler.handlerRule(rule);
+ DivideRuleHandle cached =
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+ assertEquals("test_attribute",
cached.getCanary().getStickyKey().getParamType());
+ } finally {
+ dividePluginDataHandler.removeRule(rule);
+ }
+ }
+
+ @Test
+ public void testRejectOverlappingLabelsBeforeReplacingCachedRule() {
+ RuleData rule = new RuleData();
+ rule.setId("label-validation-rule");
+ rule.setSelectorId("label-validation-selector");
+ CanaryConfig config = new CanaryConfig();
+ config.setEnabled(false);
+ config.setCanaryLabels(Map.of("release", "canary"));
+ config.setStableLabels(Map.of("release", "stable"));
+ DivideRuleHandle handle = new DivideRuleHandle();
+ handle.setCanary(config);
+ rule.setHandle(GsonUtils.getGson().toJson(handle));
+ dividePluginDataHandler.handlerRule(rule);
+ final DivideRuleHandle previous =
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+ config.setStableLabels(Map.of("region", "east"));
+ rule.setHandle(GsonUtils.getGson().toJson(handle));
+ assertThrows(IllegalArgumentException.class, () ->
dividePluginDataHandler.handlerRule(rule));
+ assertSame(previous,
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+ dividePluginDataHandler.removeRule(rule);
+ }
+
}
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 7e33f12756..b835ec1159 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
@@ -272,6 +272,42 @@ public class DivideUpstreamDataHandlerTest {
assertEquals(17, original.getSucceeded().get());
}
+ @Test
+ public void testLegacyGrayPolicyAndLiveFlagUpdates() {
+ DiscoveryUpstreamData gray = instance("gray:8080", "{\"gray\":true}");
+ DiscoveryUpstreamData ordinary = instance("ordinary:8080", "{}");
+ publish(List.of(gray, ordinary));
+ UpstreamCacheManager manager = UpstreamCacheManager.getInstance();
+ assertEquals(List.of("gray:8080"),
manager.findUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+ assertEquals(2,
manager.findAllUpstreamListBySelectorId("handler").size());
+ Upstream cached =
manager.findUpstreamListBySelectorId("handler").get(0);
+ cached.getSucceeded().set(17);
+ gray.setProps("{}");
+ ordinary.setProps("{\"gray\":true}");
+ publish(List.of(gray, ordinary));
+ assertEquals(List.of("ordinary:8080"),
manager.findUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+ assertFalse(cached.isGray());
+ assertEquals(17, cached.getSucceeded().get());
+ ordinary.setProps("{}");
+ publish(List.of(gray, ordinary));
+ assertEquals(2,
manager.findUpstreamListBySelectorId("handler").size());
+ }
+
+ @Test
+ public void testUnhealthyGrayDoesNotFallBackAndRemovalClearsGrayPolicy() {
+ DiscoveryUpstreamData gray = instance("gray:8080", "{\"gray\":true}");
+ gray.setStatus(1);
+ DiscoveryUpstreamData ordinary = instance("ordinary:8080", "{}");
+ publish(List.of(gray, ordinary));
+ UpstreamCacheManager manager = UpstreamCacheManager.getInstance();
+ assertTrue(manager.findUpstreamListBySelectorId("handler").isEmpty());
+ assertEquals(List.of("ordinary:8080"),
manager.findAllUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+ publish(List.of());
+ assertNull(manager.findAllUpstreamListBySelectorId("handler"));
+ publish(List.of(ordinary));
+ assertEquals(1,
manager.findUpstreamListBySelectorId("handler").size());
+ }
+
private DiscoveryUpstreamData instance(final String url, final String
props) {
return
DiscoveryUpstreamData.builder().protocol("http://").url(url).status(0).props(props).build();
}
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
new file mode 100644
index 0000000000..82e8a708a0
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
@@ -0,0 +1,18 @@
+# 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.
+
+
+test_attribute=org.apache.shenyu.plugin.divide.canary.fixture.AttributeParameterData
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
index ba75f15ecc..7e43448bfd 100644
---
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
@@ -22,10 +22,13 @@ import
org.apache.shenyu.plugin.api.context.ShenyuContextDecorator;
import org.apache.shenyu.plugin.base.handler.MetaDataHandler;
import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
import org.apache.shenyu.plugin.divide.DividePlugin;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
import org.apache.shenyu.plugin.divide.context.DivideShenyuContextDecorator;
import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
import org.apache.shenyu.plugin.divide.handler.DivideMetaDataHandler;
import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -40,11 +43,23 @@ public class DividePluginConfiguration {
/**
* init dividePlugin.
*
+ * @param canaryDecisionService request partition decision service
* @return {@linkplain DividePlugin}
*/
@Bean
- public ShenyuPlugin dividePlugin() {
- return new DividePlugin();
+ public ShenyuPlugin dividePlugin(final CanaryDecisionService
canaryDecisionService) {
+ return new DividePlugin(canaryDecisionService);
+ }
+
+ /**
+ * Default request partition decision service.
+ *
+ * @return Canary decision service
+ */
+ @Bean
+ @ConditionalOnMissingBean(CanaryDecisionService.class)
+ public CanaryDecisionService canaryDecisionService() {
+ return new DefaultCanaryDecisionService();
}
/**
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
index 619dd2dad3..c9f5767e99 100644
---
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
@@ -21,6 +21,8 @@ import org.apache.shenyu.plugin.api.ShenyuPlugin;
import org.apache.shenyu.plugin.api.context.ShenyuContextDecorator;
import org.apache.shenyu.plugin.base.handler.MetaDataHandler;
import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,6 +32,9 @@ import
org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
/**
* Test case for {@link DividePluginConfiguration}.
@@ -57,6 +62,20 @@ public class DividePluginConfigurationTest {
);
}
+ @Test
+ public void testDefaultCanaryDecisionService() {
+ applicationContextRunner.run(context ->
assertTrue(context.getBean(CanaryDecisionService.class) instanceof
DefaultCanaryDecisionService));
+ }
+
+ @Test
+ public void testCustomCanaryDecisionService() {
+ CanaryDecisionService custom = mock(CanaryDecisionService.class);
+ applicationContextRunner.withBean(CanaryDecisionService.class, () ->
custom).run(context -> {
+ assertSame(custom, context.getBean(CanaryDecisionService.class));
+ assertNotNull(context.getBean("dividePlugin", ShenyuPlugin.class));
+ });
+ }
+
@Test
public void testDividePluginDataHandler() {
applicationContextRunner.run(context -> {