This is an automated email from the ASF dual-hosted git repository.
damccorm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 402b41c726d Fix ThreadSanitizer data race and failed RPC calls (#39076)
402b41c726d is described below
commit 402b41c726d14d6bd2064629bf82353712563bdd
Author: Tejas Iyer <[email protected]>
AuthorDate: Mon Jun 29 07:28:15 2026 -0700
Fix ThreadSanitizer data race and failed RPC calls (#39076)
* Fix ThreadSanitizer data race in AsyncWrapperTest
* Fix failed future memory leak to support transient RPC retries
* Fix AsyncWrapper to not mark cancelled futures as finished
---
.../apache/beam/sdk/transforms/AsyncWrapper.java | 10 ++--
.../beam/sdk/transforms/AsyncWrapperTest.java | 66 +++++++++++++++++++++-
sdks/python/apache_beam/transforms/async_dofn.py | 4 +-
.../apache_beam/transforms/async_dofn_test.py | 47 +++++++++++++++
4 files changed, 121 insertions(+), 6 deletions(-)
diff --git
a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/AsyncWrapper.java
b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/AsyncWrapper.java
index f6272ba4fe6..23372be2a2d 100644
---
a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/AsyncWrapper.java
+++
b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/AsyncWrapper.java
@@ -644,15 +644,17 @@ public class AsyncWrapper<K, InputT, OutputT> extends
DoFn<KV<K, InputT>, Output
if (activeElements.containsKey(elementId)) {
InFlightElement<OutputT> inFlight = activeElements.get(elementId);
+ // Future is either completed, cancelled, or throws an exception
if (inFlight.future.isDone()) {
+ // Remove from local active map before checking the result
+ activeElements.remove(elementId);
try {
if (!inFlight.future.isCancelled()) {
toReturn.add(inFlight.future.get());
+ // Only mark as finished if future was not cancelled
+ finishedElementIds.add(elementId);
+ itemsFinished++;
}
-
- finishedElementIds.add(elementId);
- activeElements.remove(elementId);
- itemsFinished++;
} catch (Exception e) {
LOG.error("Error executing async task for element {}", element,
e);
throw new RuntimeException("Error executing async task for
element " + element, e);
diff --git
a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/AsyncWrapperTest.java
b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/AsyncWrapperTest.java
index 183b1851459..d098104a8cf 100644
---
a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/AsyncWrapperTest.java
+++
b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/AsyncWrapperTest.java
@@ -19,6 +19,7 @@ package org.apache.beam.sdk.transforms;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
import java.io.Serializable;
import java.util.ArrayList;
@@ -179,7 +180,7 @@ public class AsyncWrapperTest implements Serializable {
// 4. Used for testing Timer mock implementations.
private static class FakeTimer implements Timer {
- private Instant time = Instant.EPOCH;
+ private volatile Instant time = Instant.EPOCH;
@Override
public void set(Instant absoluteTime) {
@@ -874,4 +875,67 @@ public class AsyncWrapperTest implements Serializable {
// Verify calling resetState() while background tasks are running finishes
cleanly
AsyncWrapper.resetState();
}
+
+ // Test 15: testTransientRpcFailureRetry
+ // Verify DoFn exceptions wipe local active state so bundle retries
reschedule work.
+ @Test
+ public void testTransientRpcFailureRetry() {
+ FlakyDoFn dofn = new FlakyDoFn();
+ AsyncWrapper<String, String, String> asyncWrapper =
+ new AsyncWrapper<>(
+ dofn, 1, Duration.standardSeconds(5), null, null, null, null,
useThreadPool);
+ asyncWrapper.setup(null);
+
+ FakeBagState<KV<String, String>> fakeBagState = new FakeBagState<>();
+ FakeTimer fakeTimer = new FakeTimer();
+ KV<String, String> msg = KV.of("key1", "1");
+
+ asyncWrapper.processDirect(msg, GlobalWindow.INSTANCE, Instant.now(),
fakeBagState, fakeTimer);
+ waitForEmpty(asyncWrapper);
+
+ // Attempt 1 should throw RuntimeException wrapping ExecutionException
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ asyncWrapper.commitFinishedItemsDirect(
+ fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer));
+
+ // Verify the failed Future was removed from local active elements
+ assertEquals(0, asyncWrapper.getItemsInBufferCount());
+
+ // Simulate runner bundle retry: commitFinishedItemsDirect runs again with
msg still in state.
+ // Because the dead future was removed, it will reschedule msg and succeed
on Attempt 2.
+ waitForEmpty(asyncWrapper);
+ List<String> result =
+ asyncWrapper.commitFinishedItemsDirect(
+ fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer);
+ if (result.isEmpty()) {
+ waitForEmpty(asyncWrapper);
+ result =
+ asyncWrapper.commitFinishedItemsDirect(
+ fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer);
+ }
+
+ checkOutput(result, Collections.singletonList("1"));
+ assertEquals(0, fakeBagState.items.size());
+ }
+
+ private static class FlakyDoFn extends DoFn<String, String> {
+ private int attempts = 0;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ @ProcessElement
+ public void processElement(@Element String element, OutputReceiver<String>
receiver) {
+ lock.lock();
+ try {
+ attempts++;
+ if (attempts == 1) {
+ throw new RuntimeException("Transient RPC Error");
+ }
+ } finally {
+ lock.unlock();
+ }
+ receiver.output(element);
+ }
+ }
}
diff --git a/sdks/python/apache_beam/transforms/async_dofn.py
b/sdks/python/apache_beam/transforms/async_dofn.py
index ad3d5bc6646..f104770d8c0 100644
--- a/sdks/python/apache_beam/transforms/async_dofn.py
+++ b/sdks/python/apache_beam/transforms/async_dofn.py
@@ -489,9 +489,11 @@ class AsyncWrapper(beam.DoFn):
if x_id in processing_elements:
_, future = processing_elements[x_id]
if future.done():
+ # Pop from local active map before checking the result
+ processing_elements.pop(x_id)
to_return.append(future.result())
+ # Only mark as finished if result() succeeded without exception
finished_items.append(x)
- processing_elements.pop(x_id)
items_finished += 1
else:
items_not_yet_finished += 1
diff --git a/sdks/python/apache_beam/transforms/async_dofn_test.py
b/sdks/python/apache_beam/transforms/async_dofn_test.py
index 39901d791fb..f9e07d57be9 100644
--- a/sdks/python/apache_beam/transforms/async_dofn_test.py
+++ b/sdks/python/apache_beam/transforms/async_dofn_test.py
@@ -522,6 +522,53 @@ class AsyncTest(unittest.TestCase):
else:
self.assertEqual(p.exitcode, 0)
+ def test_transient_rpc_failure_retry(self):
+ # Verify DoFn exceptions wipe local active state so retries reschedule
work.
+ class FlakyDoFn(beam.DoFn):
+ def __init__(self):
+ self.attempts = 0
+ self.lock = Lock()
+
+ def process(self, element):
+ with self.lock:
+ self.attempts += 1
+ current_attempt = self.attempts
+ if current_attempt == 1:
+ raise RuntimeError("Transient RPC Error")
+ yield element
+
+ dofn = FlakyDoFn()
+ async_dofn = async_lib.AsyncWrapper(dofn, use_asyncio=self.use_asyncio)
+ async_dofn.setup()
+ fake_bag_state = FakeBagState([])
+ fake_timer = FakeTimer(0)
+ msg = ('key1', 1)
+
+ async_dofn.process(msg, to_process=fake_bag_state, timer=fake_timer)
+ self.wait_for_empty(async_dofn)
+
+ # Attempt 1 should raise the RuntimeError stored in the future
+ with self.assertRaises(RuntimeError):
+ async_dofn.commit_finished_items(fake_bag_state, fake_timer)
+
+ # Verify the failed future was popped from local processing_elements
+ with async_lib.AsyncWrapper._lock:
+ self.assertNotIn(
+ async_dofn._id_fn(msg[1]),
+ async_lib.AsyncWrapper._processing_elements[async_dofn._uuid],
+ )
+
+ # Simulate runner bundle retry: commit_finished_items runs again with msg
still in state.
+ # Because the dead future was popped, it will reschedule msg and succeed
on Attempt 2.
+ self.wait_for_empty(async_dofn)
+ result = async_dofn.commit_finished_items(fake_bag_state, fake_timer)
+ if not result:
+ self.wait_for_empty(async_dofn)
+ result = async_dofn.commit_finished_items(fake_bag_state, fake_timer)
+
+ self.check_output(result, [msg])
+ self.assertEqual(fake_bag_state.items, [])
+
if __name__ == '__main__':
unittest.main()