This is an automated email from the ASF dual-hosted git repository.

wenjin272 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 6d0a261d5 [runtime][java][python] Keep durable gather outcomes 
completed before an ordinary cancellation (#1239)
6d0a261d5 is described below

commit 6d0a261d50c11d3385d520dc2ba635409d0afde0
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Tue Oct 13 15:06:10 2026 +0900

    [runtime][java][python] Keep durable gather outcomes completed before an 
ordinary cancellation (#1239)
    
    Generated-by: Claude Code 2.1.296 (Claude Opus 5.5)
---
 .../flink_agents/runtime/flink_runner_context.py   | 44 +++++++++++++++---
 .../runtime/tests/test_durable_exception.py        | 35 ++++++++++++---
 .../runtime/context/JavaRunnerContextImpl.java     | 39 ++++++++++------
 ...vaRunnerContextImplDurableExecuteAsyncTest.java | 52 ++++++++++++++++++++++
 4 files changed, 146 insertions(+), 24 deletions(-)

diff --git a/python/flink_agents/runtime/flink_runner_context.py 
b/python/flink_agents/runtime/flink_runner_context.py
index 2d68b7906..23e0654c8 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -407,11 +407,38 @@ class _BatchAsyncExecutionResult:
             executed = _collect_sliding_window_outcomes_on_timeout(
                 batch_futures, exception
             )
+        # An ordinary interruption must not be cached as a business failure 
either.
+        # Durable children before it in input order are still finalized, as in 
a
+        # durable-only batch, so recovery does not run a call whose outcome is 
known.
+        interrupted = next(
+            (
+                position
+                for position, (index, outcome) in enumerate(
+                    zip(execution_indexes, executed, strict=True)
+                )
+                if isinstance(self._futures[index], _SingleAsyncFuture)
+                and is_java_interruption(outcome.error)
+            ),
+            None,
+        )
+        if interrupted is not None:
+            if plan is not None:
+                prefix = [
+                    position
+                    for position in durable_execution_indexes
+                    if position < interrupted
+                ]
+                # The call index stays at the batch base, so re-awaiting 
rereads
+                # the saved prefix.
+                self._ctx._finalize_batch_execution(
+                    durable_calls,
+                    plan,
+                    [started[position] for position in prefix],
+                    [executed[position] for position in prefix],
+                    advance_call_index=False,
+                )
+            raise executed[interrupted].error
         for index, outcome in zip(execution_indexes, executed, strict=True):
-            if isinstance(
-                self._futures[index], _SingleAsyncFuture
-            ) and is_java_interruption(outcome.error):
-                raise outcome.error
             outcomes[index] = outcome
         if plan is not None:
             durable_outcomes = self._ctx._finalize_batch_execution(
@@ -1490,11 +1517,15 @@ class FlinkRunnerContext(RunnerContext, 
ExecutionReporter):
         plan: _BatchExecutionPlan,
         started: list[bool],
         executed: list[Outcome],
+        *,
+        advance_call_index: bool = True,
     ) -> list[Outcome]:
+        # ``executed`` may be a prefix of ``plan.suppliers`` when an ordinary
+        # child in the same batch was interrupted.
         base = self._j_runner_context.getCurrentCallIndex()
         outcomes = list(plan.outcomes)
         for i, ((call_index, _), outcome) in enumerate(
-            zip(plan.suppliers, executed, strict=True)
+            zip(plan.suppliers[: len(executed)], executed, strict=True)
         ):
             call = calls[call_index]
             function_id = self._durable_identity(call)
@@ -1520,7 +1551,8 @@ class FlinkRunnerContext(RunnerContext, 
ExecutionReporter):
             except Exception as e:
                 outcome = Outcome.failure(e)
             outcomes[call_index] = outcome
-        self._j_runner_context.advanceCallIndexBy(len(calls))
+        if advance_call_index:
+            self._j_runner_context.advanceCallIndexBy(len(calls))
         return outcomes
 
     @override
diff --git a/python/flink_agents/runtime/tests/test_durable_exception.py 
b/python/flink_agents/runtime/tests/test_durable_exception.py
index 8950af89b..3888acdda 100644
--- a/python/flink_agents/runtime/tests/test_durable_exception.py
+++ b/python/flink_agents/runtime/tests/test_durable_exception.py
@@ -677,6 +677,18 @@ def 
test_ordinary_java_interruption_propagates_without_caching(
             raise interruption
         return "retried"
 
+    durable_calls = []
+
+    def durable() -> str:
+        durable_calls.append(1)
+        return "durable"
+
+    later_calls = []
+
+    def later() -> str:
+        later_calls.append(1)
+        return "later"
+
     store = _FakeJavaRunnerContext()
     ctx = _create_runner_context(store)
     try:
@@ -685,18 +697,25 @@ def 
test_ordinary_java_interruption_propagates_without_caching(
             handle = future
         else:
             sibling = (
-                ctx.durable_execute_async(lambda: "durable")
+                ctx.durable_execute_async(durable)
                 if mode == "mixed_batch"
                 else ctx.execute_async(lambda: "ordinary")
             )
-            handle = ctx.gather(sibling, future)
+            if mode == "mixed_batch":
+                handle = ctx.gather(sibling, future, 
ctx.durable_execute_async(later))
+            else:
+                handle = ctx.gather(sibling, future)
         with pytest.raises(type(interruption)) as raised:
             _run_async(handle)
         assert raised.value is interruption
         assert store.current_call_index == 0
+        # The durable sibling completed before the interruption, so its 
outcome is
+        # kept and recovery rereads it instead of running it again.
         assert [slot.status for slot in store.call_results] == (
-            ["PENDING"] if mode == "mixed_batch" else []
+            ["SUCCEEDED", "PENDING"] if mode == "mixed_batch" else []
         )
+        if mode == "mixed_batch":
+            assert later_calls == [1]
 
         result = _run_async(handle)
         assert (result if mode == "single" else result[1].value) == "retried"
@@ -704,8 +723,14 @@ def 
test_ordinary_java_interruption_propagates_without_caching(
         assert len(calls) == 2
         if mode == "mixed_batch":
             assert result[0].value == "durable"
-            assert [slot.status for slot in store.call_results] == 
["SUCCEEDED"]
-            assert store.current_call_index == 1
+            assert durable_calls == [1]
+            assert result[2].value == "later"
+            assert later_calls == [1, 1]
+            assert [slot.status for slot in store.call_results] == [
+                "SUCCEEDED",
+                "SUCCEEDED",
+            ]
+            assert store.current_call_index == 2
         else:
             assert store.operations == []
     finally:
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 d4f541637..655368230 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
@@ -144,26 +144,39 @@ public class JavaRunnerContextImpl extends 
RunnerContextImpl {
         }
 
         BatchExecutionResult<T> executed = executeOutcomeSuppliers(suppliers);
+        // An ordinary cancellation must not be cached as a business failure 
either. Durable
+        // children before it in input order are still finalized, as in a 
durable-only batch, so
+        // recovery does not run a call whose outcome is already known.
+        int ordinaryCancellation = executed.getOutcomes().size();
         for (int i = 0; i < executed.getOutcomes().size(); i++) {
-            Outcome<T> outcome = executed.getOutcomes().get(i);
-            // An ordinary cancellation must not be cached as a business 
failure either.
-            if (futures.get(executableIndexes.get(i)).getDurableCallable() == 
null) {
-                rethrowCancellation(outcome.getError());
+            if (futures.get(executableIndexes.get(i)).getDurableCallable() == 
null
+                    && 
CancellationUtils.isCancellation(executed.getOutcomes().get(i).getError())) {
+                ordinaryCancellation = i;
+                break;
             }
-            outcomes.set(executableIndexes.get(i), outcome);
         }
         List<Outcome<T>> durableOutcomes = new ArrayList<>();
-        boolean[] durableStarted = new boolean[durableExecutionIndexes.size()];
-        for (int i = 0; i < durableExecutionIndexes.size(); i++) {
-            int executionIndex = durableExecutionIndexes.get(i);
+        List<Boolean> durableStarted = new ArrayList<>();
+        for (int executionIndex : durableExecutionIndexes) {
+            if (executionIndex >= ordinaryCancellation) {
+                break;
+            }
             durableOutcomes.add(executed.getOutcomes().get(executionIndex));
-            durableStarted[i] = executed.wasStarted(executionIndex);
+            durableStarted.add(executed.wasStarted(executionIndex));
+        }
+        boolean[] started = new boolean[durableStarted.size()];
+        for (int i = 0; i < started.length; i++) {
+            started[i] = durableStarted.get(i);
         }
         finalizeExecutedOutcomes(
-                durableCalls,
-                base,
-                plan,
-                new BatchExecutionResult<>(durableOutcomes, durableStarted));
+                durableCalls, base, plan, new 
BatchExecutionResult<>(durableOutcomes, started));
+        if (ordinaryCancellation < executed.getOutcomes().size()) {
+            // The call index stays at the batch base, so re-awaiting rereads 
the saved prefix.
+            
rethrowCancellation(executed.getOutcomes().get(ordinaryCancellation).getError());
+        }
+        for (int i = 0; i < executed.getOutcomes().size(); i++) {
+            outcomes.set(executableIndexes.get(i), 
executed.getOutcomes().get(i));
+        }
         durableIndex = 0;
         for (int i = 0; i < futures.size(); i++) {
             if (futures.get(i).getDurableCallable() != null) {
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 67ce7c7ec..b1ccc2a44 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
@@ -816,6 +816,58 @@ class JavaRunnerContextImplDurableExecuteAsyncTest {
                         + " the pending slots instead of skipping them");
     }
 
+    @Test
+    void testMixedGatherOrdinaryInterruptionKeepsEarlierDurableOutcomes() 
throws Exception {
+        InspectingContinuationActionExecutor executor = new 
InspectingContinuationActionExecutor();
+        JavaRunnerContextImpl context = createContext(new ActionState(null), 
executor);
+        AtomicInteger chargeCalls = new AtomicInteger();
+        AtomicInteger ordinaryCalls = new AtomicInteger();
+        InterruptedException interruption = new 
InterruptedException("cancelled");
+        TestDurableCallable<String> charge =
+                new TestDurableCallable<>(
+                        "charge",
+                        String.class,
+                        () -> {
+                            chargeCalls.incrementAndGet();
+                            return "charged";
+                        });
+        TestDurableCallable<String> later =
+                new TestDurableCallable<>("later", String.class, () -> 
"later");
+        AsyncFuture<String> ordinary =
+                context.executeAsync(
+                        () -> {
+                            if (ordinaryCalls.incrementAndGet() == 1) {
+                                throw interruption;
+                            }
+                            return "retried";
+                        });
+        AsyncFuture<List<Outcome<String>>> gathered =
+                context.gather(
+                        List.of(
+                                context.durableExecuteAsync(charge),
+                                ordinary,
+                                context.durableExecuteAsync(later)));
+        Thread.interrupted(); // clear any stray interrupt flag left over from 
another test
+
+        assertSame(interruption, assertThrows(InterruptedException.class, 
gathered::await));
+
+        assertTrue(Thread.interrupted());
+        List<CallResult> persisted =
+                
context.getDurableExecutionContext().getActionState().getCallResults();
+        assertTrue(persisted.get(0).isSuccess());
+        assertTrue(persisted.get(1).isPending());
+        assertEquals(0, 
context.getDurableExecutionContext().getCurrentCallIndex());
+
+        List<Outcome<String>> outcomes = gathered.await();
+
+        assertEquals("charged", outcomes.get(0).getValue());
+        assertEquals("retried", outcomes.get(1).getValue());
+        assertEquals("later", outcomes.get(2).getValue());
+        assertEquals(1, chargeCalls.get());
+        assertEquals(2, ordinaryCalls.get());
+        assertEquals(2, 
context.getDurableExecutionContext().getCurrentCallIndex());
+    }
+
     @Test
     void testGatherWithoutDurableStateInterruptionPropagates() throws 
Exception {
         InspectingContinuationActionExecutor executor = new 
InspectingContinuationActionExecutor();

Reply via email to