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 dc59d7d438 fix(loadbalancer): count outstanding requests in
leastActive (#7986)
dc59d7d438 is described below
commit dc59d7d438e99280b7b010adc7c2d7fb64d5f8f3
Author: hanzhe-one <[email protected]>
AuthorDate: Fri Oct 23 10:06:54 2026 +0800
fix(loadbalancer): count outstanding requests in leastActive (#7986)
LeastActiveLoadBalance kept a private per-domain counter that was only ever
incremented on selection. It compared lifetime selections rather than
concurrent active requests, so completed requests kept skewing the choice
and a
newly added upstream received a catch-up burst until its counter matched the
others; weight and warmup could not protect it.
Read the active request count from Upstream#getInflight() instead, which the
divide plugin maintains for p2c. The gateway now reserves the selected
upstream
for leastActive and releases it on success, error and cancellation, in both
the
first attempt and the failover retry path. Upstreams that share the lowest
count are picked by weight, warmup included, which is also what protects a
new
node.
The per-domain count map, its recycling pass and the UpstreamIdentity
leastActiveKey are gone: identity-based accounting kept two upstreams with
the
same address but different group/version apart, and the inflight counter
does
that by construction.
Fixes #7972
---
.../loadbalancer/spi/LeastActiveLoadBalance.java | 139 ++++++---------
.../spi/LeastActiveLoadBalanceTest.java | 187 ++++++++++++---------
.../plugin/httpclient/DefaultRetryStrategy.java | 11 +-
.../plugin/httpclient/RetryStrategyTest.java | 29 ++++
.../apache/shenyu/plugin/divide/DividePlugin.java | 11 +-
.../shenyu/plugin/divide/DividePluginTest.java | 37 ++++
6 files changed, 239 insertions(+), 175 deletions(-)
diff --git
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
index 11af52eda1..017bf8fc3a 100644
---
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
+++
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalance.java
@@ -21,110 +21,69 @@ import
org.apache.shenyu.loadbalancer.entity.LoadBalanceData;
import org.apache.shenyu.loadbalancer.entity.Upstream;
import org.apache.shenyu.spi.Join;
-import java.util.Comparator;
import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.stream.Collectors;
+import java.util.concurrent.ThreadLocalRandom;
/**
* least active algorithm impl.
+ *
+ * <p>An upstream is active while it is serving a request, which the divide
plugin records in
+ * {@link Upstream#getInflight()}: the count is increased before a request is
forwarded and released on
+ * success, error and cancellation. The least active upstream wins; when
several upstreams share the lowest
+ * count they are picked by weight, warmup included, so a newly added upstream
is not sent a catch-up burst
+ * while its weight is still ramping up.
+ *
+ * <p>Selection only reads the count. A caller that does not maintain it sees
every upstream at the same
+ * baseline and the choice falls back to weight.
*/
@Join
public class LeastActiveLoadBalance extends AbstractLoadBalancer {
- private final int recyclePeriod = 60000;
-
- private final ConcurrentMap<String, ActiveCount> countMap = new
ConcurrentHashMap<>(16);
-
- private final AtomicBoolean updateLock = new AtomicBoolean();
-
- private volatile long lastRecycle;
-
@Override
protected Upstream doSelect(final List<Upstream> upstreamList, final
LoadBalanceData data) {
- long now = System.currentTimeMillis();
- Map<String, Upstream> domainMap = upstreamList.stream()
- .collect(Collectors.toConcurrentMap(upstream ->
upstream.identity().leastActiveKey(), upstream -> upstream));
-
- domainMap.keySet().forEach(domain -> {
- ActiveCount activeCount = countMap.computeIfAbsent(domain, key ->
new ActiveCount(now));
- activeCount.setLastUpdate(now);
- });
-
- final String domain = countMap.entrySet().stream()
- // Ensure that the filtered domain is included in the
domainMap.
- .filter(entry -> domainMap.containsKey(entry.getKey()))
- .min(Comparator.comparingLong(entry ->
entry.getValue().getCount()))
- .map(Map.Entry::getKey)
- .orElse(upstreamList.get(0).buildDomain());
-
- ActiveCount activeCount = countMap.get(domain);
- if (Objects.nonNull(activeCount)) {
- activeCount.increase();
- }
-
- // A removed domain's entry lingers for up to recyclePeriod, safely
excluded from selection meanwhile.
- if (!updateLock.get() && now - lastRecycle > recyclePeriod &&
updateLock.compareAndSet(false, true)) {
- try {
- countMap.entrySet().removeIf(item -> now -
item.getValue().getLastUpdate() > recyclePeriod);
- lastRecycle = now;
- } finally {
- updateLock.set(false);
+ int length = upstreamList.size();
+ long leastActive = Long.MAX_VALUE;
+ int leastCount = 0;
+ int[] leastIndexes = new int[length];
+ int[] weights = new int[length];
+ int totalWeight = 0;
+ int firstWeight = 0;
+ boolean sameWeight = true;
+
+ for (int i = 0; i < length; i++) {
+ Upstream upstream = upstreamList.get(i);
+ long active = upstream.getInflight().get();
+ int afterWarmup = getWeight(upstream);
+ weights[i] = afterWarmup;
+ if (active < leastActive) {
+ leastActive = active;
+ leastCount = 1;
+ leastIndexes[0] = i;
+ totalWeight = afterWarmup;
+ firstWeight = afterWarmup;
+ sameWeight = true;
+ } else if (active == leastActive) {
+ leastIndexes[leastCount++] = i;
+ totalWeight += afterWarmup;
+ if (sameWeight && i > 0 && afterWarmup != firstWeight) {
+ sameWeight = false;
+ }
}
}
- return domainMap.get(domain);
- }
- /**
- * The type Active count.
- */
- protected static class ActiveCount {
-
- private final AtomicLong count = new AtomicLong(Long.MIN_VALUE);
-
- private volatile long lastUpdate;
-
- ActiveCount(final long lastUpdate) {
- this.lastUpdate = lastUpdate;
+ if (leastCount == 1) {
+ return upstreamList.get(leastIndexes[0]);
}
-
- /**
- * Increase count.
- */
- void increase() {
- count.addAndGet(1);
- }
-
- /**
- * Get count.
- *
- * @return the count
- */
- long getCount() {
- return count.get();
- }
-
- /**
- * Gets last update.
- *
- * @return the last update
- */
- long getLastUpdate() {
- return lastUpdate;
- }
-
- /**
- * Sets last update.
- *
- * @param lastUpdate the last update
- */
- void setLastUpdate(final long lastUpdate) {
- this.lastUpdate = lastUpdate;
+ if (!sameWeight && totalWeight > 0) {
+ int offsetWeight =
ThreadLocalRandom.current().nextInt(totalWeight);
+ for (int i = 0; i < leastCount; i++) {
+ int leastIndex = leastIndexes[i];
+ offsetWeight -= weights[leastIndex];
+ if (offsetWeight < 0) {
+ return upstreamList.get(leastIndex);
+ }
+ }
}
+ return
upstreamList.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]);
}
}
diff --git
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
index 21fe11361b..047f8cccee 100644
---
a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
+++
b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/spi/LeastActiveLoadBalanceTest.java
@@ -22,10 +22,9 @@ import org.apache.shenyu.loadbalancer.entity.Upstream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
-import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
/**
* The type least activity load balance test.
@@ -50,114 +49,138 @@ public class LeastActiveLoadBalanceTest {
}
@Test
- public void testResponseTimeBalancer() throws Exception {
+ public void testResponseTimeBalancer() {
buildUpstreamList();
final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
- Upstream upstream = leastActiveLoadBalance.doSelect(onlyOneList, new
LoadBalanceData());
- Upstream upstream1 = leastActiveLoadBalance.doSelect(onlyOneList, new
LoadBalanceData());
- Assertions.assertTrue(upstream.getUrl().equals("baidu.com") &&
upstream1.getUrl().equals("pro.jd.com")
- || upstream1.getUrl().equals("baidu.com") &&
upstream.getUrl().equals("pro.jd.com"));
+ List<Upstream> selected = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ selected.add(leastActiveLoadBalance.doSelect(onlyOneList, new
LoadBalanceData()));
+ }
+ // Every upstream is idle, so both share the traffic.
+ Assertions.assertTrue(selected.contains(onlyOneList.get(0)));
+ Assertions.assertTrue(selected.contains(onlyOneList.get(1)));
}
@Test
- public void testSameAddressVariantsKeepIndependentCounts() throws
Exception {
+ public void testSameAddressVariantsKeepIndependentCounts() {
Upstream first =
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("blue").version("v1").build();
Upstream second =
Upstream.builder().protocol("dubbo://").url("127.0.0.1:20880").group("green").version("v2").build();
List<Upstream> upstreamList = List.of(first, second);
LeastActiveLoadBalance loadBalance = new LeastActiveLoadBalance();
- Upstream firstSelection = loadBalance.doSelect(upstreamList, new
LoadBalanceData());
- Upstream secondSelection = loadBalance.doSelect(upstreamList, new
LoadBalanceData());
-
- Assertions.assertNotNull(firstSelection);
- Assertions.assertNotNull(secondSelection);
- Assertions.assertNotSame(firstSelection, secondSelection);
- Assertions.assertEquals(2, getCountMap(loadBalance).size());
+ // The two variants share an address but keep separate active counts.
+ first.getInflight().set(5);
+ for (int i = 0; i < 20; i++) {
+ Assertions.assertSame(second, loadBalance.doSelect(upstreamList,
new LoadBalanceData()));
+ }
+ first.getInflight().set(1);
+ second.getInflight().set(5);
+ for (int i = 0; i < 20; i++) {
+ Assertions.assertSame(first, loadBalance.doSelect(upstreamList,
new LoadBalanceData()));
+ }
}
+ /**
+ * A completed request must stop competing with outstanding work: while
the slow upstream holds one
+ * request, every selection goes to the idle upstream instead of
alternating with the busy one.
+ */
@Test
- public void testRemoveStaleCountMapEntries() throws Exception {
+ public void testSlowOutstandingRequestReceivesNoFurtherTraffic() {
buildUpstreamList();
+ Upstream busy = onlyOneList.get(0);
+ Upstream idle = onlyOneList.get(1);
+ busy.getInflight().set(2);
final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- Assertions.assertEquals(2, getCountMap(leastActiveLoadBalance).size());
- onlyOneList.remove(1);
- // The periodic cleanup is time-driven, simulate the elapsed time so
that the entry of the
- // removed upstream becomes stale and is evicted by the next cleanup.
- long old = System.currentTimeMillis() - 60_001;
- setLastUpdate(leastActiveLoadBalance, "https://pro.jd.com", old);
- setLastRecycle(leastActiveLoadBalance, old);
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- Map<String, LeastActiveLoadBalance.ActiveCount> countMap =
getCountMap(leastActiveLoadBalance);
- Assertions.assertEquals(1, countMap.size());
- Assertions.assertTrue(countMap.containsKey("https://baidu.com"));
- Assertions.assertFalse(countMap.containsKey("https://pro.jd.com"));
+
+ for (int i = 0; i < 50; i++) {
+ Assertions.assertSame(idle,
leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData()));
+ }
+ // Releasing the outstanding request lets the upstream compete again.
+ busy.getInflight().set(1);
+ List<Upstream> selected = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ selected.add(leastActiveLoadBalance.doSelect(onlyOneList, new
LoadBalanceData()));
+ }
+ Assertions.assertTrue(selected.contains(busy));
}
+ /**
+ * The selections that preceded a long-running request are completed work.
They must not make the busy
+ * upstream look idle, whatever the gateway did before that request
started.
+ */
@Test
- public void testCountMapNotAffectOtherSelector() throws Exception {
+ public void testCumulativeSelectionsDoNotMaskOutstandingWork() {
buildUpstreamList();
- final List<Upstream> anotherList = new ArrayList<>();
-
anotherList.add(Upstream.builder().url("jd.com").protocol("https://").build());
+ Upstream busy = onlyOneList.get(0);
+ Upstream idle = onlyOneList.get(1);
final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- leastActiveLoadBalance.doSelect(anotherList, new LoadBalanceData());
- Assertions.assertEquals(3, getCountMap(leastActiveLoadBalance).size());
- // Selector 1 removes pro.jd.com, and the cleanup must not evict the
live entry jd.com
- // which only belongs to selector 2.
- onlyOneList.remove(1);
- long old = System.currentTimeMillis() - 60_001;
- setLastUpdate(leastActiveLoadBalance, "https://pro.jd.com", old);
- setLastRecycle(leastActiveLoadBalance, old);
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- Map<String, LeastActiveLoadBalance.ActiveCount> countMap =
getCountMap(leastActiveLoadBalance);
- Assertions.assertEquals(2, countMap.size());
- Assertions.assertTrue(countMap.containsKey("https://baidu.com"));
- Assertions.assertTrue(countMap.containsKey("https://jd.com"));
- Assertions.assertFalse(countMap.containsKey("https://pro.jd.com"));
+ for (int i = 0; i < 100; i++) {
+ leastActiveLoadBalance.doSelect(onlyOneList, new
LoadBalanceData());
+ }
+ busy.getInflight().set(2);
+ for (int i = 0; i < 20; i++) {
+ Assertions.assertSame(idle,
leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData()));
+ }
}
+ /**
+ * A newly added upstream starts idle, as do the existing ones. Weight,
warmup included, decides between
+ * the upstreams that share the lowest active count, so the new node is
not sent every request.
+ */
@Test
- public void testCountMapNotAffectOtherSelectorWhenFirstRemoved() throws
Exception {
- buildUpstreamList();
- final List<Upstream> anotherList = new ArrayList<>();
-
anotherList.add(Upstream.builder().url("jd.com").protocol("https://").build());
+ public void testNewlyAddedUpstreamIsNotFlooded() {
+ Upstream a =
Upstream.builder().protocol("http://").url("a:80").weight(100).status(true).build();
+ Upstream b =
Upstream.builder().protocol("http://").url("b:80").weight(100).status(true).build();
+ Upstream c =
Upstream.builder().protocol("http://").url("c:80").weight(100).status(true).build();
final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- leastActiveLoadBalance.doSelect(anotherList, new LoadBalanceData());
- Assertions.assertEquals(3, getCountMap(leastActiveLoadBalance).size());
- // Symmetric case: selector 1 removes the first upstream baidu.com.
The cleanup must evict
- // baidu.com but keep pro.jd.com (still served by selector 1) and
jd.com (served by selector 2).
- onlyOneList.remove(0);
- long old = System.currentTimeMillis() - 60_001;
- setLastUpdate(leastActiveLoadBalance, "https://baidu.com", old);
- setLastRecycle(leastActiveLoadBalance, old);
- leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
- Map<String, LeastActiveLoadBalance.ActiveCount> countMap =
getCountMap(leastActiveLoadBalance);
- Assertions.assertEquals(2, countMap.size());
- Assertions.assertTrue(countMap.containsKey("https://pro.jd.com"));
- Assertions.assertTrue(countMap.containsKey("https://jd.com"));
- Assertions.assertFalse(countMap.containsKey("https://baidu.com"));
- }
- @SuppressWarnings("unchecked")
- private Map<String, LeastActiveLoadBalance.ActiveCount> getCountMap(final
LeastActiveLoadBalance loadBalance) throws Exception {
- Field field =
LeastActiveLoadBalance.class.getDeclaredField("countMap");
- field.setAccessible(true);
- return (Map<String, LeastActiveLoadBalance.ActiveCount>)
field.get(loadBalance);
+ for (int i = 0; i < 100; i++) {
+ leastActiveLoadBalance.doSelect(List.of(a, b), new
LoadBalanceData());
+ }
+ int newNodeHits = 0;
+ for (int i = 0; i < 50; i++) {
+ if (leastActiveLoadBalance.doSelect(List.of(a, b, c), new
LoadBalanceData()) == c) {
+ newNodeHits++;
+ }
+ }
+ // Equal weights and equal active counts: the new node takes its third
of the traffic, not all of it.
+ Assertions.assertTrue(newNodeHits < 50, "the new upstream was selected
" + newNodeHits + " out of 50 times");
+ Assertions.assertTrue(newNodeHits > 0, "the new upstream was never
selected");
}
- private void setLastUpdate(final LeastActiveLoadBalance loadBalance, final
String domain, final long lastUpdate) throws Exception {
- LeastActiveLoadBalance.ActiveCount activeCount =
getCountMap(loadBalance).get(domain);
- Assertions.assertNotNull(activeCount);
- Field field =
LeastActiveLoadBalance.ActiveCount.class.getDeclaredField("lastUpdate");
- field.setAccessible(true);
- field.set(activeCount, lastUpdate);
+ /**
+ * Upstreams that share the lowest count are picked by weight, so an
upstream whose warmup is still
+ * ramping up receives proportionally less traffic than a fully warm one.
+ */
+ @Test
+ public void testWeightBreaksTiesAmongLeastActiveUpstreams() {
+ Upstream warm =
Upstream.builder().protocol("http://").url("warm:80").weight(100).status(true)
+ .timestamp(System.currentTimeMillis() -
60_000).warmup(60_000).build();
+ // One tenth of its warmup elapsed, so its effective weight is 10
against the fully warm 100.
+ Upstream ramping =
Upstream.builder().protocol("http://").url("ramping:80").weight(100).status(true)
+ .timestamp(System.currentTimeMillis() -
60_000).warmup(600_000).build();
+ List<Upstream> upstreamList = List.of(warm, ramping);
+ final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
+
+ int rampingHits = 0;
+ for (int i = 0; i < 400; i++) {
+ if (leastActiveLoadBalance.doSelect(upstreamList, new
LoadBalanceData()) == ramping) {
+ rampingHits++;
+ }
+ }
+ Assertions.assertTrue(rampingHits > 0, "the ramping upstream was never
selected");
+ Assertions.assertTrue(rampingHits < 100, "the ramping upstream was
selected " + rampingHits + " out of 400 times");
}
- private void setLastRecycle(final LeastActiveLoadBalance loadBalance,
final long lastRecycle) throws Exception {
- Field field =
LeastActiveLoadBalance.class.getDeclaredField("lastRecycle");
- field.setAccessible(true);
- field.set(loadBalance, lastRecycle);
+ @Test
+ public void testInflightCountIsReadNotReserved() {
+ buildUpstreamList();
+ onlyOneList.get(0).setInflight(new AtomicLong(3));
+ onlyOneList.get(1).setInflight(new AtomicLong(1));
+ final LeastActiveLoadBalance leastActiveLoadBalance = new
LeastActiveLoadBalance();
+ leastActiveLoadBalance.doSelect(onlyOneList, new LoadBalanceData());
+ // Selection leaves the accounting to the request lifecycle.
+ Assertions.assertEquals(3, onlyOneList.get(0).getInflight().get());
+ Assertions.assertEquals(1, onlyOneList.get(1).getInflight().get());
}
}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
index 01c9c96f3f..89698bddfa 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
@@ -18,6 +18,7 @@
package org.apache.shenyu.plugin.httpclient;
import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.enums.LoadBalanceEnum;
import org.apache.shenyu.common.enums.RetryEnum;
import org.apache.shenyu.common.exception.ShenyuException;
import org.apache.shenyu.common.utils.UriUtils;
@@ -150,10 +151,16 @@ public class DefaultRetryStrategy<R> implements
RetryStrategy<R> {
// in order not to affect the next retry call, newUri needs to be
excluded
exclude.add(newUri);
final String breakerKey = UpstreamCircuitBreaker.buildKey(selectorId,
upstream);
- return UpstreamCircuitBreaker.recordOutcome(httpClientPlugin
+ Mono<R> resend = httpClientPlugin
.doRequest(exchange, exchange.getRequest().getMethod().name(),
newUri, httpClientPlugin.getCachedRequestBody(exchange))
.timeout(duration, Mono.error(() -> new
TimeoutException("Response took longer than timeout: " + duration)))
- .doOnError(e -> LOG.error(e.getMessage(), e)), breakerKey);
+ .doOnError(e -> LOG.error(e.getMessage(), e));
+ if (LoadBalanceEnum.LEAST_ACTIVITY.getName().equals(loadBalance)) {
+ // Least active compares outstanding requests, so this attempt
stays reserved until it terminates.
+ upstream.getInflight().incrementAndGet();
+ resend = resend.doFinally(signalType ->
upstream.getInflight().decrementAndGet());
+ }
+ return UpstreamCircuitBreaker.recordOutcome(resend, breakerKey);
}
private boolean isSameUpstream(final URI first, final URI second) {
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
index 6649a340f7..941e4c32fc 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
@@ -53,6 +53,7 @@ import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -194,6 +195,34 @@ public class RetryStrategyTest {
assertEquals(URI.create(standby.buildDomain() + "/test?mode=retry"),
uriCaptor.getValue());
}
+ @Test
+ void testFailoverReservesAndReleasesLeastActiveAccounting() {
+ AbstractHttpClientPlugin<String> plugin =
mock(AbstractHttpClientPlugin.class);
+ ServerWebExchange exchange =
createFailoverExchange("http://localhost:8080/test?mode=retry");
+ exchange.getAttributes().put(Constants.LOAD_BALANCE, "leastActive");
+ Upstream failed = Upstream.builder().url("localhost:8080").build();
+ Upstream standby =
Upstream.builder().protocol("http://").url("localhost:8081").build();
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+
when(cacheManager.findUpstreamListBySelectorId("selector-7475")).thenReturn(Arrays.asList(failed,
standby));
+ when(plugin.getCachedRequestBody(exchange)).thenReturn(Flux.empty());
+ AtomicLong inflightDuringResend = new AtomicLong();
+ when(plugin.doRequest(eq(exchange), eq("GET"), any(URI.class),
any())).thenReturn(Mono.defer(() -> {
+ inflightDuringResend.set(standby.getInflight().get());
+ return Mono.just("success");
+ }));
+
+ try (MockedStatic<UpstreamCacheManager> cacheMock =
mockStatic(UpstreamCacheManager.class)) {
+
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+ StepVerifier.create(new
DefaultRetryStrategy<>(plugin).execute(Mono.error(new
TimeoutException("upstream failed")),
+ exchange, Duration.ofSeconds(5), 2))
+ .expectNext("success")
+ .verifyComplete();
+ }
+ // the attempt is counted while it runs and released once it terminates
+ assertEquals(2, inflightDuringResend.get());
+ assertEquals(1, standby.getInflight().get());
+ }
+
@Test
void testFailoverWithMissingUpstreamList() {
AbstractHttpClientPlugin<String> httpClientPlugin =
mock(AbstractHttpClientPlugin.class);
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 bb3ea89222..199005dc67 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
@@ -60,6 +60,8 @@ public class DividePlugin extends AbstractShenyuPlugin {
private static final String P2C = "p2c";
+ private static final String LEAST_ACTIVE = "leastActive";
+
private static final String SHORTEST_RESPONSE = "shortestResponse";
@Override
@@ -145,6 +147,11 @@ public class DividePlugin extends AbstractShenyuPlugin {
Object error = ShenyuResultWrap.error(exchange,
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
return WebFluxResultUtils.result(exchange, error);
}
+ if (LEAST_ACTIVE.equals(ruleHandle.getLoadBalance())) {
+ // Least active compares outstanding requests, so the choice is
reserved here and released
+ // once the request terminates, whatever its outcome.
+ upstream.getInflight().incrementAndGet();
+ }
// set domain
String domain = upstream.buildDomain();
exchange.getAttributes().put(Constants.HTTP_DOMAIN, domain);
@@ -199,7 +206,9 @@ public class DividePlugin extends AbstractShenyuPlugin {
private Mono<Void> forwardWithCircuitBreaker(final ServerWebExchange
exchange, final ShenyuPluginChain chain,
final DivideRuleHandle
ruleHandle, final String breakerKey, final Upstream upstream, final boolean
probe) {
Mono<Void> response = Mono.defer(() -> chain.execute(exchange));
- if (ruleHandle.getLoadBalance().equals(P2C)) {
+ if (ruleHandle.getLoadBalance().equals(LEAST_ACTIVE)) {
+ response = response.doFinally(signalType ->
upstream.getInflight().decrementAndGet());
+ } else if (ruleHandle.getLoadBalance().equals(P2C)) {
response = response.doFinally(signalType ->
responseTrigger(upstream));
} else if (ruleHandle.getLoadBalance().equals(SHORTEST_RESPONSE)) {
long beginTime = System.currentTimeMillis();
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginTest.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginTest.java
index 1a08d32821..9691027b46 100644
---
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginTest.java
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginTest.java
@@ -282,6 +282,43 @@ public final class DividePluginTest {
}
}
+ @Test
+ public void leastActiveInflightShouldBeReservedAndReleased() {
+ DivideRuleHandle ruleHandle =
DividePluginDataHandler.CACHED_HANDLE.get()
+ .obtainHandle(CacheKeyUtils.INST.getKey(ruleData));
+ ruleHandle.setLoadBalance("leastActive");
+ Upstream upstream = Upstream.builder().url("http://upstream").build();
+ when(chain.execute(exchange)).thenReturn(Mono.empty());
+
+ try (MockedStatic<LoadbalancerUtils> loadbalancerUtils =
mockStatic(LoadbalancerUtils.class)) {
+ loadbalancerUtils.when(() ->
LoadbalancerUtils.getForExchange(any(), anyString(), any()))
+ .thenReturn(upstream);
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData)).verifyComplete();
+ assertEquals(1, upstream.getInflight().get());
+ }
+ }
+
+ @Test
+ public void leastActiveInflightShouldBeReleasedOnCancellation() {
+ DivideRuleHandle ruleHandle =
DividePluginDataHandler.CACHED_HANDLE.get()
+ .obtainHandle(CacheKeyUtils.INST.getKey(ruleData));
+ ruleHandle.setLoadBalance("leastActive");
+ Upstream upstream = Upstream.builder().url("http://upstream").build();
+ upstream.getInflight().set(2);
+ when(chain.execute(exchange)).thenReturn(Mono.never());
+
+ try (MockedStatic<LoadbalancerUtils> loadbalancerUtils =
mockStatic(LoadbalancerUtils.class)) {
+ loadbalancerUtils.when(() ->
LoadbalancerUtils.getForExchange(any(), anyString(), any()))
+ .thenReturn(upstream);
+ Disposable subscription = dividePlugin.doExecute(exchange, chain,
selectorData, ruleData).subscribe();
+ assertEquals(3, upstream.getInflight().get());
+
+ subscription.dispose();
+
+ assertEquals(2, upstream.getInflight().get());
+ }
+ }
+
@Test
public void successResponseTriggerTest() throws Exception {
dividePlugin = DividePlugin.class.newInstance();