Repository navigation
fix(grpc): drain client and bidi streams according to outbound readiness (#7927) - #8035
Conversation
Aias00
left a comment
There was a problem hiding this comment.
Approved. This is a real fix rather than a cosmetic rewrite: the previous code pushed the whole request list through onNext regardless of isReady(), and the new sender gates every send on readiness while preserving order, exactly-once submission and a single termination.
What I verified
- Read the production diff plus both new classes.
setOnReadyHandleris installed frombeforeStart, and I confirmed against the pinned runtime that this is the only legal window: grpc-java 1.65.1ClientCalls.startCallconstructsStreamObserverToCallListenerAdapterbeforecall.start(...), and that constructor invokesbeforeStart(adapter);CallToStreamObserverAdapter.setOnReadyHandlerthrowsIllegalStateException("Cannot alter onReadyHandler after call started. Use ClientResponseObserver")oncefreeze()runs immediately afterbeforeStart. The handler is therefore installed before the call starts and is read on every readiness delivery (StreamObserverToCallListenerAdapter.onReady->adapter.onReadyHandler.run()). So the fix is live, not silently falling back to the legacy loop. - The explicit
drain()instart()covers the "already ready at start" case where no false->true transition would ever deliveronReady. Good call. - Rollback: reverting only
ShenyuGrpcClient.javato base (keeping both new main classes and every test) fails exactly the 3 new streaming tests withexpected: <true> but was: <false>("must not send while the stream is not ready" x2 plus the cancellation case), while the 4 pre-existing tests stay green. - Mutation on
ReadinessAwareRequestSender: droppingobserver.isReady()fromdrainLoopfails 3 tests; makingterminate()a no-op fails 3 tests across both classes. Both guards are pinned. mvn -pl ...shenyu-plugin-grpc test-> 91/91 green;checkstyle:checkclean.- Concurrency: I walked the CAS/lock/re-check loop for lost wake-ups. A caller that loses the CAS returns, and the holder re-checks
terminal / cursor / isReady()after releasing the lock, so a readiness transition observed only by the loser is still picked up.onCompletedis reachable only by the lock holder, so it stays exactly-once. - No half-open stream on termination:
MessageWriter.triggerOverflowcancels the call, and a normal response-side completion already ends both directions.
Non-blocking comments
reentrantReadinessCallbackDoesNotDuplicateMessagesdoes not actually pin the CAS single-flight guard (details inline).- The two
FakeRequestObserverclasses duplicate ~100 lines; if extracted, modelling thefrozencontract would protect thebeforeStartinvariant (details inline). - Optional: no
RELEASE-NOTES.mdentry, while other recently merged fixes (#7969, #8043) added one. - #7927's own acceptance list also asks for a slow-reading backend run with a large finite request list. The fakes cannot show convergence, so that is worth recording on the issue rather than blocking here.
Nice work on keeping the request list as a snapshot with no extra queue and no extra executor.
| } | ||
|
|
||
| @Test | ||
| public void reentrantReadinessCallbackDoesNotDuplicateMessages() { |
There was a problem hiding this comment.
Mutation check: replacing draining.compareAndSet(false, true) with a plain draining.set(true) keeps all 8 tests green. With only 3 messages the reentrant fireReady() unwinds in 3 frames, and cursor.getAndIncrement() still prevents duplicates, so the guard this test is named after is not actually pinned.
The guard's real job is bounding reentrant recursion depth. A case with a few thousand requests and a reentrant fireReady() inside onNext, asserting no StackOverflowError, would catch its removal.
| }; | ||
| } | ||
|
|
||
| private static final class FakeRequestObserver extends ClientCallStreamObserver<DynamicMessage> { |
There was a problem hiding this comment.
This fake duplicates most of ReadinessAwareRequestSenderTest.FakeRequestObserver. If the two are extracted into a shared fixture, consider modelling the grpc-java adapter contract as well: CallToStreamObserverAdapter.setOnReadyHandler throws IllegalStateException once freeze() runs right after beforeStart.
Today a refactor that moved setOnReadyHandler out of beforeStart (for example into start()) would still pass both fakes, while production would throw on every client/bidi call. Making the fake throw once started would turn that invariant into a test failure instead of a runtime failure.
Problem
ShenyuGrpcClient.invoke()sendsCLIENT_STREAMINGandBIDI_STREAMINGrequests with an unconditionalrequestList.forEach(requestObserver::onNext)and never observesClientCallStreamObserver.isReady().onNextcan enqueue messages before the peer can consume them, creating avoidable outbound buffering when the backend reads slowly.Change
ClientResponseObserverwrapper for the two streaming branches. grpc-java callsbeforeStart(ClientCallStreamObserver)and only afterwards freezes the stream, so this is the supported hook to installsetOnReadyHandler; no cast to an internal adapter is needed.ReadinessAwareRequestSenderdrains the existing request list with a cursor only whileisReady(). A CAS single-drain lock serializes reentrant/concurrent readiness callbacks, and readiness is rechecked after releasing the lock so a callback that races the loop cannot strand the remaining messages.onCompleted()is called exactly once after the last message. Termination (response completion, response error, returned-future cancellation) stops further sends.ClientCallStreamObserver.cancel(...), so the response path ends inonErrorand the returned future cannot hang.Tests
ShenyuGrpcClientTest: client-streaming and bidi wiring throughMockedStatic<ClientCalls>with a non-ready fake observer — no sends while not ready, ordered delivery and one completion after readiness, and cancellation of the returned future stops pending sends.ReadinessAwareRequestSenderTest: all-ready; not-ready then ready; mid-drain readiness change; reentrantonReady(no duplicates, order preserved); termination before/after a partial drain; a send failure cancels once and does not complete; an empty list completes once; the readiness handler is installed before the first send.master: the streaming readiness test fails because the eager loop sends while the fake is not ready. Green after the change.Verification
shenyu-plugin-grpcfull module → 84/84checkstyle:check→ 0 violationsNo slow-reading-backend benchmark was run; deterministic readiness/cancellation tests cover the contract (the issue lists the slow-backend exercise as proposed validation). Unary and server-streaming behavior is unchanged.
Fixes #7927