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

snuyanzin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new 2229f1bdc37 [FLINK-40456][datastream] `AsyncWaitOperator` can 
permanently drop an element's result when timeout races with a retry
2229f1bdc37 is described below

commit 2229f1bdc379805a7b2e1ad8d5f8aa0fd9e240e3
Author: Sergey Nuyanzin <[email protected]>
AuthorDate: Mon Aug 24 17:52:43 2026 +0200

    [FLINK-40456][datastream] `AsyncWaitOperator` can permanently drop an 
element's result when timeout races with a retry
---
 .../api/operators/async/AsyncWaitOperator.java     | 18 +++++--
 .../api/operators/async/AsyncWaitOperatorTest.java | 60 ++++++++++++++++++++++
 2 files changed, 75 insertions(+), 3 deletions(-)

diff --git 
a/flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperator.java
 
b/flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperator.java
index 873e7c3ff67..7c2972d8ee8 100644
--- 
a/flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperator.java
+++ 
b/flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperator.java
@@ -484,6 +484,9 @@ public class AsyncWaitOperator<IN, OUT>
          */
         private final AtomicBoolean retryAwaiting = new AtomicBoolean(false);
 
+        // set once the timeout fired; makes the result terminal and bypass 
the retry path
+        private final AtomicBoolean timedOut = new AtomicBoolean(false);
+
         public RetryableResultHandlerDelegator(
                 StreamRecord<IN> inputRecord,
                 ResultFuture<OUT> resultFuture,
@@ -511,6 +514,9 @@ public class AsyncWaitOperator<IN, OUT>
                 // cancel delayed retry timer first
                 cancelRetryTimer();
 
+                // timeout result is terminal: route it straight to the 
handler, not the retry path
+                timedOut.set(true);
+
                 // force reset retryAwaiting to prevent the handler to trigger 
retry unnecessarily
                 retryAwaiting.set(false);
 
@@ -522,7 +528,7 @@ public class AsyncWaitOperator<IN, OUT>
         public void complete(Collection<OUT> results) {
             Preconditions.checkNotNull(
                     results, "Results must not be null, use empty collection 
to emit nothing");
-            if (!retryDisabledOnFinish.get() && 
resultHandler.inputRecord.isRecord()) {
+            if (shouldProcessResultForRetry()) {
                 processRetryInMailBox(results, null);
             } else {
                 cancelRetryTimer();
@@ -533,7 +539,7 @@ public class AsyncWaitOperator<IN, OUT>
 
         @Override
         public void completeExceptionally(Throwable error) {
-            if (!retryDisabledOnFinish.get() && 
resultHandler.inputRecord.isRecord()) {
+            if (shouldProcessResultForRetry()) {
                 processRetryInMailBox(null, error);
             } else {
                 cancelRetryTimer();
@@ -546,7 +552,7 @@ public class AsyncWaitOperator<IN, OUT>
         public void complete(CollectionSupplier<OUT> supplier) {
             Preconditions.checkNotNull(
                     supplier, "Runnable must not be null, return empty 
collection to emit nothing");
-            if (!retryDisabledOnFinish.get() && 
resultHandler.inputRecord.isRecord()) {
+            if (shouldProcessResultForRetry()) {
                 mailboxExecutor.submit(
                         () -> {
                             try {
@@ -563,6 +569,12 @@ public class AsyncWaitOperator<IN, OUT>
             }
         }
 
+        private boolean shouldProcessResultForRetry() {
+            return !timedOut.get()
+                    && !retryDisabledOnFinish.get()
+                    && resultHandler.inputRecord.isRecord();
+        }
+
         private void processRetryInMailBox(Collection<OUT> results, Throwable 
error) {
             mailboxExecutor.execute(
                     () -> processRetry(results, error), "delayed retry or 
complete");
diff --git 
a/flink-streaming-java/src/test/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperatorTest.java
 
b/flink-streaming-java/src/test/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperatorTest.java
index 2c8dc6fb880..e4784392f64 100644
--- 
a/flink-streaming-java/src/test/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperatorTest.java
+++ 
b/flink-streaming-java/src/test/java/org/apache/flink/streaming/api/operators/async/AsyncWaitOperatorTest.java
@@ -1399,6 +1399,66 @@ public class AsyncWaitOperatorTest {
         
testProcessingTimeAlwaysTimeoutFunctionWithRetry(AsyncDataStream.OutputMode.UNORDERED);
     }
 
+    /** A timeout firing while a retry is already queued must still emit its 
result. */
+    @Test
+    void testTimeoutRaceWithRetry() throws Exception {
+        ControllableExceptionThenTimeoutFunction.releaseCompletion = new 
CountDownLatch(1);
+        ControllableExceptionThenTimeoutFunction.completionEnqueued = new 
CountDownLatch(1);
+
+        try (OneInputStreamOperatorTestHarness<Integer, Integer> testHarness =
+                createTestHarnessWithRetry(
+                        new ControllableExceptionThenTimeoutFunction(),
+                        TIMEOUT,
+                        1,
+                        AsyncDataStream.OutputMode.UNORDERED,
+                        exceptionRetryStrategy)) {
+
+            testHarness.open();
+            testHarness.setProcessingTime(0L);
+
+            // start the async call; its timeout timer is now registered at 
TIMEOUT
+            synchronized (testHarness.getCheckpointLock()) {
+                testHarness.processElement(new StreamRecord<>(1, 1L));
+            }
+
+            // let the async call complete exceptionally, then wait until the 
resulting retry mail
+            // is actually enqueued in the mailbox
+            
ControllableExceptionThenTimeoutFunction.releaseCompletion.countDown();
+            
ControllableExceptionThenTimeoutFunction.completionEnqueued.await();
+
+            // fire the timeout while the retry mail is still queued, then run 
both mails in order
+            testHarness.setProcessingTime(TIMEOUT + 1L);
+            drainMailbox(testHarness);
+
+            assertThat(testHarness.getOutput()).containsExactly(new 
StreamRecord<>(-1, 1L));
+        }
+    }
+
+    private static class ControllableExceptionThenTimeoutFunction
+            extends AlwaysTimeoutWithDefaultValueAsyncFunction {
+        private static final long serialVersionUID = 2L;
+
+        // released by the test to let the async call complete exceptionally
+        static CountDownLatch releaseCompletion;
+        // counted down once the exceptional completion has been enqueued into 
the mailbox
+        static CountDownLatch completionEnqueued;
+
+        @Override
+        public void asyncInvoke(Integer input, ResultFuture<Integer> 
resultFuture) {
+            tryCounts.merge(input, 1, Integer::sum);
+            CompletableFuture.runAsync(
+                    () -> {
+                        try {
+                            releaseCompletion.await();
+                        } catch (InterruptedException e) {
+                            throw new RuntimeException(e);
+                        }
+                        resultFuture.completeExceptionally(new 
Exception("Dummy error"));
+                        completionEnqueued.countDown();
+                    });
+        }
+    }
+
     /**
      * Test the AsyncWaitOperator with an always-timeout async function under 
ordered mode and
      * processing time.

Reply via email to