This is an automated email from the ASF dual-hosted git repository.
yunfengzhou-hub pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 9af407ed8 [runtime][java][python] Avoid async-pool self-deadlock in
internal sub-agent await (#1213) (#1237)
9af407ed8 is described below
commit 9af407ed860e382a16434e46d49690adfe8b8d48
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Mon Oct 12 17:24:16 2026 +0800
[runtime][java][python] Avoid async-pool self-deadlock in internal
sub-agent await (#1213) (#1237)
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.
AI-Contributed/Feature: 35/196
AI-Contributed/UT: 142/375
Co-authored-by: Qoder <[email protected]>
---
.../test_flink_runner_context_reconcilable.py | 101 ++++++---
.../runtime/async/ContinuationActionExecutor.java | 11 +
.../runtime/async/ContinuationNativeCallable.java | 34 +++
.../runtime/context/JavaRunnerContextImpl.java | 35 +++-
.../subagent/BaseDeferredSubagentSetup.java | 6 +-
.../runtime/subagent/InternalSubagentSetup.java | 72 +++++--
.../runtime/async/ContinuationActionExecutor.java | 38 ++++
...vaRunnerContextImplDurableExecuteAsyncTest.java | 23 ++
.../runtime/context/TestDurableCallables.java | 18 ++
.../subagent/InternalSubagentDeadlockTest.java | 233 +++++++++++++++++++++
10 files changed, 516 insertions(+), 55 deletions(-)
diff --git
a/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
b/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
index 92c0e154a..f0456d870 100644
---
a/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
+++
b/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
@@ -246,17 +246,47 @@ def
test_chat_failure_outcome_replays_without_provider_call(asynchronous: bool)
assert store.call_results[0].exception_payload is None
-def _run_async(result: Any) -> object:
- iterator = result.__await__()
- value = None
+def _drain(iterator: Any) -> object:
while True:
try:
- iterator.send(value)
- value = None
+ iterator.send(None)
except StopIteration as e: # noqa: PERF203
return e.value
+def _run_async(result: Any) -> object:
+ return _drain(result.__await__())
+
+
+_WORKER_START_TIMEOUT_SECONDS = 30.0
+
+
+def _await_workers(started: list[threading.Event]) -> None:
+ deadline = time.monotonic() + _WORKER_START_TIMEOUT_SECONDS
+ for index, event in enumerate(started):
+ remaining = max(deadline - time.monotonic(), 0.0)
+ assert event.wait(remaining), f"async worker {index} never started"
+
+
+def _run_async_after_workers_started(
+ result: Any, started: list[threading.Event]
+) -> object:
+ """Run a batch, pausing after submission until its workers have begun.
+
+ A batch deadline is wall-clock from the first submission, so a test
asserting
+ that a slot was *started* when the timeout hits cannot leave thread startup
+ racing that deadline: under CI load the worker has not begun yet and the
slot
+ is persisted as PENDING instead of FAILED. One generator step submits the
+ batch; waiting there until every worker reports it began makes "started but
+ unfinished" hold by construction, and the deadline has certainly passed by
the
+ time driving resumes.
+ """
+ iterator = result.__await__()
+ iterator.send(None)
+ _await_workers(started)
+ return _drain(iterator)
+
+
def _preload_pending(
j_runner_context: _FakeJavaRunnerContext,
func: Callable[..., Any],
@@ -1052,27 +1082,32 @@ def
test_flink_runner_context_gather_collects_failures() -> None:
def test_flink_runner_context_gather_timeout_keeps_completed_results() -> None:
j_runner_context = _FakeJavaRunnerContext()
- config = AgentConfiguration({"async.batch.timeout.ms": 100})
+ config = AgentConfiguration(
+ {"async.batch.timeout.ms": 100, "async.batch.parallelism": 2}
+ )
ctx = _create_runner_context(j_runner_context, config=config)
# The slow worker blocks on an event instead of racing a sleep against the
- # deadline. Once its worker thread starts, _mark_started_on_run has already
- # flipped the started flag, so the timeout must record it as FAILED
(started
- # but unfinished) rather than PENDING. The event guarantees it never
- # finishes before the deadline, and the finally block releases it so the
- # pool can shut down.
+ # deadline, and announces itself so the driver can wait for it. Once its
worker
+ # thread starts, _mark_started_on_run has already flipped the started
flag, so
+ # the timeout must record it as FAILED (started but unfinished) rather than
+ # PENDING. The event guarantees it never finishes before the deadline, and
the
+ # finally block releases it so the pool can shut down.
release = threading.Event()
+ slow_started = threading.Event()
def slow_call() -> str:
+ slow_started.set()
release.wait(5)
return "slow"
try:
- outcomes = _run_async(
+ outcomes = _run_async_after_workers_started(
ctx.gather(
ctx.durable_execute_async(lambda: "fast"),
ctx.durable_execute_async(slow_call),
- )
+ ),
+ [slow_started],
)
finally:
release.set()
@@ -1096,22 +1131,26 @@ def
test_flink_runner_context_gather_timeout_leaves_unsubmitted_slots_pending()
# The two blocking calls saturate the parallelism budget and hold their
# worker threads (started flag flipped) until the deadline, so they fail;
the
# remaining two suppliers are never submitted and stay pending. Blocking
on an
- # event instead of sleeping makes "started" deterministic rather than
racing
- # thread startup against a short wall-clock timeout.
+ # event instead of sleeping, and waiting for both workers to announce
+ # themselves, makes "started" deterministic rather than racing thread
startup
+ # against a short wall-clock timeout.
release = threading.Event()
+ started_events = {"one": threading.Event(), "two": threading.Event()}
def blocking_call(value: str) -> str:
+ started_events[value].set()
release.wait(5)
return value
try:
- outcomes = _run_async(
+ outcomes = _run_async_after_workers_started(
ctx.gather(
ctx.durable_execute_async(blocking_call, "one"),
ctx.durable_execute_async(blocking_call, "two"),
ctx.durable_execute_async(_call_value, "three"),
ctx.durable_execute_async(_call_value, "four"),
- )
+ ),
+ [started_events["one"], started_events["two"]],
)
finally:
release.set()
@@ -1141,12 +1180,15 @@ def
test_flink_runner_context_gather_timeout_leaves_queued_slots_pending() -> No
# Blocking on an event (rather than sleeping) keeps the two running workers
# deterministically "started but unfinished" until the deadline, while the
two
- # queued suppliers never begin.
+ # queued suppliers never begin. Waiting for both workers to announce
themselves
+ # removes the race between thread startup and the wall-clock deadline.
release = threading.Event()
+ started_events = {"one": threading.Event(), "two": threading.Event()}
executions: list[str] = []
def blocking_call(value: str) -> str:
executions.append(value)
+ started_events[value].set()
release.wait(5)
return value
@@ -1155,13 +1197,14 @@ def
test_flink_runner_context_gather_timeout_leaves_queued_slots_pending() -> No
return value
try:
- outcomes = _run_async(
+ outcomes = _run_async_after_workers_started(
ctx.gather(
ctx.durable_execute_async(blocking_call, "one"),
ctx.durable_execute_async(blocking_call, "two"),
ctx.durable_execute_async(queued_call, "three"),
ctx.durable_execute_async(queued_call, "four"),
- )
+ ),
+ [started_events["one"], started_events["two"]],
)
finally:
release.set()
@@ -1532,15 +1575,19 @@ def
test_mixed_batch_timeout_preserves_unstarted_durable_slot_for_recovery() ->
try:
# The cached durable child takes no worker; the ordinary child
occupies the
- # only worker, leaving the new durable child queued until the timeout.
+ # only worker, leaving the new durable child queued until the timeout.
The
+ # driver waits for that worker to announce itself, so "the only worker
is
+ # occupied when the deadline trips" does not race thread startup.
assert ctx.durable_execute(lambda: "cached", durable_id="cached") ==
"cached"
j_ctx.current_call_index = 0
- outcomes = _run_async(ctx.gather(
- ctx.durable_execute_async(lambda: "cached", durable_id="cached"),
- ctx.execute_async(ordinary),
- ctx.durable_execute_async(durable, durable_id="pending"),
- ))
- assert started.is_set()
+ outcomes = _run_async_after_workers_started(
+ ctx.gather(
+ ctx.durable_execute_async(lambda: "cached",
durable_id="cached"),
+ ctx.execute_async(ordinary),
+ ctx.durable_execute_async(durable, durable_id="pending"),
+ ),
+ [started],
+ )
assert outcomes[0].value == "cached"
assert isinstance(outcomes[1].error, TimeoutError)
assert isinstance(outcomes[2].error, TimeoutError)
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
index 37769a072..6132ae039 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
@@ -25,6 +25,7 @@ import javax.annotation.Nullable;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
@@ -136,6 +137,16 @@ public class ContinuationActionExecutor {
}
}
+ /**
+ * Awaits a {@link CompletableFuture} by blocking the calling thread. On
JDK 21+ the
+ * multi-release variant yields the continuation instead, avoiding
async-pool exhaustion (issue
+ * #1213).
+ */
+ public <T> T awaitFuture(ContinuationContext context, CompletableFuture<T>
future)
+ throws Exception {
+ return future.get();
+ }
+
/** Stops async work without waiting for worker exit cleanup. */
public void shutdown() {
batchExecutor.shutdown();
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationNativeCallable.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationNativeCallable.java
new file mode 100644
index 000000000..d9bd89e36
--- /dev/null
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationNativeCallable.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.runtime.async;
+
+import org.apache.flink.agents.api.context.DurableCallable;
+
+/**
+ * Marks a {@link DurableCallable} that suspends by yielding the caller's
continuation, and must
+ * therefore run inline on the mailbox thread instead of being dispatched to
an async-pool worker.
+ *
+ * <p>A waiting caller that occupies a pool worker holds a resource its own
callee may need to make
+ * progress, which self-deadlocks once concurrent or nested waits exhaust the
pool (issue #1213). A
+ * callable marked here awaits by yielding, so the worker pool stays free for
the callee.
+ *
+ * <p>Only meaningful on JDK 21+, where the continuation the callable yields
is the caller's; the
+ * callers that produce such callables gate on {@code
requireMailboxSuspension} beforehand.
+ */
+public interface ContinuationNativeCallable<T> extends DurableCallable<T> {}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
index 27b402969..d4f541637 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
@@ -27,6 +27,7 @@ import org.apache.flink.agents.runtime.actionstate.CallResult;
import org.apache.flink.agents.runtime.async.BatchExecutionResult;
import org.apache.flink.agents.runtime.async.ContinuationActionExecutor;
import org.apache.flink.agents.runtime.async.ContinuationContext;
+import org.apache.flink.agents.runtime.async.ContinuationNativeCallable;
import org.apache.flink.agents.runtime.metrics.FlinkAgentsMetricGroupImpl;
import java.time.Duration;
@@ -35,6 +36,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -71,19 +73,29 @@ public class JavaRunnerContextImpl extends
RunnerContextImpl {
@Override
protected <T> T resolveDurableAsync(DurableCallable<T> callable) throws
Exception {
+ Callable<T> boundary = executionBoundary(callable);
if (durableExecutionContext != null) {
Callable<T> reconcileCallable = callable.reconciler();
if (reconcileCallable != null) {
- return durableExecuteAsyncWithReconcile(callable,
reconcileCallable);
+ return durableExecuteWithReconcile(callable,
reconcileCallable, boundary);
}
}
- return durableExecuteCompletionOnly(callable, () ->
resolveAsync(callable::call));
+ return durableExecuteCompletionOnly(callable, boundary);
}
- private <T> T durableExecuteAsyncWithReconcile(
- DurableCallable<T> callable, Callable<T> reconcileCallable) throws
Exception {
- return durableExecuteWithReconcile(
- callable, reconcileCallable, () ->
resolveAsync(callable::call));
+ /**
+ * Chooses where one durable async call executes, on both the
completion-only and the reconcile
+ * path. A {@link ContinuationNativeCallable} suspends by yielding the
caller's continuation, so
+ * it runs inline on the mailbox thread; handing it to an async-pool
worker would make the
+ * waiting caller hold a worker its own callee needs to progress (issue
#1213), and the yield
+ * itself would fail off the mailbox thread. Any other callable is
offloaded to the pool, which
+ * lets the continuation yield while it runs.
+ */
+ private <T> Callable<T> executionBoundary(DurableCallable<T> callable) {
+ if (callable instanceof ContinuationNativeCallable) {
+ return callable::call;
+ }
+ return () -> resolveAsync(callable::call);
}
@Override
@@ -350,6 +362,17 @@ public class JavaRunnerContextImpl extends
RunnerContextImpl {
}
}
+ /**
+ * Awaits a {@link CompletableFuture} by yielding the continuation,
without occupying an
+ * async-pool worker. Falls back to blocking {@code get()} when
continuations are unavailable.
+ */
+ public <T> T awaitCompletableFuture(CompletableFuture<T> future) throws
Exception {
+ if (continuationExecutor != null && continuationContext != null) {
+ return continuationExecutor.awaitFuture(continuationContext,
future);
+ }
+ return future.get();
+ }
+
@Override
public void close() throws Exception {
super.close();
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
index 56b7cedf8..7d840cd94 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
@@ -58,8 +58,10 @@ public abstract class BaseDeferredSubagentSetup extends
BaseSubagentSetup {
*
* <p>Called exactly once per invocation, when the deferred handle is
first resolved, on the
* mailbox thread. Implementations may therefore perform the
mailbox-confined part of issuing
- * the request here; the returned callable's {@link
DurableCallable#call()} carries only the
- * part that runs off the mailbox thread.
+ * the request here; the returned callable's {@link
DurableCallable#call()} then carries the
+ * wait, normally on an async-pool worker off the mailbox thread. A
callable marked {@link
+ * org.apache.flink.agents.runtime.async.ContinuationNativeCallable}
instead runs inline on the
+ * mailbox thread and releases it by suspending the caller's continuation.
*
* <p>The callable folds its own comprehensible failures into the returned
{@link
* SubagentResult}; an exception escaping {@link DurableCallable#call()}
is a system-level
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/InternalSubagentSetup.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/InternalSubagentSetup.java
index 9a660fe90..774205210 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/InternalSubagentSetup.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/InternalSubagentSetup.java
@@ -33,6 +33,7 @@ import org.apache.flink.agents.plan.AgentPlan;
import org.apache.flink.agents.plan.actions.Action;
import org.apache.flink.agents.runtime.ResourceCache;
import org.apache.flink.agents.runtime.async.ContinuationActionExecutor;
+import org.apache.flink.agents.runtime.async.ContinuationNativeCallable;
import org.apache.flink.agents.runtime.condition.ActionMatcher;
import org.apache.flink.agents.runtime.context.JavaRunnerContextImpl;
import org.apache.flink.agents.runtime.context.RunnerContextImpl;
@@ -54,8 +55,10 @@ import java.util.Map;
* <p>Execution mode is the deferred one, inherited from {@link
BaseDeferredSubagentSetup}: {@link
* #submit} returns a deferred handle whose request is prepared on first
resolve. {@link #prepare}
* then runs the mailbox-confined bootstrap — registering the call status and
sending one {@code
- * InternalSubagentCallEvent} — and returns the durable callable that waits
off the mailbox thread
- * for the child to quiesce, so the operator can dispatch the child agent's
actions in between.
+ * InternalSubagentCallEvent} — and returns the durable callable that waits
for the child to
+ * quiesce. Unlike the other deferred setups, that callable runs inline on the
mailbox thread and
+ * releases it by yielding the caller's continuation, so the operator can
dispatch the child agent's
+ * actions in between while no async-pool worker is held.
*
* <p>Orchestration state lives here, not in the operator: the per-call
quiesce statuses, the
* per-scope child resource caches, and the per-key session index used to
clean up when a record
@@ -158,8 +161,15 @@ public class InternalSubagentSetup extends
BaseDeferredSubagentSetup {
}
/**
- * The mailbox-confined bootstrap plus the off-mailbox wait. Sending the
bootstrap event must
- * happen before the wait releases the mailbox, so it runs here, on the
resolving thread.
+ * The mailbox-confined bootstrap plus the wait. Sending the bootstrap
event must happen before
+ * the wait releases the mailbox, so it runs here, on the resolving thread.
+ *
+ * <p>The returned callable is {@link ContinuationNativeCallable}, so it
runs inline inside the
+ * caller's continuation rather than on an async-pool worker, and its wait
suspends by yielding
+ * that continuation. A worker-dispatched wait would hold a pool worker
for the whole child call
+ * while the child's own durable calls queue behind it for a worker from
the same finite pool —
+ * a self-deadlock at {@code num-async-threads=1} and under enough nested
or concurrent waits at
+ * any size (issue #1213).
*/
@Override
protected DurableCallable<SubagentResult> prepare(
@@ -171,7 +181,7 @@ public class InternalSubagentSetup extends
BaseDeferredSubagentSetup {
throw new IllegalStateException(
"Failed to bootstrap internal sub-agent call for scope " +
scope, e);
}
- return new DurableCallable<SubagentResult>() {
+ return new ContinuationNativeCallable<SubagentResult>() {
@Override
public String getId() {
return sessionId + "#" + callId;
@@ -184,11 +194,11 @@ public class InternalSubagentSetup extends
BaseDeferredSubagentSetup {
@Override
public SubagentResult call() {
- // Runs off the mailbox thread, so the operator can dispatch
the child's
- // actions; failures converge into a failed SubagentResult
like the other
- // deferred setups.
+ // Runs inline on the mailbox thread; the wait yields the
continuation, so the
+ // operator can dispatch the child's actions without a pool
worker being held.
+ // Failures converge into a failed SubagentResult like the
other deferred setups.
try {
- return SubagentResult.ok(awaitSubagentCall(sessionId,
callId));
+ return SubagentResult.ok(awaitSubagentCallYielding(ctx,
sessionId, callId));
} catch (Exception e) {
return SubagentResult.error(e);
}
@@ -251,6 +261,29 @@ public class InternalSubagentSetup extends
BaseDeferredSubagentSetup {
* and returns its accumulated output. Must be invoked off the mailbox
thread.
*/
public List<Object> awaitSubagentCall(String sessionId, String callId)
throws Exception {
+ try {
+ return requireCallStatus(sessionId,
callId).getResponseFuture().get();
+ } catch (java.util.concurrent.ExecutionException e) {
+ throw unwrapChildFailure(e);
+ }
+ }
+
+ /**
+ * Awaits the identified call by yielding the caller's continuation, so
the wait holds no
+ * async-pool worker and the child can draw from the pool to make progress
(issue #1213).
+ *
+ * <p>The wait runs inline on the mailbox thread, so the blocking {@link
#awaitSubagentCall} is
+ * not an option here — it would pin the mailbox and stall the very child
it waits for. Only a
+ * Java action reaches {@link #prepare}; Python bootstraps through {@code
+ * RunnerContextImpl.bootstrapSubagentCallForScope} and blocks on its own
async worker instead.
+ */
+ private List<Object> awaitSubagentCallYielding(
+ RunnerContext ctx, String sessionId, String callId) throws
Exception {
+ InternalSubagentCallStatus cs = requireCallStatus(sessionId, callId);
+ return ((JavaRunnerContextImpl)
ctx).awaitCompletableFuture(cs.getResponseFuture());
+ }
+
+ private InternalSubagentCallStatus requireCallStatus(String sessionId,
String callId) {
InternalSubagentCallStatus cs = getCallStatus(sessionId, callId);
if (cs == null) {
throw new IllegalStateException(
@@ -259,17 +292,16 @@ public class InternalSubagentSetup extends
BaseDeferredSubagentSetup {
+ ", callId="
+ callId);
}
- try {
- return cs.getResponseFuture().get();
- } catch (java.util.concurrent.ExecutionException e) {
- // Surface the child's failure directly, so the caller's
SubagentResult carries
- // the failing action's exception rather than the future's
plumbing.
- Throwable cause = e.getCause();
- if (cause instanceof Exception) {
- throw (Exception) cause;
- }
- throw e;
- }
+ return cs;
+ }
+
+ /**
+ * Surfaces the child's failure directly, so the caller's {@link
SubagentResult} carries the
+ * failing action's exception rather than the future's plumbing.
+ */
+ private static Exception
unwrapChildFailure(java.util.concurrent.ExecutionException e) {
+ Throwable cause = e.getCause();
+ return cause instanceof Exception ? (Exception) cause : e;
}
/** The quiesce status of the identified call, or {@code null} when this
setup owns none. */
diff --git
a/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
b/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
index eb0b6003c..ef7e4f662 100644
---
a/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
+++
b/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
@@ -370,6 +370,44 @@ public class ContinuationActionExecutor {
: System.nanoTime() + timeout.toNanos();
}
+ /**
+ * Yields the current continuation until {@code future} completes, without
occupying an
+ * async-pool worker. The caller must be running inside a continuation on
the mailbox thread;
+ * this method yields that continuation and lets {@link #executeAction}
resume it once the
+ * future is done.
+ *
+ * <p>This is the non-blocking alternative to submitting a blocking {@code
future.get()} to the
+ * pool via {@link #executeAsync}. It prevents the deadlock where a parent
awaiting a child
+ * holds the only pool worker the child needs to make progress (issue
#1213).
+ */
+ public <T> T awaitFuture(ContinuationContext context, CompletableFuture<T>
future)
+ throws Exception {
+ context.clearAsyncState();
+ // Bridge to Future<?> so executeAction's hasPendingAsync gate drives
the resumption.
+ context.setPendingFuture(future.thenApply(v -> null));
+ // Re-checked before every yield, and resumption is polled by the
operator's mail loop
+ // rather than signalled by a completion callback, so a future already
done here — or one
+ // completing between the two lines above — simply never suspends.
+ while (!future.isDone()) {
+ Continuation.yield(SCOPE);
+ }
+ return joinCompletableFuture(future);
+ }
+
+ private static <T> T joinCompletableFuture(CompletableFuture<T> future)
throws Exception {
+ try {
+ return future.join();
+ } catch (CompletionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof Exception) {
+ throw (Exception) cause;
+ } else if (cause instanceof Error) {
+ throw (Error) cause;
+ }
+ throw e;
+ }
+ }
+
/** Stops async work without waiting for worker exit cleanup. */
public void shutdown() {
asyncExecutor.shutdownNow();
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
index ed996a297..67ce7c7ec 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
@@ -125,6 +125,29 @@ class JavaRunnerContextImplDurableExecuteAsyncTest {
assertTrue(persisted.isSuccess());
}
+ @Test
+ void testDurableExecuteAsyncContinuationNativeReconcilableRunsInline()
throws Exception {
+ InspectingContinuationActionExecutor executor = new
InspectingContinuationActionExecutor();
+ JavaRunnerContextImpl context = createContext(new ActionState(null),
executor);
+ TestContinuationNativeReconcilableCallable<String> callable =
+ new TestContinuationNativeReconcilableCallable<>(
+ "recon-inline",
+ String.class,
+ () -> "ok",
+ () -> fail("reconcile should not be called on initial
async execution"));
+
+ String result = context.durableExecuteAsync(callable).await();
+
+ assertEquals("ok", result);
+ assertEquals(1, callable.getCallCount());
+ assertEquals(0, callable.getReconcileCount());
+ assertEquals(0, executor.getExecuteAsyncCallCount());
+ assertEquals(2, persistCallCount.get());
+ CallResult persisted =
+
context.getDurableExecutionContext().getActionState().getCallResults().get(0);
+ assertTrue(persisted.isSuccess());
+ }
+
@Test
void testDurableExecuteAsyncReconcilableReplaySuccess() throws Exception {
InspectingContinuationActionExecutor executor = new
InspectingContinuationActionExecutor();
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/TestDurableCallables.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/TestDurableCallables.java
index 46ad2cedb..c1311cfd5 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/TestDurableCallables.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/TestDurableCallables.java
@@ -19,6 +19,7 @@
package org.apache.flink.agents.runtime.context;
import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.runtime.async.ContinuationNativeCallable;
import java.util.concurrent.Callable;
@@ -108,3 +109,20 @@ class TestReconcilableCallable<T> implements
DurableCallable<T> {
return reconcileCount;
}
}
+
+/**
+ * Reconciliable fixture that is also marked {@link
ContinuationNativeCallable}, so the runner
+ * context must run its {@link #call()} inline on the caller's thread rather
than dispatch it to an
+ * async-pool worker.
+ */
+class TestContinuationNativeReconcilableCallable<T> extends
TestReconcilableCallable<T>
+ implements ContinuationNativeCallable<T> {
+
+ TestContinuationNativeReconcilableCallable(
+ String id,
+ Class<T> resultClass,
+ Callable<T> callSupplier,
+ Callable<T> reconcileSupplier) {
+ super(id, resultClass, callSupplier, reconcileSupplier);
+ }
+}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/InternalSubagentDeadlockTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/InternalSubagentDeadlockTest.java
new file mode 100644
index 000000000..f2b0acd4f
--- /dev/null
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/InternalSubagentDeadlockTest.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.runtime.subagent;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.subagent.SubagentFutures;
+import org.apache.flink.agents.api.subagent.SubagentResult;
+import org.apache.flink.agents.api.subagent.SubagentSetup;
+import org.apache.flink.agents.plan.AgentConfiguration;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.runtime.operator.ActionExecutionOperator;
+import org.apache.flink.agents.runtime.operator.ActionExecutionOperatorFactory;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reproduces the deadlock described in GitHub issue #1213: with {@code
num-async-threads=1}, a
+ * parent awaiting an internal sub-agent holds the single async-pool worker
via a blocking {@code
+ * responseFuture.get()}, while the child's own durable async call needs a
worker from the same pool
+ * to make progress. The result is a self-deadlock — neither side can advance.
+ *
+ * <p>The fix must let the parent's await yield its worker so the child can
proceed, regardless of
+ * pool size. This test times out under the broken behavior and completes
promptly once fixed.
+ */
+@EnabledForJreRange(min = JRE.JAVA_21)
+public class InternalSubagentDeadlockTest {
+
+ private static final AtomicBoolean CHILD_DURABLE_EXECUTED = new
AtomicBoolean(false);
+ private static final AtomicInteger DURABLE_EXECUTIONS = new
AtomicInteger();
+
+ @BeforeEach
+ void resetCounters() {
+ CHILD_DURABLE_EXECUTED.set(false);
+ DURABLE_EXECUTIONS.set(0);
+ }
+
+ /**
+ * Child agent whose action performs a durable async call before emitting
output. The call
+ * itself is trivial (returns immediately), but it still requires an
async-pool worker to
+ * execute. Under the deadlock, the parent holds the only worker and this
call never starts.
+ */
+ public static class DurableAsyncChildAgent extends Agent {
+ public DurableAsyncChildAgent() throws Exception {
+ addAction(
+ new String[] {InputEvent.EVENT_TYPE},
+ DurableAsyncChildAgent.class.getMethod(
+ "handle", Event.class, RunnerContext.class));
+ }
+
+ @SuppressWarnings("unused")
+ public static void handle(Event event, RunnerContext ctx) throws
Exception {
+ String result = ctx.durableExecuteAsync(durableCall()).await();
+ ctx.sendEvent(new OutputEvent(result));
+ }
+ }
+
+ /**
+ * Child that itself awaits a grandchild performing a durable call, so two
waits are nested
+ * while the grandchild's call still needs a worker from the same
single-thread pool.
+ */
+ public static class NestingDurableChildAgent extends Agent {
+ public NestingDurableChildAgent() throws Exception {
+ addResource("grandchild", ResourceType.AGENT, new
DurableAsyncChildAgent());
+ addAction(
+ new String[] {InputEvent.EVENT_TYPE},
+ NestingDurableChildAgent.class.getMethod(
+ "relay", Event.class, RunnerContext.class));
+ }
+
+ @SuppressWarnings("unused")
+ public static void relay(Event event, RunnerContext ctx) throws
Exception {
+ SubagentSetup grandchild =
+ (SubagentSetup) ctx.getResource("grandchild",
ResourceType.AGENT);
+ SubagentResult result =
+ grandchild.submit(ctx,
InputEvent.fromEvent(event).getInput()).await();
+ ctx.sendEvent(new OutputEvent("relayed:" + firstOutput(result)));
+ }
+ }
+
+ private static DurableCallable<String> durableCall() {
+ return new DurableCallable<String>() {
+ @Override
+ public String getId() {
+ return "child-durable";
+ }
+
+ @Override
+ public Class<String> getResultClass() {
+ return String.class;
+ }
+
+ @Override
+ public String call() {
+ CHILD_DURABLE_EXECUTED.set(true);
+ DURABLE_EXECUTIONS.incrementAndGet();
+ return "durable-result";
+ }
+ };
+ }
+
+ private static String firstOutput(SubagentResult result) {
+ return String.valueOf(((List<?>) result.getResult()).get(0));
+ }
+
+ @SuppressWarnings("unused")
+ public static void callChild(Event event, RunnerContext ctx) throws
Exception {
+ SubagentSetup child = (SubagentSetup) ctx.getResource("child",
ResourceType.AGENT);
+ SubagentResult result = child.submit(ctx,
InputEvent.fromEvent(event).getInput()).await();
+ ctx.sendEvent(new OutputEvent("parent:" + firstOutput(result)));
+ }
+
+ @SuppressWarnings("unused")
+ public static void callChildTwiceCombined(Event event, RunnerContext ctx)
throws Exception {
+ SubagentSetup child = (SubagentSetup) ctx.getResource("child",
ResourceType.AGENT);
+ SubagentFutures combined = child.submit(ctx,
"a").combine(child.submit(ctx, "b"));
+ List<SubagentResult> results = combined.awaitAll();
+ ctx.sendEvent(
+ new OutputEvent(
+ "parent:"
+ + firstOutput(results.get(0))
+ + ","
+ + firstOutput(results.get(1))));
+ }
+
+ /**
+ * With {@code num-async-threads=1}, the parent's await must not hold the
only worker. If it
+ * does, the child's durable call can never execute and this test times
out.
+ */
+ @Test
+ @Timeout(30)
+ void singleAsyncThreadDoesNotDeadlockWhenChildUsesDurableCall() throws
Exception {
+ List<Object> output = run(plan("callChild", new
DurableAsyncChildAgent()));
+
+ assertThat(CHILD_DURABLE_EXECUTED).isTrue();
+ assertThat(output).containsExactly("parent:durable-result");
+ }
+
+ /**
+ * Nesting multiplies the waits in flight: a child awaiting its own
grandchild must not consume
+ * a worker either, or one nested call deadlocks the single-thread pool.
+ */
+ @Test
+ @Timeout(30)
+ void singleAsyncThreadDoesNotDeadlockOnNestedCalls() throws Exception {
+ List<Object> output = run(plan("callChild", new
NestingDurableChildAgent()));
+
+ assertThat(CHILD_DURABLE_EXECUTED).isTrue();
+ assertThat(output).containsExactly("parent:relayed:durable-result");
+ }
+
+ /**
+ * Concurrent calls combined into one wait: {@code awaitAll} prepares both
calls (dispatching
+ * both children) before awaiting either, so both waits are in flight at
once and neither may
+ * hold a worker.
+ */
+ @Test
+ @Timeout(30)
+ void singleAsyncThreadDoesNotDeadlockOnCombinedCalls() throws Exception {
+ List<Object> output = run(plan("callChildTwiceCombined", new
DurableAsyncChildAgent()));
+
+ assertThat(DURABLE_EXECUTIONS.get()).isEqualTo(2);
+
assertThat(output).containsExactly("parent:durable-result,durable-result");
+ }
+
+ // --- helpers ---
+
+ /** Builds a plan whose single caller action is {@code callerAction}, with
one async worker. */
+ private static AgentPlan plan(String callerAction, Agent child) throws
Exception {
+ AgentConfiguration config = new AgentConfiguration();
+ config.set(AgentExecutionOptions.NUM_ASYNC_THREADS, 1);
+
+ Agent parent = new Agent();
+ parent.addResource("child", ResourceType.AGENT, child);
+ parent.addAction(
+ new String[] {InputEvent.EVENT_TYPE},
+ InternalSubagentDeadlockTest.class.getMethod(
+ callerAction, Event.class, RunnerContext.class));
+ return new AgentPlan(parent, config);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<Object> run(AgentPlan plan) throws Exception {
+ try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object>
harness =
+ new KeyedOneInputStreamOperatorTestHarness<>(
+ new ActionExecutionOperatorFactory<>(plan, true),
+ (KeySelector<Long, Long>) value -> value,
+ TypeInformation.of(Long.class))) {
+ harness.open();
+ harness.processElement(new StreamRecord<>(1L));
+ ((ActionExecutionOperator<Long, Object>) harness.getOperator())
+ .waitInFlightEventsFinished();
+ return ((List<StreamRecord<Object>>) harness.getRecordOutput())
+
.stream().map(StreamRecord::getValue).collect(Collectors.toList());
+ }
+ }
+}