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 6323cbd1 [runtime] Replay a durable call that returned null instead of 
throwing (#1171)
6323cbd1 is described below

commit 6323cbd196a5dc6d27efe6a301be79c4514c20d1
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Tue Sep 29 16:27:16 2026 +0900

    [runtime] Replay a durable call that returned null instead of throwing 
(#1171)
    
    Generated-by: Claude Code 2.1.283 (Claude Opus 5.5)
---
 .../agents/runtime/context/RunnerContextImpl.java  | 21 ++++++++----
 ...vaRunnerContextImplDurableExecuteAsyncTest.java | 21 ++++++++++++
 .../RunnerContextImplDurableExecuteTest.java       | 40 ++++++++++++++++++++++
 3 files changed, 75 insertions(+), 7 deletions(-)

diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
index ec0eccec..e09d50b8 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
@@ -620,10 +620,10 @@ public class RunnerContextImpl implements RunnerContext, 
ExecutionReporter {
             return executeAndFinalizeCurrentCall(functionId, argsDigest, 
executionCallable);
         }
 
-        Optional<T> cachedResult =
+        Optional<Outcome<T>> cachedResult =
                 tryGetCachedResult(functionId, argsDigest, 
durableCallable.getResultClass());
         if (cachedResult.isPresent()) {
-            return cachedResult.get();
+            return cachedResult.get().getValue();
         }
 
         T result = null;
@@ -916,7 +916,13 @@ public class RunnerContextImpl implements RunnerContext, 
ExecutionReporter {
         return null;
     }
 
-    protected <T> Optional<T> tryGetCachedResult(
+    /**
+     * Returns the recorded outcome of the next durable call when it matches 
and has finished, or an
+     * empty {@code Optional} when there is nothing to replay. A recorded 
failure is rethrown. A
+     * recorded success may carry a {@code null} value (for example a {@code 
Void} call), so the
+     * presence of the outcome, not its value, marks the hit.
+     */
+    protected <T> Optional<Outcome<T>> tryGetCachedResult(
             String functionId, String argsDigest, Class<T> resultClass) throws 
Exception {
         Object[] cached = matchNextOrClearSubsequentCallResult(functionId, 
argsDigest);
         if (cached != null && (Boolean) cached[0]) {
@@ -928,9 +934,10 @@ public class RunnerContextImpl implements RunnerContext, 
ExecutionReporter {
                         OBJECT_MAPPER.readValue(exceptionPayload, 
DurableExecutionException.class);
                 throw cachedException.toException();
             } else if (resultPayload != null) {
-                return Optional.of(OBJECT_MAPPER.readValue(resultPayload, 
resultClass));
+                return Optional.of(
+                        Outcome.success(OBJECT_MAPPER.readValue(resultPayload, 
resultClass)));
             } else {
-                return Optional.of(null);
+                return Optional.of(Outcome.success(null));
             }
         }
         return Optional.empty();
@@ -978,10 +985,10 @@ public class RunnerContextImpl implements RunnerContext, 
ExecutionReporter {
         }
 
         if (!current.isPending()) {
-            Optional<T> cachedResult =
+            Optional<Outcome<T>> cachedResult =
                     tryGetCachedResult(functionId, argsDigest, 
durableCallable.getResultClass());
             if (cachedResult.isPresent()) {
-                return cachedResult.get();
+                return cachedResult.get().getValue();
             }
             throw new IllegalStateException(
                     String.format(
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 401374d0..35f9ed08 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
@@ -146,6 +146,27 @@ class JavaRunnerContextImplDurableExecuteAsyncTest {
         assertEquals(0, persistCallCount.get());
     }
 
+    @Test
+    void testDurableExecuteAsyncReconcilableReplayNullSuccess() throws 
Exception {
+        InspectingContinuationActionExecutor executor = new 
InspectingContinuationActionExecutor();
+        ActionState actionState = new ActionState(null);
+        actionState.addCallResult(new CallResult("recon-async", "", null, 
null));
+        JavaRunnerContextImpl context = createContext(actionState, executor);
+        TestReconcilableCallable<Void> callable =
+                new TestReconcilableCallable<>(
+                        "recon-async",
+                        Void.class,
+                        () -> fail("call should not be executed"),
+                        () -> fail("reconcile should not be called for 
terminal slot"));
+
+        assertNull(context.durableExecuteAsync(callable).await());
+        assertEquals(0, callable.getCallCount());
+        assertEquals(0, callable.getReconcileCount());
+        assertEquals(0, executor.getExecuteAsyncCallCount());
+        assertEquals(0, persistCallCount.get());
+        assertEquals(1, 
context.getDurableExecutionContext().getActionState().getCallResultCount());
+    }
+
     @Test
     void testDurableExecuteAsyncReconcilableReconcileSuccess() throws 
Exception {
         InspectingContinuationActionExecutor executor = new 
InspectingContinuationActionExecutor();
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
index b85ea929..2cad205e 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
@@ -105,6 +105,26 @@ class RunnerContextImplDurableExecuteTest {
         assertEquals(1, persistCallCount.get());
     }
 
+    @Test
+    void nullResultReplaysThroughRuntimeStateWithoutReExecution() throws 
Exception {
+        RunnerContextImpl context = createContext(new ActionState(new 
Event("test")));
+        TestDurableCallable<Void> callable =
+                new TestDurableCallable<>("void-call", Void.class, () -> null);
+        assertNull(context.durableExecute(callable));
+
+        ActionState restored =
+                
org.apache.flink.agents.runtime.actionstate.ActionStateSerde.deserialize(
+                        
org.apache.flink.agents.runtime.actionstate.ActionStateSerde.serialize(
+                                lastPersistedState));
+        RunnerContextImpl replay = createContext(restored);
+
+        assertNull(replay.durableExecute(callable));
+        assertEquals(1, callable.getCallCount());
+        assertEquals(1, persistCallCount.get());
+        assertEquals(1, 
replay.getDurableExecutionContext().getCurrentCallIndex());
+        assertEquals(1, 
replay.getDurableExecutionContext().getActionState().getCallResultCount());
+    }
+
     @Test
     void testDurableExecuteCompletionOnlyDoesNotPersistInterruption() {
         RunnerContextImpl context = createContext(new ActionState(null));
@@ -288,6 +308,26 @@ class RunnerContextImplDurableExecuteTest {
         assertEquals(1, 
context.getDurableExecutionContext().getCurrentCallIndex());
     }
 
+    @Test
+    void testDurableExecuteReconcilableReplayNullSuccess() throws Exception {
+        ActionState actionState = new ActionState(null);
+        actionState.addCallResult(new CallResult("recon-call", "", null, 
null));
+        RunnerContextImpl context = createContext(actionState);
+        TestReconcilableCallable<String> callable =
+                new TestReconcilableCallable<>(
+                        "recon-call",
+                        String.class,
+                        () -> fail("call should not be re-executed"),
+                        () -> fail("reconcile should not be called for 
terminal slot"));
+
+        assertNull(context.durableExecute(callable));
+        assertEquals(0, callable.getCallCount());
+        assertEquals(0, callable.getReconcileCount());
+        assertEquals(0, persistCallCount.get());
+        assertEquals(1, 
context.getDurableExecutionContext().getCurrentCallIndex());
+        assertEquals(1, 
context.getDurableExecutionContext().getActionState().getCallResultCount());
+    }
+
     @Test
     void testDurableExecuteReconcilableReplayFailure() throws Exception {
         ActionState actionState = new ActionState(null);

Reply via email to