Repository navigation
Prevent Fn data plane deadlock via non-blocking CancellableQueue.add and gRPC inbound flow control - #40788
Prevent Fn data plane deadlock via non-blocking CancellableQueue.add and gRPC inbound flow control#40788sjvanrossum wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
📋 Review Summary
This PR introduces manual gRPC inbound flow control on BeamFnDataGrpcMultiplexer, requesting a new Elements message only after a bundle consumer finishes processing, drops, or rejects the current message. While the overall approach is much simpler than PR #40606, there are several critical flow-control bugs and edge cases—including permanent stalls when messages contain multiple instruction IDs, broken flow control when BeamFnDataInboundObserver is reused across bundles via reset(), and an order-dependent callback wiring issue in registerConsumer—that must be addressed before merging.
🔍 General Feedback
-
Multi-instruction
Elementsmessages cause permanent gRPC stalls: UnderdisableAutoInboundFlowControl(), gRPC provides only 1 initial permit. When a singleElementsmessage is split across$N > 1$ instructions inInboundObserver.onNext, only 1 permit was consumed on the wire, yet$N$ callbacks are created, which either stalls when$N-1$ consumers wait foris_lastor inflates the in-flight window by$N-1$ permits. -
BeamFnDataInboundObserverreuse across bundles (reset()):ProcessBundleHandlercachesBeamFnDataInboundObserverinstances and callsreset()between bundles before callingregisterConsumer()again, which breaks theonElementsConsumedlifecycle as currently written. -
Wait-with-timeout before poisoning unregistered consumers: Replacing
consumerFuture.get(3, TimeUnit.HOURS)with an immediateExecutionExceptionifregisterConsumerhas not completed within thecomputeIfAbsentblock (or immediately thereafter) changes the contract when data arrives slightly before bundle registration finishes.
💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.
💬 Inline Findings
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java(line 236): 🔴 Critical: When anElementsmessage contains emptydataandtimerslists (instructionId == null),onNextreturns without callingrequest(1). BecausedisableAutoInboundFlowControl()is enabled and this message consumed the single in-flight gRPC delivery permit, failing to callrequest(1)here permanently stalls the inbound gRPC stream. Callrequest(1)before returning.
if (instructionId == null) {
request(1);
return;
}
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java(line 258): 🔴 Critical: In the multi-instruction slow path, a single inbound gRPCElementsmessage (which consumed only 1 gRPC permit) is split and forwarded toinstructionIds.size()different consumers:
- If those consumers are
BeamFnDataInboundObserverinstances that already haveonElementsConsumed = this::requestwired, each of the$N$ bundles will callrequest(1)when it processes its split piece—inflating the gRPC flow-control window by$N - 1$ extra permits per multi-instruction message. - Worse, if the consumers are not yet registered when
onNextruns, the first loop iteration blocks inconsumerFuture.get(3, TimeUnit.HOURS)and if the bundle needs subsequent messages before completing, or ifregisterConsumerwas called beforebeforeStart, permits are either never requested or requested$N$ times.
Instead of relying on per-consumer callbacks firing request(1) for a split message behind an AtomicInteger(instructionIds.size()) countdown (as in #40606) or ensure exactly 1 permit is replenished per onNext invocation.
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java(line 122): 🔴 Critical:inboundObserver.requestObserveris only initialized when gRPC invokesbeforeStart()on the client call. IfregisterConsumer()is called beforebeforeStart()runs (for example, in unit tests, or if a consumer is registered while the channel/stub is still starting),inboundObserver.requestObserver != nullisfalse, sosetOnElementsConsumed(inboundObserver::request)is skipped and never wired even afterbeforeStart()later setsrequestObserver.
Additionally, inboundObserver.requestObserver is read on the thread calling registerConsumer and written on the gRPC thread in beforeStart without volatile or synchronization.
Always wire ((BeamFnDataInboundObserver) receiver).setOnElementsConsumed(inboundObserver::request) unconditionally in registerConsumer, make requestObserver volatile, and have inboundObserver.request(int) check ClientCallStreamObserver<BeamFnApi.Elements> observer = this.requestObserver; if (observer != null) { observer.request(count); }.
if (receiver instanceof BeamFnDataInboundObserver) {
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataInboundObserver.java(line 157): 🟠 High:BeamFnDataInboundObserverinstances are cached and reused across bundles byProcessBundleHandler(which callsdataReceiver.reset()before reusing the observer for a new instruction). Two issues arise:
onElementsConsumedis a non-volatilefield written byregisterConsumer(which may run on a different thread than the bundle execution thread or gRPC inbound thread) and read insidereset(),close(), andawaitCompletion().- When
ProcessBundleHandlercallsreset()at the end of a bundle (afterawaitCompletion()has already closed the queue),reset()clearscancellationException. If any late data was queued or ifreset()is called before reuse,onElementsConsumedshould bevolatile.
private volatile @Nullable Consumer<Integer> onElementsConsumed;
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java(line 318): 🟠 High: Whenconsumeris not aBeamFnDataInboundObserver(e.g., a customCloseableFnDataReceiveror in tests),request(1)is only called ifconsumer.accept(value)succeeds. Ifconsumer.accept(value)throws an exception,poisonInstructionId(instructionId)is called in thecatchblock,inboundObserver.requestObserver == nullisfalse(whenrequestObserver != null), andrequest(1)is never called for the failed message—permanently leaking the only gRPC flow-control permit and stalling the stream for all subsequent bundles.
Similarly, if consumer is a BeamFnDataInboundObserver and consumer.accept(value) throws (because the bundle already failed/closed its CancellableQueue), queue.put(elements) throws cancellationException without ever enqueuing the element, so awaitCompletion()/close() never sees it and never calls onElementsConsumed.accept(1)! Thus, whenever consumer.accept(value) throws, the permit for value is leaked unless request(1) is called in the catch (Exception e) block.
} catch (Exception e) {
LOG.error("Failed to handle data for instruction {}", instructionId, e);
poisonInstructionId(instructionId);
request(1);
}
sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java(line 292): 🟠 High: WithdisableAutoInboundFlowControl()(which sets the initial gRPC window to1), blocking the gRPC inbound thread insideconsumer.accept(value)(queue.put(elements)) until the bundle thread callsqueue.take()and finishesmultiplexElementsbefore callingrequest(1)can deadlock pipelines when:
- A bundle has a large
CancellableQueue(default capacity 10,000), soqueue.put()does not block, but with an initial window of1, gRPC will not deliver message #2 until message #1 is completely processed bymultiplexElements()andonElementsConsumed.accept(1)is called. - If a transform buffers elements in memory across multiple
Elementsmessages (or waits for a side input / timer / second input in the same bundle) while emitting output that fills the outbound gRPC buffer, or if message #1 is for Bundle A (whose transform is blocked on output backpressure) and message #2 on the same multiplexed stream isis_lastor timer data needed by Bundle B to unblock the runner, having an initial window of only1(with norequest(1)untilmultiplexElementsfinishes) serializes all inbound messages across all concurrent bundles on the multiplexer to 1 in-flight message at a time.
|
Checks are failing. Will not request review until checks are succeeding. If you'd like to override that behavior, comment |
|
This was certainly unexpected since I added this test and it passes locally...
|
4ba17b5 to
af2841e
Compare
I'm guessing this was supposed to be regarding line 326 of Edit: I just realized this was a stale review. Triggering another one. |
|
Assigning reviewers: R: @kennknowles for label java. This pull request likely touches a core component ("core" label). Please review with scrutiny. Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #40788 +/- ##
===========================================
- Coverage 80.04% 0 -80.05%
===========================================
Files 490 0 -490
Lines 82916 0 -82916
===========================================
- Hits 66373 0 -66373
+ Misses 16543 0 -16543
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/gemini review |
There was a problem hiding this comment.
📋 Review Summary
This PR resolves a bidirectional gRPC flow-control deadlock on the Fn Data plane by forwarding beforeStart through ForwardingClientResponseObserver, disabling automatic inbound flow control in BeamFnDataGrpcMultiplexer, switching inbound delivery to a non-blocking CancellableQueue.add, and tracking buffered elements via a lock-free VarHandle state machine. The overall design and unit test coverage are thorough and well-structured.
🔍 General Feedback
- The bit-packed
flowControlStatestate machine inBeamFnDataGrpcMultiplexercleanly coordinatesrequest(1)parking and wake-ups across the gRPCSerializingExecutorthread and bundle processing threads. - There are a couple of thread-visibility and registration-ordering details around
BeamFnDataInboundObserver.onElementsConsumedandBeamFnDataGrpcMultiplexer.registerConsumerworth tightening up.
💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.
Fixes a bidirectional flow-control deadlock on the Java SDK Fn API data plane stream (as described in #40606) without introducing secondary per-instruction queues or per-element allocations.
On a client-side gRPC stream (
OutboundObserverFactory.DirectClient/BufferingStreamObserver), gRPC uses a single per-callSerializingExecutorto execute bothInboundObserver.onNext(inbound elements) andClientCallStreamObserver.onReady(which callsAdvancingPhaser.arrive()to unblock outboundDirectStreamObserver.onNextcalls).When a bundle processing thread in
BeamFnDataInboundObserver.awaitCompletion()emits elements back over the same data plane stream and blocks inDirectStreamObserver.onNextwaiting for the outbound stream to become ready (AdvancingPhaser), it stops draining itsCancellableQueue(capacity100). Once100inboundElementsmessages accumulate in thatCancellableQueue, the nextInboundObserver.onNextblocks inCancellableQueue.put()directly on the gRPCSerializingExecutorthread. Because theSerializingExecutorthread is blocked input(), the pendingonReadycallback queued behind it on the sameSerializingExecutorcan never run to unblock the consumer thread, resulting in a permanent deadlock.Benchmarks
ProcessBundleBenchmarkexecuted on Apple M5 Pro../gradlew :sdks:java:harness:jmh:jmh --args="org.apache.beam.fn.harness.jmh.ProcessBundleBenchmark -p elementsEmbedding=false -f 1 -wi 3 -i 5 -w 2s -r 2s -foe=true"testLargeBundle14,991.4 ± 496.3 ops/s14,931.6 ± 119.8 ops/s-0.4%testTinyBundle35,573.1 ± 1,610.7 ops/s37,160.9 ± 223.5 ops/s+4.5%testStateWithCaching6,748.1 ± 167.0 ops/s6,768.4 ± 38.9 ops/s+0.3%testStateWithoutCaching3,936.6 ± 216.9 ops/s4,113.7 ± 96.3 ops/s+4.5%