Skip to content

[Java SDK harness] Prevent a Fn data plane deadlock in BeamFnDataGrpcMultiplexer - #40606

Open
prodriguezdefino wants to merge 5 commits into
apache:masterfrom
prodriguezdefino:java/fn-data-elastic-inbound
Open

prodriguezdefino wants to merge 5 commits into
apache:masterfrom
prodriguezdefino:java/fn-data-elastic-inbound

Conversation

@prodriguezdefino

Copy link
Copy Markdown
Contributor

When a bundle's input queue is full, BeamFnDataGrpcMultiplexer blocks in onNext.

gRPC runs onNext and the outbound onReady callback on the same executor, so a bundle thread that waits in DirectStreamObserver for onReady only continues when its wait times out (up to 60 s). The runner logs "Ignoring data element byte limit … to avoid stuckness" and the job crawls or stalls. We hit this on Dataflow with a Java step fused between two steps of another SDK.

The inbound data stream now uses manual gRPC flow control, so onNext never blocks. A message that does not fit in the bundle queue waits in a buffer, and the stream asks for more messages as bundles finish them. While output waits on flow control, it asks for one more message every 500 ms, up to 100 for each active bundle. DirectStreamObserver checks isReady() before each message.

Related to #20028.

@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 56.16%. Comparing base (76f6e6d) to head (9388a8a).
⚠️ Report is 320 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff            @@
##             master   #40606   +/-   ##
=========================================
  Coverage     56.16%   56.16%           
  Complexity     2288     2288           
=========================================
  Files          1124     1124           
  Lines        178336   178336           
  Branches       1489     1489           
=========================================
  Hits         100170   100170           
  Misses        75645    75645           
  Partials       2521     2521           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@prodriguezdefino
prodriguezdefino force-pushed the java/fn-data-elastic-inbound branch from 2bdb35a to 8de9191 Compare October 15, 2026 22:49
@prodriguezdefino
prodriguezdefino marked this pull request as ready for review October 16, 2026 20:40
@github-actions

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @ahmedabu98 for label java.
Backup reviewer: damccorm (if the main reviewer is unresponsive, you can escalate to them by tagging their username)

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 assign to next reviewer.

Available commands:

  • stop reviewer notifications - opt out of the automated review tooling
  • remind me after tests pass - tag the comment author after tests pass
  • waiting on author - shift the attention set back to the author (any comment or push by the author will return the attention set to the reviewers)

The PR bot will only process comments in the main thread (not review comments).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

This PR introduces per-bundle inbound queuing in BeamFnDataInboundObserver and adds a StalledPollHandler hook across DirectStreamObserver and ForwardingClientResponseObserver so that when outbound gRPC writes block on flow control, the bundle thread can cooperatively drain pending inbound elements for other bundles from BeamFnDataGrpcMultiplexer. The architectural approach effectively addresses cross-bundle head-of-line blocking when inbound and outbound data share a single gRPC stream.

🔍 General Feedback

  • Thread-Safety & Concurrency: There is a critical synchronization gap when BeamFnDataGrpcMultiplexer.tryDrainPendingForOtherBundles invokes BeamFnDataInboundObserver.accept concurrently from a stalled bundle's worker thread while the gRPC event loop thread is also calling accept for the same bundle.
  • Deadlock Prevention in Buffered Mode: The stall-callback mechanism is only wired into DirectStreamObserver (OutboundObserverFactory.Direct), leaving BufferingStreamObserver (beam_fn_api_buffered_stream) vulnerable to the same deadlock when its bounded CancellableQueue fills up.

💡 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/BeamFnDataInboundObserver.java (line 122): 🔴 Critical: accept is no longer exclusively called on the serialized gRPC inbound thread: BeamFnDataGrpcMultiplexer.tryDrainPendingForOtherBundles can invoke consumer.accept(element) on a stalled bundle worker thread concurrently with the gRPC thread calling consumer.accept(nextElement) for the same instructionId (once the consumer has been registered in consumers).

