Repository navigation
fix(loadbalancer): count outstanding requests for websocket and dubbo - #8043
Conversation
LeastActiveLoadBalance reads Upstream#getInflight(), but only the divide plugin maintained it. WebSocket and apache dubbo gray routing left every upstream at the same baseline, so leastActive degenerated to weighted random on those paths. WebSocketPlugin reserves the chosen upstream when the proxied session is subscribed and releases it in doFinally, mirroring DividePlugin. The reservation sits inside Mono.defer so a session that is never subscribed cannot leak the count. The dubbo gray load balance has no lifecycle hook of its own, so it hands back a delegating Invoker for leastActive. The count is increased when the RPC starts rather than when the invoker is selected: Dubbo's cluster can pick an invoker and then discard the pick, and a selection-time reservation would leak those counts. It is released exactly once, on result completion or on a synchronous throw. The wrapper delegates equals and hashCode to the invoker it wraps. Dubbo's cluster keeps the selected invoker in its already-tried list and in its connectivity-invalidation set and compares by identity there, so a wrapper that did not delegate equality would make failover retry the same provider and would stop unhealthy providers from being removed. shortestResponse also needs the average elapsed time of successful requests, which only divide records, so it still falls back to weight outside the divide plugin. The grpc picker, the tcp proxy and the java sdk rebuild their Upstream objects on every call and cannot share a count; both limits are documented in the balancer javadoc and RELEASE-NOTES.
Aias00
left a comment
There was a problem hiding this comment.
Both regressions are real and the fix is the right shape.
Verification I ran locally
- Reverting the two changed main sources and dropping
InflightInvokerleaves exactly the three discriminating cases red:WebSocketPluginTest.leastActiveShouldCountTheProxiedSession,ApacheDubboGrayLoadBalanceTest.countingWrapperShouldKeepTheIdentityOfTheWrappedInvokerand#leastActiveShouldCountTheRpcWhileItRuns. With the patch: websocket 18/18, apache-dubbo 57/57,checkstyle:checkclean on both modules. - The gate matches the selection in both paths:
WebSocketPlugin.doExecutepicksupstreamwithruleHandle.getLoadBalance()andApacheDubboGrayLoadBalancepicks it withdubboRuleHandle.getLoadBalance(), so the counter is maintained exactly on the paths whose selection reads it. No other caller is silently affected. - The
Upstreambeing counted in the dubbo path is the oneLoadbalancerUtils.getForNoExchangereturned fromUpstreamCacheManager, i.e. the same objectLeastActiveLoadBalancereads — not a per-invocation copy. That is the correct pairing. - Reserving on
invoke()instead of on selection is the right call for the reasons you give, andAtomicBooleanmakes the release exactly-once across all three exits (throw, null result, completion). Theequals/hashCodedelegation is genuinely load-bearing —invokers.contains(selected)andindexOfin the test pin theBitList.remove/ sticky-routing behaviour.
Needs doing before merge
The branch is CONFLICTING against master; please rebase.
Nits
WebSocketPluginincrements beforedoFinallyis attached; if that call ever threw, the count would leak. Attaching first and incrementing after (orMono.usingWhen) closes the window.- In the dubbo path, when
upstreamwas chosen by leastActive butinvokerGrayscomes back empty, the code falls back todubboSelect(invokers, ...)with no reservation at all. Nothing leaks, but that one branch is an exception to "every request that went through leastActive selection is counted" — worth a comment so the next reader does not read it as an oversight. InflightInvokerhas notoString, so retry/invalidation log lines will name the wrapper instead of the provider;getUrl()is delegated so this is cosmetic.
| // the proxied session runs and is released when it terminates, whatever its outcome. The reservation | ||
| // happens on subscription, so a session that is never subscribed does not leak the count. | ||
| return Mono.defer(() -> { | ||
| upstream.getInflight().incrementAndGet(); |
There was a problem hiding this comment.
The increment runs before doFinally is attached. It cannot throw today, but attaching the release first and incrementing afterwards (or using Mono.usingWhen) removes the ordering assumption entirely — as written, any throw between these two lines leaks a permanent count for that upstream.
| @@ -84,11 +85,30 @@ public <T> Invoker<T> select(final List<Invoker<T>> invokers, final URL url, fin | |||
| if (CollectionUtils.isEmpty(invokerGrays)) { | |||
| return dubboSelect(invokers, url, invocation); | |||
There was a problem hiding this comment.
When upstream was picked by leastActive but the gray filter matches no invoker, this fallback selects from the full list and reserves nothing, so the count never moves even though the shenyu-side selection read it. Not a leak, but it is the one branch that breaks the "counted for the whole RPC" invariant the rest of the change establishes — a one-line comment saying the pick is discarded would keep it from reading as an oversight.
…llers # Conflicts: # RELEASE-NOTES.md
Aias00
left a comment
There was a problem hiding this comment.
The Dubbo side reads well: counting at invoke rather than at selection is the right choice, because a selection that the cluster later discards never gets released, and delegating equals/hashCode to the wrapped invoker is the detail that keeps Dubbo's already-tried and connectivity-invalidation sets working — that is the part most wrappers get wrong.
I have one question about the WebSocket half, and one nit on the Dubbo wrapper.
WebSocket: does the returned Mono really stay pending for the session lifetime?
The count is released in doFinally on the Mono returned by WebSocketService.handleRequest. Shenyu's bean is a plain HandshakeWebSocketService (WebSocketPluginConfiguration:94), and HandshakeWebSocketService.handleRequest delegates to requestUpgradeStrategy.upgrade(...), whose Mono completes when the upgrade handshake is done. The proxied session then runs inside the container, driven by ShenyuWebSocketHandler.handle(session)'s own subscription, not by the Mono the plugin holds. If that is so, a WebSocket that stays open for an hour is counted for a few milliseconds, and least-active still sees nothing from the WebSocket path.
The tests cannot settle this, because webSocketService is mocked to return Mono.never() — that pins the assumption rather than the real service. Can you confirm the lifetime, and if it is the handshake, move the decrement to where the session actually ends (e.g. ShenyuWebSocketHandler.handle returning session.receive().then() with a doFinally, or an explicit session.close() hook)? A note in the PR body on how this was verified against a real connection would be enough for me.
InflightInvoker.released is per-invoker, not per-invocation
Invoker is a reusable object by contract — Dubbo's own RpcStatus counts per invocation for exactly this reason. If the same wrapper instance is invoked twice, the second call increments and then hits released.compareAndSet(false, true) == false, so its count is never returned. In the current call path withInflight builds a fresh wrapper per select, so it does not happen today, but the guard would be more obviously correct created inside invoke and captured by the callback.
| // happens on subscription, so a session that is never subscribed does not leak the count. | ||
| return Mono.defer(() -> { | ||
| upstream.getInflight().incrementAndGet(); | ||
| return proxySession.doFinally(signalType -> upstream.getInflight().decrementAndGet()); |
There was a problem hiding this comment.
HandshakeWebSocketService.handleRequest completes when the upgrade handshake finishes, not when the proxied session ends, so this doFinally may fire immediately after the handshake. The tests mock Mono.never(), which cannot distinguish the two. Please confirm against a real connection.
Review on apache#8043 showed the count was attached to the wrong Mono. The Mono returned by WebSocketService.handleRequest completes when the upgrade handshake finishes: reactor-netty's withWebsocketSupport returns a Mono over the handshaker promise and subscribes the session handler separately inside doOnEach. A session open for an hour was therefore counted for a few milliseconds, and leastActive still saw nothing from the websocket path. The count now sits on the WebSocketHandler the service is given, whose Mono terminates when the session ends. It is released on that Mono's termination, and also when the handler fails before starting or returns nothing, so a failure cannot leave the upstream reserved. InflightInvoker had the same class of bug in miniature: a single AtomicBoolean per invoker meant a second invocation on a reused invoker increased the count and never released it. The guard is now per call. Dubbo's own RpcStatus counts per invocation for the same reason. Also from the review: the dubbo fallback that discards a gray pick now says so, and the wrapper delegates toString so log lines name the provider. The websocket tests no longer stub the service to Mono.never(), which pinned the wrong assumption. They drive the real handler and assert that the count is still held after the handshake completes.
|
Thanks for the review — the WebSocket point was correct and it invalidated that half of the change. Pushed WebSocket lifetime: you were right, the count was on the handshake. I traced it in the bytecode rather than trusting the tests. so the Mono is over the handshake promise, and The fix wraps the Verification against a real connection: I did not run one — this environment cannot open a local socket, which is why
Increment-before- Dubbo fallback branch. Added the comment you asked for — the gray pick is discarded because it matched no provider, so that upstream is not carrying the request.
The one open item is the real-connection verification above; everything else is addressed in |
Aias00
left a comment
There was a problem hiding this comment.
Approved. Counting inflight for websocket sessions (InflightWebSocketHandler counts connected sessions, not handshakes) and dubbo gray-route invocations makes least-active balancing accurate for those transports. Wrapping the invoker in InflightInvoker (delegating equals/hashCode) avoids breaking Dubbo retry/cluster comparisons. RELEASE-NOTES updated. Good work.
Fixes #8004
Problem
#7986madeLeastActiveLoadBalancereadUpstream#getInflight()instead of a private per-domain counter. OnlyDividePlugin(and the failover attempts inDefaultRetryStrategy) maintain that value, so on the other paths that callLoadbalancerUtilsevery upstream sits at the same baseline andleastActivedegenerates to weighted random — a regression against the previous behaviour, where selections were at least accumulated.The affected call sites named in the issue are the websocket plugin and apache dubbo gray routing.
Changes
shenyu-plugin-websocketWebSocketPluginreserves the chosen upstream forleastActivewhen the proxied session is subscribed and releases it indoFinally, mirroringDividePlugin. The reservation is insideMono.defer, so a session that is built but never subscribed cannot leak the count.shenyu-plugin-apache-dubboInflightInvoker, a delegatingInvokerthat increases the upstream's count when the RPC starts and releases it exactly once: on result completion, or when the invocation throws before producing a result.ApacheDubboGrayLoadBalancewraps the selected invoker in it forleastActiveonly. Other algorithms keep the plain invoker.shenyu-loadbalancer/ docsLeastActiveLoadBalanceandShortestResponseLoadBalancer, and aRELEASE-NOTESentry, state which paths maintain the count and which do not.Two decisions worth reviewing
The reservation is on
invoke(), not on selection. Dubbo's cluster can select an invoker and then discard the pick (already tried, unavailable, or replaced by a reselect). A selection-time reservation would leak those counts permanently.invoke()is the only place that observes both the start and the end of the RPC.The wrapper delegates
equalsandhashCode.AbstractClusterInvoker.doSelectkeeps the returned invoker in its already-tried list,reselectandFailoverClusterInvokerdedupe withList.contains, sticky routing checksinvokers.contains(stickyInvoker), andaddInvalidateInvokerremoves the selected invoker fromvalidInvokersviaBitList.remove. All of these compare withequals, and no Dubbo invoker overrides it, so they are identity comparisons. A wrapper that compared by its own identity would make failover retry a provider it already tried, silently disable sticky routing and stop unhealthy providers from being invalidated. Delegating equality keeps the wrapper transparent to all of them.Not covered here
shortestResponseremains weight-based outside the divide plugin. It needs the average elapsed time of successful requests as well as the outstanding count, and onlyDividePlugin.successResponseTriggerrecords that. Reserving inflight alone would not change the outcome, and connection lifetime is a poor proxy for WebSocket latency. This is stated in the javadoc andRELEASE-NOTESrather than silently left.Upstreamobjects on every call (convertUpstreamList), so a count kept on them could never be shared across requests. They are documented as weight-based for both algorithms.Validation
./mvnw -pl shenyu-loadbalancer,shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide test— 70 and 102 tests, 0 failures../mvnw -pl shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-websocket test— 18 tests, 0 failures../mvnw -pl shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo test— 57 tests, 0 failures../mvnw -pl shenyu-plugin/shenyu-plugin-httpclient -Dtest=RetryStrategyTest test— 25 tests, 0 failures.New tests, each verified against the pre-fix source:
WebSocketPluginTest#leastActiveShouldCountTheProxiedSession— fails without the fix (expected: <2> but was: <1>).WebSocketPluginTest#leastActiveShouldReleaseOnCompletionand#otherAlgorithmsShouldNotCountTheProxiedSession.ApacheDubboGrayLoadBalanceTest#leastActiveShouldCountTheRpcWhileItRuns— fails without the fix (expected: <2> but was: <1>); asserts the count is held while the future is incomplete and released on completion.ApacheDubboGrayLoadBalanceTest#countingWrapperShouldKeepTheIdentityOfTheWrappedInvoker— fails without the fix, since the unwrapped invoker is returned.ApacheDubboGrayLoadBalanceTest#leastActiveShouldReleaseWhenTheRpcThrows.ApacheDubboGrayLoadBalanceTest#otherAlgorithmsShouldReturnTheSelectedInvokerUnchanged.