weiqingy commented on code in PR #926:
URL: https://github.com/apache/flink-agents/pull/926#discussion_r3793394265


##########
python/flink_agents/runtime/flink_runner_context.py:
##########
@@ -241,6 +294,136 @@ def __await__(self) -> Any:
         return result
 
 
+class _DurableBatchAsyncExecutionResult(AsyncExecutionResult):
+    def __init__(self, ctx: "FlinkRunnerContext", calls: list[DurableCall]) -> 
None:
+        self._ctx = ctx
+        self._calls = calls
+
+    def __await__(self) -> Any:
+        plan = self._ctx._prepare_batch_execution(self._calls)
+        parallelism = 
self._ctx.config.get(AgentExecutionOptions.TOOL_CALL_PARALLELISM)
+        timeout_ms = 
self._ctx.config.get(AgentExecutionOptions.TOOL_CALL_BATCH_TIMEOUT_MS)
+        deadline = time.monotonic() + timeout_ms / 1000 if timeout_ms > 0 else 
None
+        suppliers = [supplier for _, supplier in plan.suppliers]
+        batch_futures: list[Any | None] = [None] * len(suppliers)
+        try:
+            executed = yield from _execute_sliding_window_batch(
+                self._ctx.executor,
+                suppliers,
+                parallelism,
+                deadline,
+                timeout_ms,
+                batch_futures,
+                plan.submitted,
+            )
+        except _BatchTimeoutError as exception:
+            executed = _collect_sliding_window_outcomes_on_timeout(
+                batch_futures, plan.submitted, exception
+            )
+        return self._ctx._finalize_batch_execution(self._calls, plan, executed)
+
+
+class _BatchTimeoutError(TimeoutError):
+    """Raised when a durable batch exceeds its deadline."""
+
+
+def _execute_sliding_window_batch(
+    executor: ThreadPoolExecutor,
+    suppliers: list[Any],
+    parallelism: int,
+    deadline: float | None,
+    timeout_ms: int,
+    futures: list[Any | None],
+    submitted: list[bool],
+) -> Any:
+    batch_size = len(suppliers)
+    if batch_size == 0:
+        return []
+
+    parallelism_limit = min(max(parallelism, 1), batch_size)
+    next_to_submit = 0
+    completed = 0
+    counted = [False] * batch_size
+
+    def in_flight() -> int:
+        return sum(
+            1
+            for i in range(next_to_submit)
+            if futures[i] is not None and not futures[i].done()
+        )
+
+    while completed < batch_size:
+        if deadline is not None and time.monotonic() >= deadline:
+            timeout_message = (
+                f"Async durable batch execution timed out after {timeout_ms} 
ms"
+            )
+            raise _BatchTimeoutError(timeout_message)
+
+        while next_to_submit < batch_size and in_flight() < parallelism_limit:
+            futures[next_to_submit] = 
executor.submit(suppliers[next_to_submit])
+            submitted[next_to_submit] = True
+            next_to_submit += 1
+
+        for i in range(next_to_submit):
+            if not counted[i] and futures[i].done():
+                counted[i] = True
+                completed += 1
+
+        if completed < batch_size:
+            yield
+
+    return _collect_outcomes(futures)
+
+
+def _collect_sliding_window_outcomes_on_timeout(
+    futures: list[Any | None],
+    submitted: list[bool],
+    timeout_exception: BaseException,
+) -> list[Outcome]:
+    outcomes = []
+    for is_submitted, future in zip(submitted, futures, strict=True):
+        if not is_submitted or future is None:
+            outcomes.append(Outcome.failure(timeout_exception))
+            continue
+        if not future.done():
+            future.cancel()
+        if future.done() and not future.cancelled():

Review Comment:
   Thanks, this closes the never-submitted case. I think there's one more case 
in the same timeout path though, and I'm curious how you'd want to handle it.
   
   `submitted[i]` is set at `:364` the moment `executor.submit(...)` returns at 
`:363`, so it really means "handed to the pool" rather than "actually ran". At 
the deadline, `future.cancel()` at `:389` succeeds for anything still sitting 
in the queue, which stops it from ever running. Line `:390` even spots this 
(`future.done() and not future.cancelled()`), but the slot still falls through 
to `Outcome.failure(timeout_exception)` at `:396`. Since `submitted[i]` is 
`True`, `_finalize_batch_execution:1017` hands it to `finalizeCallAt` at 
`:1025` and it gets recorded as FAILED. After a restart, 
`_prepare_batch_execution:984-985` replays that failure rather than running the 
tool.
   
   This only shows up when `tool-call.batch.timeout.ms` is set, since `:306` 
only builds a deadline when it's positive. With it on, you'd get there through 
the busy-pool case you describe at `core_options.py:257-264`: the pool is 
`num-async-threads` (default `2 x cores`) shared per subtask, 
`tool-call.parallelism` defaults to `cores`, so once the pool fills up `submit` 
starts queueing, and any queued slot at the deadline lands here.
   
   One option would be to set the flag as the first line inside the supplier 
wrapper instead (`:363-364` here, 
`java21/ContinuationActionExecutor.java:210-219` on the Java side). That would 
keep both languages saying the same thing, since cancelling a 
`CompletableFuture` doesn't stop an already-queued supplier from running 
(`java21/ContinuationActionExecutor.java:301`). What do you think the flag 
should mean, handed to the pool or actually started?



