Skip to content

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

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

prodriguezdefino wants to merge 4 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

❌ Patch coverage is 86.76471% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.70%. Comparing base (76f6e6d) to head (8de9191).
⚠️ Report is 214 commits behind head on master.

Files with missing lines Patch % Lines
...he/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java 82.96% 14 Missing and 9 partials ⚠️
...pache/beam/sdk/fn/stream/DirectStreamObserver.java 84.21% 1 Missing and 2 partials ⚠️
.../java/org/apache/beam/sdk/fn/CancellableQueue.java 94.11% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #40606      +/-   ##
============================================
+ Coverage     56.16%   58.70%   +2.53%     
- Complexity     2288    14015   +11727     
============================================
  Files          1124     2594    +1470     
  Lines        178336   272493   +94157     
  Branches       1489    11337    +9848     
============================================
+ Hits         100170   159959   +59789     
- Misses        75645   106501   +30856     
- Partials       2521     6033    +3512     
Flag Coverage Δ
java 65.13% <86.76%> (-10.66%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

consumingReceivedData.set(true);
if (multiplexElements(dataIterator, timersIterator)) {
boolean isDone = multiplexElements(dataIterator, timersIterator);
wrapper.callback.run();

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.

🔴 Critical: If multiplexElements(dataIterator, timersIterator) throws an exception on line 209, wrapper has already been removed from queue by queue.take(), so neither line 210 (wrapper.callback.run()) nor queue.cancel(e, wrapper -> wrapper.callback.run()) in the catch block will invoke wrapper.callback. In BeamFnDataGrpcMultiplexer, skipping this callback permanently leaks an inFlightInbound flow-control permit (and prevents multiCallback's countdown from reaching 0 for multi-instruction Elements messages). Repeated bundle failures on a shared multiplexer will eventually exhaust STEADY_STATE_INBOUND_WINDOW / maxWindow and deadlock inbound data flow.

Ensure wrapper.callback.run() is always executed (for example, in a finally block after cancelling the queue on exception).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now awaitCompletion keeps the callback of the element it took from the queue and runs it after cancelling the queue, so a failed bundle releases the permit of that element as well as the queued ones. BeamFnDataGrpcMultiplexerTest.testPermitsReleasedWhenBundleFails sends three messages to a bundle whose consumer throws and checks for three request(1) calls on a mocked stream.


assertThrows(RuntimeException.class, observer::awaitCompletion);

assertFalse(callback1.get());

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.

🟠 High: Corresponding to the permit leak in BeamFnDataInboundObserver.awaitCompletion(), the callback for the element being processed when the consumer throws (callback1) must also be invoked so that BeamFnDataGrpcMultiplexer.inFlightInbound is decremented.

Suggested change
assertFalse(callback1.get());
assertTrue(callback1.get());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I'd changed this to match the bug. It asserts assertTrue(callback1.get()) now.

@@ -233,9 +404,12 @@ public void onNext(BeamFnApi.Elements value) {
}
}
if (instructionId == null) {

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.

🟠 High: When late data arrives for an instruction that was already poisoned and removed from receivers, returning a new poisoned ReceiverHolder from receivers.computeIfAbsent(...) inserts it into the unbounded receivers map where it is never removed. This causes:

  1. A permanent memory leak in receivers (defeating the 20-minute TTL on poisonedInstructionIds) and permanent inflation of receivers.size(), which increases maxWindow in onStalledPoll() by MAX_INBOUND_WINDOW per poisoned instruction.
  2. If registerConsumer is called afterward for that instructionId, it sees existing != null before checking poisonedInstructionIds and calls existing.complete(receiver), which succeeds because complete() only checks this.receiver != null and not this.poisoned.

Return null from computeIfAbsent when poisonedInstructionIds.getIfPresent(instructionId) != null (so no mapping is inserted into receivers), guard consumerFuture != null before calling accept, and check if (this.receiver != null || this.poisoned) { return false; } in ReceiverHolder.complete().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now the computeIfAbsent function returns null for a poisoned ID, so late data inserts nothing and is dropped, and its permit is released. ReceiverHolder.complete() also returns false for a poisoned holder, and registerConsumer reports it as poisoned. testPoisonInstructionId now sends late data after poisoning, checks that no entry remains, and checks that a later registration throws IllegalArgumentException. testPermitsReleasedForLateDataOfPoisonedInstruction checks the permits.

}
this.receiver = receiver;
if (receiver instanceof BeamFnDataInboundObserver) {
drainPending();

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.

🟡 Medium: In ReceiverHolder.complete(), if a non-BeamFnDataInboundObserver receiver throws an exception during receiver.accept(p.elements), the remaining elements in pending (as well as the failing element) never have p.callback.run() called and pending is not cleared, leaking inFlightInbound permits. Also, in registerConsumer (lines 251-257), throwing IllegalArgumentException inside try { ... } catch (Exception e) causes duplicate registration to throw RuntimeException(IllegalArgumentException) instead of throwing IllegalArgumentException directly as before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b7532bb. complete() runs the callback of every buffered element, including the one the receiver failed on, and clears the buffer even if the receiver throws. registerConsumer rethrows runtime exceptions as they are, so duplicate registration throws IllegalArgumentException directly again. Covered by testPermitsReleasedWhenReceiverThrowsOnBufferedElements and the duplicate-registration check in testReceiverHolderBuffersBeforeConsumerRegisteredAndDrains.

* STEADY_STATE_INBOUND_WINDOW} messages are open again.
*/
public void requestInbound(int count) {
inFlightInbound.decrementAndGet();

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.

🟡 Medium: requestInbound(int count) accepts a count parameter but ignores it and always decrements inFlightInbound by 1. Use inFlightInbound.addAndGet(-count) (or remove the parameter if it is only ever intended to release a single message).

Suggested change
inFlightInbound.decrementAndGet();
inFlightInbound.addAndGet(-count);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b7532bb with addAndGet(-count). testRequestInboundReleasesCount opens three extra messages with onStalledPoll(), then releases five and checks that two more are requested.

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.

if (existing.isPoisoned()) {
throw new IllegalArgumentException("Instruction id was poisoned");
}
if (!complete(existing, receiver)) {

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.

🟠 High: When elements arrive before registerConsumer is called, receivers already contains a ReceiverHolder (existing) with buffered elements. If complete(existing, receiver) throws an exception because receiver.accept(p.elements) fails while draining pending (as in testPermitsReleasedWhenReceiverThrowsOnBufferedElements), existing.complete(receiver) has already set this.receiver = receiver (line 167), and the thrown exception aborts receivers.compute(...).

Per ConcurrentHashMap.compute semantics, throwing from the remapping function leaves the existing entry in receivers. As a result, hasConsumer(instructionId) remains true, instructionId is not poisoned, receiver.close() is not called (unlike when receiver.accept throws during onNext in forwardToConsumerForInstructionId), and receivers.size() stays permanently inflated by the leaked entry (which also inflates maxWindow in onStalledPoll()).

Consider catching failures from holder.complete(receiver) and calling poisonInstructionId(instructionId) outside receivers.compute before rethrowing the exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6f9d444.

When the receiver throws on a buffered element, registerConsumer poisons the instruction after compute() returns, which removes the holder and closes the receiver, as a failure on the inbound gRPC thread does. A duplicate or poisoned registration still only throws, without poisoning.

  • testPermitsReleasedWhenReceiverThrowsOnBufferedElements now also checks that the entry is gone, that the receiver was closed, and that a later registration fails as poisoned.
  • testDuplicateRegistrationDoesNotPoison checks that a rejected duplicate leaves the first registration working.


// We use discarding iterators and don't reference the elements longer than necessary to
// avoid pinning possibly large data pages and making them ineligible for garbage
// collection.

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.

🟡 Medium: Two issues here:

  1. The { ... } block on lines 201–207 exists specifically so no local variable in the outer while scope retains a reference to BeamFnApi.Elements while multiplexElements(dataIterator, timersIterator) runs (see the comment on lines 195–198 and createDiscardingIterator). Declaring ElementWithCallback wrapper outside { ... } and calling wrapper.callback.run() on line 215 keeps wrapper (and its final BeamFnApi.Elements elements field) strongly reachable for the entire duration of multiplexElements. Since inProgressCallback = wrapper.callback is already captured inside { ... }, wrapper can remain scoped inside { ... } and lines 214–215 can invoke inProgressCallback after clearing it.
  2. if (inProgressCallback != null) { inProgressCallback.run(); } is only executed in catch (Exception e) (lines 222–224). If multiplexElements throws an Error (e.g., AssertionError), finally cancels the queue via close() but leaks the inProgressCallback permit. Moving the inProgressCallback cleanup into finally (after close()) covers all Throwables.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fixed in 6f9d444.

The queued element is declared inside the scope block again, so nothing outside it holds the Elements proto while multiplexElements runs, the callback is kept separately.
The callback of the in-progress element is now released in finally, after close(), so it covers every throwable.

  • testCallbacksInvokedOnConsumerError has a consumer throw an AssertionError and checks that both callbacks run.

}
}
if (totalSecondsWaited > 0) {
if (totalMsWaited > 0) {

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.

🟡 Medium: Because waitMs was reduced from 1s to 50ms and DEFAULT_MAX_MESSAGES_BEFORE_CHECK from 100 to 0, totalMsWaited > 0 now triggers after a single 50ms poll. For any wait between 50ms and 950ms, totalMsWaited / 1000 evaluates to 0 ("Output channel stalled for 0s"). Furthermore, under normal backpressure, outboundObserver.isReady() can become true during a 50ms poll slightly before the gRPC executor dispatches the onReady callback (phaser.arrive()), exiting the loop with totalMsWaited == 50 and initialPhase == phase and logging a false-positive LOG.info warning. Requiring totalMsWaited >= 1000 preserves the previous 1-second minimum threshold for stall logging and avoids logging 0s.

Suggested change
if (totalMsWaited > 0) {
if (totalMsWaited >= 1000) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6f9d444 with totalMsWaited >= 1000, as suggested.
The stall hook still runs every 500 ms.

  • testShortStallIsNotLogged checks that a 300 ms stall logs nothing,
  • and testStallOfASecondIsLogged checks that a 1.3 s stall still logs "stalled for 1s".

… 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.

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.

1 participant