eemario commented on code in PR #1208:
URL: https://github.com/apache/flink-agents/pull/1208#discussion_r4228037899


##########
docs/content/docs/operations/configuration.md:
##########
@@ -134,10 +134,10 @@ Here is the list of all built-in core configuration 
options.
 | `retry-wait-interval`     | 1                          | int                 
  | Base wait interval in seconds between retries. Uses exponential backoff: 
the actual wait time for the Nth retry is `retry-wait-interval * 2^(N-1)` 
seconds. For example, with default 1s, waits are 1s, 2s, 4s, etc. Retry count 
and total wait time are reported in `ChatResponseEvent` and recorded as metrics 
(`retryCount`, `retryWaitSec`) under the configured ChatModel resource name. |
 | `chat.async`              | true                       | boolean             
  | Whether chat asynchronously for built-in chat action.                       
                                                                                
                                                                                
                    |
 | `tool-call.async`         | true                       | boolean             
  | Whether the built-in tool-call action runs each tool via durable async 
execution.                                                                      
                                                                                
                         |
-| `tool-call.parallelism`   | os cpu count               | int                 
  | In-flight concurrency for tool calls from one `ToolRequestEvent` batch when 
`tool-call.async` is enabled. `1` runs tools serially; values greater than `1` 
run a parallel durable batch with a sliding window of at most that many 
concurrent tool calls. On **Java**, concurrent in-batch execution requires 
**JDK 21+** (Continuation API); below JDK 21 the batch still runs but tool 
calls execute serially. **Python** uses the shared async `ThreadPoolExecutor` 
and runs batches concurrently regardless of JDK version. Increases in-flight 
external calls; after failover, unfinished tools may be submitted again — 
side-effecting tools should be idempotent or provide a reconciler. {{< hint 
warning >}}**Default is parallel** (`os cpu count`). Chat, RAG, and tool 
batches share one `num-async-threads` pool **per operator subtask** (all keys 
on that subtask). Built-in actions for a single key run one at a time, so chat 
 and a tool batch on the **same key** do not overlap in the usual chat → tool 
path; delay shows up mainly **across keys** on the same subtask. With defaults 
(`num-async-threads = 2× cores`, `tool-call.parallelism = cores`), one batch 
can use up to half the pool; several busy keys can still saturate it. Lower 
this value or increase `num-async-threads` on hot subtasks. {{< /hint >}} |
-| `tool-call.batch.timeout.ms` | -1 (disabled)              | long 
(milliseconds)   | Overall timeout for one parallel tool-call batch. 
Non-positive disables it. On timeout, completed slots keep their outcome; slots 
that started but did not finish are recorded as failures; slots that never 
started executing (for example, queued in a saturated pool) stay pending, so 
they are re-executed after recovery instead of recording a false failure. 
Timeout cancellation is best-effort; external side effects from unfinished tool 
calls may still complete, so side-effecting tools should be idempotent or 
provide a reconciler. On **Java**, only enforced on **JDK 21+**; on JDK 11 the 
batch fallback ignores this setting and runs to completion serially. **Python** 
enforces the deadline in the batch await loop. {{< hint warning >}}**Thread 
reclamation:** a timeout unblocks the batch but cannot interrupt a tool that is 
still running; its worker thread stays in the shared `num-async-threads` pool 
until th
 e tool returns on its own, so a tool that never returns permanently reduces 
pool capacity. Bound blocking work inside the tool (for example an HTTP client 
read timeout) rather than relying on this timeout to free the thread.{{< /hint 
>}} |
+| `async.batch.parallelism` | os cpu count | int | Maximum concurrent calls in 
one `ctx.gather(...)` batch, including ordinary calls, durable calls, and 
built-in async tool calls. `1` runs calls sequentially. Actual concurrency is 
also limited by `num-async-threads`; lower this per-batch limit to leave 
capacity for other calls on the same subtask. Java and Python support batch 
concurrency on all supported JDK versions. |

Review Comment:
   Question on the API contract: config keys are part of the public API surface 
— is a rename with no migration note the intended behavior here?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java:
##########
@@ -75,35 +76,89 @@ protected <T> T resolveDurableAsync(DurableCallable<T> 
callable) throws Exceptio
                 return durableExecuteAsyncWithReconcile(callable, 
reconcileCallable);
             }
         }