##########
python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py:
##########
@@ -415,3 +594,458 @@ def collect_kwargs(**kwargs: Any) -> dict[str, Any]:
         _close_runner_context(ctx)
 
     assert result == {}
+
+
+def 
test_flink_runner_context_durable_execute_all_async_runs_calls_in_parallel() -> 
None:
+    j_runner_context = _FakeJavaRunnerContext()
+    config = AgentConfiguration(
+        {"tool-call.batch.timeout.ms": -1, "tool-call.parallelism": 3}
+    )
+    ctx = _create_runner_context(j_runner_context, config=config, 
executor_workers=3)
+    sleep_seconds = 0.2
+
+    def slow_call(value: str) -> str:
+        time.sleep(sleep_seconds)
+        return value
+
+    try:
+        start = time.perf_counter()
+        outcomes = _run_async(
+            ctx.durable_execute_all_async(
+                [
+                    _durable_call(slow_call, "one"),
+                    _durable_call(slow_call, "two"),
+                    _durable_call(slow_call, "three"),
+                ]
+            )
+        )
+        elapsed = time.perf_counter() - start
+    finally:
+        _close_runner_context(ctx)
+
+    assert [outcome.value for outcome in outcomes] == ["one", "two", "three"]
+    assert elapsed < sleep_seconds * 2.5

Review Comment:
   nit: with `sleep_seconds = 0.2` (`:605`), 3 calls and parallelism 3, moving 
to `2.5` shifts the threshold from 400 ms to 500 ms. That gives 100 ms more 
room for jitter, but it also halves the gap below the fully-serial 600 ms, from 
200 ms down to 100 ms. The check also only ever caught fully serial execution, 
since 2 parallel plus 1 serial comes in around 400 ms and passes either way.
   
   A `threading.Barrier(3)` awaited inside `slow_call` would make it 
deterministic instead. If execution went serial, the first call would block, 
the barrier would break, and the existing value check at `:626` would fail on 
its own, so the timing assert could go away. This one did go red on `ut-python 
[macos]` at the previous head `204eb7a3`. Would a barrier be worth it here?



##########
docs/content/docs/operations/configuration.md:
##########
@@ -131,9 +131,11 @@ Here is the list of all built-in core configuration 
options.
 | `max-retries`             | 3                          | int                 
  | Number of retries when using `ErrorHandlingStrategy.RETRY`.                 
                                                                                
                                                                                
                    |
 | `retry-wait-interval`     | 1                          | int                 
  | Base wait interval in seconds between retries when using 
`ErrorHandlingStrategy.RETRY`. 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 connection name. |
 | `chat.async`              | true                       | boolean             
  | Whether chat asynchronously for built-in chat action.                       
                                                                                
                                                                                
                    |
-| `tool-call.async`         | true                       | boolean             
  | Whether process tool call for built-in tool call 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 and 
unfinished slots fail. 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. |

Review Comment:
   Now that timed-out slots that never got submitted stay PENDING instead of 
failing, a few places still describe the old behaviour:
   
   - this row: "On timeout, completed slots keep their outcome and unfinished 
slots fail."
   - `docs/content/docs/development/tool_use.md:317-318`, the same sentence.
   - `AgentExecutionOptions.java:78-79`, "When the deadline elapses, unfinished 
slots are failed", which sits right above the new thread-reclamation text at 
`:81-87`.
   
   PENDING versus FAILED is what decides whether a timed-out tool runs again 
after a restart, so it seems worth keeping these in step with the code. Could 
the three be updated together?
   
   nit: the thread-reclamation note you added went into 
`AgentExecutionOptions.java:81-87` and `core_options.py:272-280`, but not into 
either doc page. The `tool-call.parallelism` row at `:135` keeps its capacity 
warning in a `{{< hint warning >}}` block, if you wanted somewhere to put it.



##########
runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java:
##########
@@ -477,7 +599,7 @@ protected <T> Optional<T> tryGetCachedResult(
             } else if (resultPayload != null) {
                 return Optional.of(OBJECT_MAPPER.readValue(resultPayload, 
resultClass));
             } else {
-                return Optional.of(null);
+                return Optional.empty();

Review Comment:
   Happy to leave this out of the PR. One thing on the reasoning though, in 
case it matters later.
   
   The PENDING check is real (`:789-797`), but it's the only check. 
`matchNextOrClearSubsequentCallResult` returns a hit at `:804-806` once 
`matches(functionId, argsDigest)` passes and the slot isn't pending, and 
nothing looks at the payloads. So a SUCCEEDED slot with a null result payload 
still gets through, and reaches `Optional.of(null)` at `:606`.
   
   That's also how a `null` return gets stored. `serializeDurableResult(null)` 
returns `null` rather than a JSON `null` (`:691-693`), so a durable call that 
legitimately returns `null` is saved as SUCCEEDED with no payload, and NPEs on 
replay. `ToolCallAction` is fine, since the per-execution `catch` at 
`ToolCallAction.java:187-197` turns it into a tool failure. The case that's 
actually exposed is a user action calling `durableExecute` directly.
   
   `main` carries the identical line (`RunnerContextImpl.java:618` there), so 
this PR isn't making anything worse, which is why I think deferring is the 
right call.
   
   Worth an issue though, so it doesn't get lost. The same class already 
handles this case elsewhere: `readTerminalOutcomeAt` at `:566-568` does `if 
(callResult.getResultPayload() == null) return Outcome.success(null);`.



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