Repository navigation
[runtime][java] Avoid async-pool self-deadlock in internal sub-agent await (#1213) - #1237
Merged
yunfengzhou-hub merged 1 commit intoOct 12, 2026
Conversation
…b-agent await (apache#1213) With num-async-threads=1 (and under enough nested or concurrent waits at any size), a parent action awaiting an internal sub-agent held the only pool worker via a blocking responseFuture.get(), while the child's own durable async call needed a worker from the same finite pool to make progress. The result was a self-deadlock: neither side could advance. The fix lets the parent's wait yield its continuation instead of occupying a pool worker. A new runtime-internal marker interface, ContinuationNativeCallable, identifies callables whose body suspends by yielding the caller's continuation and must therefore run inline on the mailbox thread rather than being dispatched to an async-pool worker. JavaRunnerContextImpl.executionBoundary() routes marked callables through callable::call; all others continue to go through resolveAsync. resolveDurableAsync() now computes that boundary once and passes it to both the completion-only and the reconcile path, so a marked callable that also supplies a reconciler() cannot be dispatched to a pool worker either. ContinuationActionExecutor .awaitFuture() bridges the target CompletableFuture into a Future<?> registered as pendingFuture and yields via Continuation.yield, so executeAction's existing hasPendingAsync gate drives resumption through the operator's mail loop. Zero pool workers are held during the wait, so deadlock is impossible at any pool size, nesting depth, or concurrency level. Durable state still records the real SubagentResult because the callable returns it directly. InternalSubagentSetup.prepare() now returns a ContinuationNativeCallable and awaits the child via JavaRunnerContextImpl.awaitCompletableFuture. The public blocking RunnerContextImpl.awaitSubagentCall entry point used by the Python cross-language path is preserved unchanged. DeferredSubagentFuture is untouched; the combined awaitAll path still routes through DeferredSubagentFuture.execute() and therefore through the marker. Also fixes a pre-existing flake in the Python async batch timeout tests surfaced by CI on macos/py3.10. A batch deadline is wall-clock from the first submission, so asserting that a slot was started when the timeout hits left thread startup racing that deadline; under load the worker had not begun and the slot was persisted as PENDING instead of FAILED. The tests now drive the batch one step, wait for their workers to announce they began, and only then resume, which makes "started but unfinished" hold whatever the startup latency. Shrinking the timeout to 1ms reproduces the original failure and still passes with the fix. Three @timeout(30) tests at num-async-threads=1 cover the issue's full stated scope (single call, nested calls, combined concurrent calls). All three time out without the fix and complete promptly with it. JavaRunnerContextImplDurableExecuteAsyncTest gains a case pinning that a marked callable supplying a reconciler() still runs inline. Full runtime suites pass on both JDK 11 and JDK 21. Co-Authored-By: Qoder <noreply@qoder.com> AI-Contributed/Feature: 35/196 AI-Contributed/UT: 142/375
yunfengzhou-hub
force-pushed
the
fix/internal-subagent-deadlock-1213
branch
from
October 12, 2026 07:02
ec5bdf0 to
dc6b960
Compare
wenjin272
approved these changes
Oct 12, 2026
wenjin272
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the fix! The continuation-based approach addresses the shared-pool deadlock on JDK 21+, and I have no further concerns with this PR.
We also plan to support Java internal sub-agent calls on JDK 11/17, where this continuation mechanism is unavailable. That will need a separate design for suspending the caller without blocking the mailbox or occupying an async worker. We can follow up on that separately; it does not block this PR. cc @pltbkd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue: close #1213
Purpose of change
A Java action awaiting an internal sub-agent no longer occupies an async-pool worker, so a child making its own durable async calls completes at
num-async-threads=1. Previously the wait blocked a worker while the child's calls queued for one from the same pool; neither advanced.Runtime flow
On resolve,
DeferredSubagentFuture.execute()callsdurableExecuteAsync(prepare()).prepare()already runs on the mailbox thread — registering the call status, sending the child's envelope, failing fast without stackful suspension — and now returns aContinuationNativeCallable.JavaRunnerContextImpl.executionBoundaryis the only decision point added:callable::callfor a marked callable,resolveAsync(callable::call)otherwise, used byresolveDurableAsyncon both the completion-only and the reconcile path. A marked callable therefore runs inline in the caller's continuation on the mailbox thread and hands the response future toContinuationActionExecutor.awaitFuture. That registersfuture.thenApply(v -> null)aspendingFutureand yields until it completes;executeAction's existinghasPendingAsyncgate resumes it on the mail loop. No worker is held, so the child draws on the whole pool, and the callable returns the realSubagentResult.Key decisions
awaitSubagentCall: Python calls it on its own worker, where blocking is correct.Behavioral Semantics
Interaction decisions
await()awaitAll()requireMailboxSuspensionraises first; unchangedprepare; blocks inawaitSubagentCallreconciler()Behavioral contracts
num-async-threads=1.combine(...)and awaited byawaitAll(), complete atnum-async-threads=1.SubagentResultcarrying the child's exception.DurableCallablestill runs on a pool worker, unchanged.Failure behavior
call()unwraps the cause and folds it intoSubagentResult.error; the job proceeds.Errorcause is rethrown as-is rather than folded into a result; aCompletionExceptionwith no cause is rethrown itself.(sessionId, callId), and resolve without stackful suspension, each raiseIllegalStateException; unchanged.Tests
singleAsyncThreadDoesNotDeadlockWhenChildUsesDurableCallsingleAsyncThreadDoesNotDeadlockOnNestedCallssingleAsyncThreadDoesNotDeadlockOnCombinedCallscallerReceivesTheChildsAccumulatedOutput,childFailureSurfacesThroughTheResultInternalSubagentRecoveryTest, 14 casesresolvingWithoutContinuationsFailsFastreconciler()runs inlinetestDurableExecuteAsyncContinuationNativeReconcilableRunsInlineThe three deadlock tests are
@Timeout(30)atnum-async-threads=1, gated to JDK 21+, pinning the pool size where the cycle closes and the two shapes that multiply waits in flight. Full runtime suite green on both JDKs.This PR also fixes the
ut-pythonfailure its first push hit:test_flink_runner_context_gather_timeout_keeps_completed_results(#1208; untouched by the Java diff) raced thread startup against a 100ms wall-clock deadline, so a loaded runner persisted the timed-out slot as PENDING, not FAILED. The four tests asserting a slot started first now wait for their workers to say so.Not verified: live-cluster failover of an in-flight call under the inline boundary (recovery cases are harness-level); pool sizes above one.
Rejected alternatives, mutation notes, and implementation invariants
Alternatives rejected. A
defaultmethod onDurableCallablewould let a user mark their own callable, and inlining one onto the mailbox pins it for the whole action. Matching onInternalSubagentSetupwould tie the generic context to one implementation and encode identity rather than the requirement. Bridging through a new resumption path would duplicateexecuteAction's gate and the operator's mail loop.Mutation notes. Dispatching the marked callable to the pool as before makes all three deadlock tests time out at 30s each rather than pass quietly. Restoring the reconcile path's hard-coded
resolveAsyncsupplier makestestDurableExecuteAsyncContinuationNativeReconcilableRunsInlinefailexpected: <0> but was: <1>on the pool-dispatch count.Implementation invariants, not caller-observable:
awaitFutureleaves a done bridge inpendingFuturewhen the response future was already complete on entry.hasPendingAsync()testsisDone(), so it does not read as pending, andexecuteActionclears the slot before resuming.executeAsyncleaves the same shape.Future<?>slot can carry aCompletableFuture; nothing reads its value or exception.getPendingFuture()has one reader, which nulls it without joining.Continuation.yieldrequires the calling thread to have entered the scope, which only the mailbox thread does insideexecuteAction. That is why the boundary switch is a correctness requirement, not an optimization.awaitAll()reaches the boundary once per handle:SubagentFutureGroupprepares every handle, then executes them one at a time throughexecute().executionBoundarynow governs both durable async paths:resolveDurableAsynccomputes it once and passes it todurableExecuteWithReconcileas well asdurableExecuteCompletionOnly. Before this, the reconcile path built its ownresolveAsyncsupplier, so a marked callable that also supplied areconciler()would have yielded on a pool worker and thrownNoSuchElementException. No producer supplies both today, so the change is behavior-neutral; the new unit test pins the routing.DurableCallable,SubagentSetup,SubagentFuture,RunnerContext, bothawaitSubagentCallentry points.async.batch.timeout.msin the file shrunk to 1, the pre-fix tests reproduce the CI assertion ('PENDING' == 'FAILED') and the fixed ones still pass, so the fix removes the race rather than widening the window. The four tests are driven by a new_run_async_after_workers_startedhelper that runs the batch generator one step, waits for the workers' own events, then drains it; the deadline has certainly passed by then.async.batch.parallelismis now pinned in the first of them so submission of both children does not depend on the runner's core count._mark_started_on_runwrites its flag, the slot is persisted PENDING while the task actually runs, so recovery can duplicate its side effects.awaitFutureblocks onfuture.get()for multi-release compilation only, and is unreachable here becauserequireMailboxSuspensionrejects first.runtime, 1 Python test file.runtimegreen on JDK 11 (1240 tests, 19 skipped) and JDK 21 (1244 tests, 27 skipped); the reconcilable Python test file green at 52 tests.API
No public API change:
ContinuationNativeCallablelives inruntime, notapi, and is not part of any published contract. Every existing signature, the durable state format, and the Python path are untouched.One thing changes:
num-async-threadsno longer has to exceed the in-flight internal sub-agent waits. JDK 21 is still required.Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Qoder (Qwen 3.7 Max)