-        return durableExecuteCompletionOnly(callable, () -> 
executeAsyncCallable(callable));
+        return durableExecuteCompletionOnly(callable, () -> 
resolveAsync(callable::call));
     }
 
     private <T> T durableExecuteAsyncWithReconcile(
             DurableCallable<T> callable, Callable<T> reconcileCallable) throws 
Exception {
         return durableExecuteWithReconcile(
-                callable, reconcileCallable, () -> 
executeAsyncCallable(callable));
+                callable, reconcileCallable, () -> 
resolveAsync(callable::call));
     }
 
     @Override
-    protected <T> List<Outcome<T>> 
resolveDurableBatch(List<DurableCallable<T>> callables)
+    protected <T> List<Outcome<T>> 
resolveAsyncBatch(List<SingleAsyncFuture<T>> futures)
             throws Exception {
-        if (callables.isEmpty()) {
-            return List.of();
+        List<DurableCallable<T>> durableCalls = new ArrayList<>();
+        for (SingleAsyncFuture<T> future : futures) {
+            if (future.getDurableCallable() != null) {
+                durableCalls.add(future.getDurableCallable());
+            }
         }
-        if (durableExecutionContext == null) {
-            return executeAllWithoutDurableState(callables);
+        if (durableExecutionContext == null || durableCalls.isEmpty()) {
+            List<Callable<T>> suppliers = new ArrayList<>();
+            for (SingleAsyncFuture<T> future : futures) {
+                suppliers.add(future.getCallable());
+            }
+            return executeAllWithoutDurableState(suppliers);

Review Comment:
   This new ordinary path exposes an existing JDK 21 batch-executor bug.
   When a callback throws Error, CompletableFuture.join() wraps it in
   CompletionException. AsyncFutureImpl.await() then treats that wrapper as an
   ordinary Exception, marks the handle done, and caches it.
   
   Reproducer:
   
       var batch = ctx.gather(List.of(ctx.executeAsync(() -> {
           throw new AssertionError("fatal");
       })));
       batch.await();
   
   On the real multi-release JDK 21 path, the first await throws
   CompletionException(cause=AssertionError), and the second await rethrows the 
same
   cached wrapper. JDK < 21 instead propagates the original AssertionError 
without
   caching it.
   
   This contradicts the PR's "Java fatal Errors propagate" contract and lets
   catch (Exception) accidentally swallow a fatal Error. Could the JDK 21 normal
   and timeout outcome collectors unwrap CompletionException and rethrow Error
   directly, with a packaged multi-release JDK 21 regression test?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java:
##########
@@ -222,25 +277,22 @@ private BatchExecutionPlan(int size) {
         }
     }
 
-    private <T> List<Outcome<T>> 
executeAllWithoutDurableState(List<DurableCallable<T>> callables)
+    private <T> List<Outcome<T>> 
executeAllWithoutDurableState(List<Callable<T>> suppliers)
             throws Exception {
-        List<Callable<T>> suppliers = new ArrayList<>();
-        for (DurableCallable<T> callable : callables) {
-            suppliers.add(callable::call);
-        }
         List<Outcome<T>> outcomes = 
executeOutcomeSuppliers(suppliers).getOutcomes();
         for (Outcome<T> outcome : outcomes) {
-            if (outcome.isFailure() && outcome.getError() instanceof 
InterruptedException) {
-                // Without a durable store there is nothing to leave pending, 
but a cancellation
-                // signal folded into a failed Outcome must still propagate as 
an interruption
-                // instead of being handed to the caller as an ordinary tool 
failure.
-                Thread.currentThread().interrupt();
-                throw (InterruptedException) outcome.getError();
-            }
+            rethrowInterruption(outcome);
         }
         return outcomes;
     }
 
+    private static void rethrowInterruption(Outcome<?> outcome) throws 
InterruptedException {

Review Comment:
   This only recognizes a top-level InterruptedException. The Python runtime
   and ModelRoutingResolver also classify ClosedByInterruptException and a 
nested
   InterruptedException as cancellation, while excluding bare
   InterruptedIOException network timeouts.
   
   With the new ordinary gather path, a callback that throws
   ClosedByInterruptException is returned as Outcome.failure and cached on the
   child handle; a later await rethrows the same cached exception instead of
   retrying. The durable path can similarly persist it as a business failure.
   
   Could AsyncFutureImpl.await(), this helper, and durable finalization share a
   cause-chain interruption classifier, with direct, NIO, and 
wrapped-interruption
   tests?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to