Because accept is not synchronized (and Status fields like numMessages and byteCounter are plain non-volatile primitives), concurrent calls to accept on the same BeamFnDataInboundObserver instance can:

  1. Reorder elements within the same bundle (e.g., if isLast=true arrives on the gRPC thread while drainPending is mid-flight, queue.close() can be called before earlier elements are enqueued into queue, causing those elements to be silently dropped or processed out of order).
  2. Cause data races on status.numMessages and status.byteCounter.

Consider synchronizing accept (or guarding enqueueing + status updates under a lock) and ensuring BeamFnDataGrpcMultiplexer.registerConsumer holds a per-instruction lock while draining pending elements before exposing the consumer to concurrent callers.

  public synchronized void accept(BeamFnApi.Elements t) throws Exception {
  • sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java (line 146): 🟠 High: In registerConsumer, consumers.put(instructionId, consumer) happens before drainPending(instructionId, consumer). If an inbound message arrives on the gRPC thread immediately after consumers.put(...), InboundObserver.onNext will bypass pendingElements and deliver the new message directly to consumer.accept(val) before drainPending has finished delivering the older buffered messages, resulting in out-of-order element delivery (or early stream termination if the new message has isLast=true).

Additionally, tryDrainPendingForOtherBundles and registerConsumer can both call drainPending concurrently for the same instructionId, causing two threads to poll from the same ConcurrentLinkedQueue and deliver elements out of order.

Synchronizing on the Queue<Elements> (or a per-instruction lock) across registerConsumer, drainPending, and the fallback path in onNext would preserve strict FIFO ordering.

  • sdks/java/core/src/main/java/org/apache/beam/sdk/fn/stream/OutboundObserverFactory.java (line 140): 🟡 Medium: OutboundObserverFactory.Buffered ignores the new ForwardingClientResponseObserver.create(inboundObserver, inboundObserver::onCompleted) overload that forwards StalledPollHandler, and BufferingStreamObserver does not invoke onStalledPoll when queue.put(value) blocks on a full buffer while the background drainer thread is stalled in DirectStreamObserver.onNext. When the beam_fn_api_buffered_stream experiment is enabled, a bundle thread that fills the outbound BufferingStreamObserver queue will block in queue.put without ever calling onStalledPoll(), reintroducing the cross-bundle deadlock.

When a bundle queue was full, onNext of the inbound data stream blocked.
gRPC runs onNext and the onReady callback of the outbound stream on the
same executor. So the outbound stream could not become ready again, and
the bundle, the gRPC thread and the runner waited for each other.

The inbound stream uses manual gRPC flow control and gives messages to the
bundle queue without blocking. A message that does not fit waits in a
buffer. The stream asks for one more message each time a bundle finishes
one. When output waits 500 ms on flow control, the stream asks for one
extra message, up to 100 for each active bundle. Each bundle queue keeps
its limit of 100 messages.

DirectStreamObserver checks isReady before each message and checks again
every 50 ms while it waits.
@prodriguezdefino
prodriguezdefino force-pushed the java/fn-data-elastic-inbound branch from 8de9191 to 9d92655 Compare October 16, 2026 21:01
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I pushed 9d92655, which addresses the buffered-mode point and fixes a separate deadlock I found while checking the other two.

Concurrent accept and ordering during registerConsumer (🔴 / 🟠).

I don't think these apply to the code in this PR. There is no tryDrainPendingForOtherBundles, consumers map or ConcurrentLinkedQueue in the change, and onStalledPoll() only asks gRPC for one more inbound message (request(1)), it never calls accept. Every handoff to a bundle goes through its ReceiverHolder, whose methods (accept, complete, drainPending, poison, close) are all synchronized.

registerConsumer sets the receiver and drains the buffer inside complete(), under that same lock. An onNext that arrives at the same time waits for it, and then accept() drains whatever is still pending before it adds the new element. So a newer element can't overtake buffered ones, including one with isLast=true.

The bundle thread does hand off buffered elements from its completion callback, so I added a test for that rather than relying on reading the code:

  • BeamFnDataGrpcMultiplexerTest.testConcurrentHandoffKeepsOrder: the gRPC thread sends 3,000 elements to a slow consumer, so the bundle queue fills and the bundle thread drains the pending buffer while new elements arrive. The consumer registers in the middle of the stream. Every element arrives once and in order.

Lock-order deadlock in CancellableQueue.cancel.

I found this while checking the paths above. cancel(e, onDrop) ran the drop callbacks while holding the queue lock, and those callbacks take the holder lock. Meanwhile the gRPC thread holds the holder lock and calls tryPut, which needs the queue lock. If a bundle failed while its data was still arriving, the two threads could deadlock. cancel now collects the dropped elements under the lock and runs onDrop after releasing it.

  • CancellableQueueTest.testCancelWithOnDropDoesNotDeadlockWithLockedProducer: a producer holds its own lock while it calls tryPut, and the cancel's onDrop takes that lock. It deadlocks on the old code and passes now.
  • CancellableQueueTest.testOnDropRunsWithoutQueueLock: another thread can take the queue lock while onDrop runs.

Buffered mode (🟡).

Good catch. The main deadlock was already fixed there, because inbound onNext no longer blocks, but the stall hook wasn't wired in. OutboundObserverFactory.Buffered now passes the inbound StalledPollHandler to BufferingStreamObserver. While the drainer waits on flow control, it re-checks isReady() every 50 ms and calls onStalledPoll() every 500 ms, as DirectStreamObserver does. Without a handler, the wait is unchanged.

  • BufferingStreamObserverTest.testStalledPollHandlerInvokedWhileNotReady: the handler runs while the stream isn't ready, and the drainer sends once it is, without an onReady callback.

@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

This PR replaces blocking inbound gRPC data multiplexing with manual gRPC flow control, non-blocking bounded queuing (CancellableQueue.tryPut), and a StalledPollHandler mechanism to request additional inbound messages when outbound streams stall on flow control. While the overall design effectively breaks the single-executor gRPC inbound/outbound deadlock, there are a few resource/permit leaks on error and poisoned-instruction paths that could cause inFlightInbound permits or receivers entries to leak over time.

🔍 General Feedback

  • Inbound flow-control permit accounting on exceptions: Every inbound Elements message must reliably trigger its completion callback even when consumer processing throws an exception; otherwise inFlightInbound permanently drifts upward and can eventually starve the data stream.
  • Poisoned instruction lifecycle: receivers.computeIfAbsent should not insert new ReceiverHolder entries for already-poisoned instruction IDs, as nothing removes them afterward.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

Fixes from review of the Fn data plane change:

- BeamFnDataInboundObserver runs the callback of the element that was
  being processed when the consumer throws. queue.take() had removed it,
  so the cancel did not reach it, and each failed bundle kept one inbound
  permit for the life of the multiplexer.
- Late data for a poisoned instruction no longer adds a ReceiverHolder to
  receivers. The entry was never removed, grew the stall window, and let
  a later registerConsumer succeed on a poisoned holder.
- ReceiverHolder.complete refuses a poisoned holder and runs every
  buffered callback even if the receiver throws.
- registerConsumer throws IllegalArgumentException unwrapped again.
- requestInbound releases count permits instead of one.

Tests count request(1) calls on a mocked stream for a failed bundle, late
data for a poisoned instruction, and a receiver that throws on buffered
elements, and restore the expected behavior in three earlier tests.
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

This PR resolves a bidirectional gRPC flow-control deadlock in the Java SDK harness by replacing blocking consumer lookup and queue insertion on the inbound gRPC thread with manual gRPC flow control (disableAutoRequestWithInitial / request(1)), non-blocking buffering in BeamFnDataGrpcMultiplexer, and a StalledPollHandler callback from outbound stream observers. The overall design directly addresses the shared client-call executor deadlock and is well-tested, with a few edge cases around failed consumer registration, memory retention during bundle element multiplexing, and sub-second stall logging detailed below.

🔍 General Feedback

  • When a CloseableFnDataReceiver throws while draining pre-registration buffered elements in BeamFnDataGrpcMultiplexer.registerConsumer, the exception aborts ConcurrentHashMap.compute, leaving the half-completed ReceiverHolder registered in receivers instead of poisoning and removing it.
  • In BeamFnDataInboundObserver.awaitCompletion(), keeping ElementWithCallback wrapper in the outer loop scope across multiplexElements(...) retains a strong reference to BeamFnApi.Elements, defeating the inner scope block intended to allow GC of large Elements protos during processing.
  • Lowering DirectStreamObserver's poll interval from 1s to 50ms while keeping if (totalMsWaited > 0) causes sub-second waits (50ms..950ms) to log "Output channel stalled for 0s" (and can trigger LOG.info when isReady() flips to true before onReadyHandler runs).

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

… stalls

Fixes from the next review of the Fn data plane change:

- registerConsumer poisons the instruction when the receiver throws on a
  buffered element. The exception aborted compute(), which left the holder
  in receivers with its receiver open. Poisoning removes it and closes the
  receiver, as a failure on the inbound gRPC thread does. A duplicate or
  poisoned registration still only throws.
- awaitCompletion keeps the queued element inside its scope block again, so
  the Elements proto can be collected while it is processed, and releases
  the callback of that element in finally, so an Error also releases it.
- DirectStreamObserver logs a stall only after a second of waiting, as with
  the earlier one-second poll. Shorter waits logged "0s" and a false
  "OnReady notification was not invoked" when isReady turned true just
  before the onReady callback ran. The stall hook still runs every 500 ms.
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

This PR resolves a Fn data plane deadlock in BeamFnDataGrpcMultiplexer by switching the inbound stream to manual gRPC flow control (disableAutoRequestWithInitial(16)) with non-blocking buffering per instruction via ReceiverHolder and elastic window expansion via StalledPollHandler when outbound streams stall on flow control. All previously identified issues—including permit leaks on bundle or registration failure, late data handling for poisoned instructions, GC reference scoping in BeamFnDataInboundObserver, and stall log thresholds—have been cleanly addressed with comprehensive unit test coverage.

🔍 General Feedback

  • The synchronization and lock-ordering design between ReceiverHolder and CancellableQueue (releasing the queue lock before invoking onDrop callbacks) properly avoids ABBA deadlocks between the gRPC inbound thread and bundle processing threads.
  • Permit accounting across single-instruction and multi-instruction Elements batches, early buffering, cancellation, and error paths is well-tested and sound.
  • The changes look ready to merge.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

DirectStreamObserver counts 50 ms for each wait that times out, not the
wall time. The tests kept the stream not ready for a wall time, so on a
slow machine fewer waits timed out, the counted wait stayed under a
second, and testStallOfASecondIsLogged failed. The stream now becomes
ready after a number of readiness polls, so the counted wait is exact.
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

This PR resolves a bidirectional gRPC flow-control deadlock in BeamFnDataGrpcMultiplexer by switching the inbound stream to manual flow control, decoupling inbound onNext from blocking on full bundle queues or unregistered consumers, and incrementally expanding the inbound window via StalledPollHandler when outbound streams stall. All previously identified issues—including permit accounting on bundle failures, poisoned instruction cleanup, Elements GC reachability during processing, and deterministic stall logging tests—have been cleanly addressed.

🔍 General Feedback

  • Concurrency & Lock Ordering: CancellableQueue.cancel(..., onDrop) safely invokes drop callbacks outside the queue lock, and ReceiverHolder synchronizes state transitions and pending queue draining consistently.
  • Flow-Control Accounting: Inbound permits are reliably released across normal processing, consumer exceptions/errors, unregistered/poisoned instructions, and multi-instruction Elements batches.
  • Test Coverage: The test suite thoroughly covers concurrent producer/consumer handoffs, lock-ordering edge cases, failure permit recovery, and deterministic stall logging.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

@github-actions

Copy link
Copy Markdown
Contributor

Reminder, please take a look at this pr: @ahmedabu98

@sjvanrossum

sjvanrossum commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Hey! I reviewed this change and noticed quite a bit of complexity around synchronization/locking was added and I worried that it might affect bundle processing throughput. I found a notable throughput regression that seems to be caused by cross-thread requestStream.request(1) calls from bundle worker threads, extra locking and per-message allocations and a 20ms poll-stall fallback when a bundle queue fills.

I've addressed this in #40788 by using a simple state machine and non-blocking add since overflow spills onto a backlog queue anyhow.

Benchmarks

ProcessBundleBenchmark executed 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"

This change shows a ~17% throughput regression on testLargeBundle:

Benchmark Base This PR vs base
testLargeBundle 14,991.4 ± 496.3 ops/s 12,426.3 ± 2,080.8 ops/s -17.1%
testTinyBundle 35,573.1 ± 1,610.7 ops/s 35,691.6 ± 1,846.2 ops/s +0.3%
testStateWithCaching 6,748.1 ± 167.0 ops/s 6,831.1 ± 296.1 ops/s +1.2%
testStateWithoutCaching 3,936.6 ± 216.9 ops/s 3,699.4 ± 394.4 ops/s -6.0%

Checking before each message cost about 17% on ProcessBundleBenchmark.testLargeBundle.
Adds a test that data for an unregistered instruction does not block onReady.
@github-actions github-actions Bot added the size/xl XL: >=1000 lines changed label Oct 25, 2026
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

@sjvanrossum Thank you for the review.

I can see the same decrease in throughput. One change causes it: DEFAULT_MAX_MESSAGES_BEFORE_CHECK which went from 100 to 0 (I left that out after testing if forcing eager checks would help). Because of that, DirectStreamObserver was calling isReady() for each message. I set the value back to 100.

ProcessBundleBenchmark.testLargeBundle, 3 forks:

Branch ops/s
master 14,537 ± 360
This PR, before the change 12,627 ± 272
This PR, after the change 15,140 ± 182
#40788 14,762 ± 247

I also tried a lock-free counter that calls request() in batches. The result was 15,205 ± 367. This difference is in the noise so I left that out.

JIC #40788 does not fix all of the deadlock. Data can arrive before registerConsumer. ThenconsumerFuture.get(3, HOURS) stops the gRPC thread. onReady uses the same thread, so onReady cannot run for any bundle.

InboundUnregisteredConsumerReproTest shows this problem. The test fails on master and on #40788 and passes on this PR.

@sjvanrossum

sjvanrossum commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

One change causes it: DEFAULT_MAX_MESSAGES_BEFORE_CHECK

I overlooked the easiest thing! Yeah, that looks a lot better now.
The current implementation varies quite a bit on my laptop, whereas both #40606 and #40788 (patched now) tighten that up quite a bit and are nearly identical (at least on a MacBook Pro with M5 Pro).

I'll run this benchmark on a few different machine types for an extended period of time when I'm at the office tomorrow.

testLargeBundle:

Branch ops/s
master 13,070.3 ± 1,020.6
#40606 13,837.3 ± 559.3 (+5.9%)
#40788 14,361.8 ± 308.5 (+9.9%)

sjvanrossum added a commit to sjvanrossum/beam that referenced this pull request Oct 26, 2026
@prodriguezdefino

Copy link
Copy Markdown
Contributor Author

@sjvanrossum if your PR covers the case I care about (not having both delivery threads blocking each other when a data producer is faster than the data consumer from the harness perspective), and you feel your code is easier to maintain than what I proposed here; feel free to close this PR once yours reach main.

I can test my particular case then, and open another fix if #40788 does not covers it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants