Skip to content

Commit 402b41c

Browse files
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
1 parent f17b370 commit 402b41c

4 files changed

Lines changed: 121 additions & 6 deletions

File tree

sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/AsyncWrapper.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -644,15 +644,17 @@ void commitFinishedItems(
644644

645645
if (activeElements.containsKey(elementId)) {
646646
InFlightElement<OutputT> inFlight = activeElements.get(elementId);
647+
// Future is either completed, cancelled, or throws an exception
647648
if (inFlight.future.isDone()) {
649+
// Remove from local active map before checking the result
650+
activeElements.remove(elementId);
648651
try {
649652
if (!inFlight.future.isCancelled()) {
650653
toReturn.add(inFlight.future.get());
654+
// Only mark as finished if future was not cancelled
655+
finishedElementIds.add(elementId);
656+
itemsFinished++;
651657
}
652-
653-
finishedElementIds.add(elementId);
654-
activeElements.remove(elementId);
655-
itemsFinished++;
656658
} catch (Exception e) {
657659
LOG.error("Error executing async task for element {}", element, e);
658660
throw new RuntimeException("Error executing async task for element " + element, e);

sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/AsyncWrapperTest.java

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import static org.junit.Assert.assertEquals;
2121
import static org.junit.Assert.assertNotEquals;
22+
import static org.junit.Assert.assertThrows;
2223

2324
import java.io.Serializable;
2425
import java.util.ArrayList;
@@ -179,7 +180,7 @@ public BagState<T> readLater() {
179180

180181
// 4. Used for testing Timer mock implementations.
181182
private static class FakeTimer implements Timer {
182-
private Instant time = Instant.EPOCH;
183+
private volatile Instant time = Instant.EPOCH;
183184

184185
@Override
185186
public void set(Instant absoluteTime) {
@@ -874,4 +875,67 @@ public void testResetStateConcurrentTeardown() {
874875
// Verify calling resetState() while background tasks are running finishes cleanly
875876
AsyncWrapper.resetState();
876877
}
878+
879+
// Test 15: testTransientRpcFailureRetry
880+
// Verify DoFn exceptions wipe local active state so bundle retries reschedule work.
881+
@Test
882+
public void testTransientRpcFailureRetry() {
883+
FlakyDoFn dofn = new FlakyDoFn();
884+
AsyncWrapper<String, String, String> asyncWrapper =
885+
new AsyncWrapper<>(
886+
dofn, 1, Duration.standardSeconds(5), null, null, null, null, useThreadPool);
887+
asyncWrapper.setup(null);
888+
889+
FakeBagState<KV<String, String>> fakeBagState = new FakeBagState<>();
890+
FakeTimer fakeTimer = new FakeTimer();
891+
KV<String, String> msg = KV.of("key1", "1");
892+
893+
asyncWrapper.processDirect(msg, GlobalWindow.INSTANCE, Instant.now(), fakeBagState, fakeTimer);
894+
waitForEmpty(asyncWrapper);
895+
896+
// Attempt 1 should throw RuntimeException wrapping ExecutionException
897+
assertThrows(
898+
RuntimeException.class,
899+
() ->
900+
asyncWrapper.commitFinishedItemsDirect(
901+
fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer));
902+
903+
// Verify the failed Future was removed from local active elements
904+
assertEquals(0, asyncWrapper.getItemsInBufferCount());
905+
906+
// Simulate runner bundle retry: commitFinishedItemsDirect runs again with msg still in state.
907+
// Because the dead future was removed, it will reschedule msg and succeed on Attempt 2.
908+
waitForEmpty(asyncWrapper);
909+
List<String> result =
910+
asyncWrapper.commitFinishedItemsDirect(
911+
fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer);
912+
if (result.isEmpty()) {
913+
waitForEmpty(asyncWrapper);
914+
result =
915+
asyncWrapper.commitFinishedItemsDirect(
916+
fakeTimer.getCurrentRelativeTime(), fakeBagState, fakeTimer);
917+
}
918+
919+
checkOutput(result, Collections.singletonList("1"));
920+
assertEquals(0, fakeBagState.items.size());
921+
}
922+
923+
private static class FlakyDoFn extends DoFn<String, String> {
924+
private int attempts = 0;
925+
private final ReentrantLock lock = new ReentrantLock();
926+
927+
@ProcessElement
928+
public void processElement(@Element String element, OutputReceiver<String> receiver) {
929+
lock.lock();
930+
try {
931+
attempts++;
932+
if (attempts == 1) {
933+
throw new RuntimeException("Transient RPC Error");
934+
}
935+
} finally {
936+
lock.unlock();
937+
}
938+
receiver.output(element);
939+
}
940+
}
877941
}

sdks/python/apache_beam/transforms/async_dofn.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,11 @@ def commit_finished_items(
489489
if x_id in processing_elements:
490490
_, future = processing_elements[x_id]
491491
if future.done():
492+
# Pop from local active map before checking the result
493+
processing_elements.pop(x_id)
492494
to_return.append(future.result())
495+
# Only mark as finished if result() succeeded without exception
493496
finished_items.append(x)
494-
processing_elements.pop(x_id)
495497
items_finished += 1
496498
else:
497499
items_not_yet_finished += 1

sdks/python/apache_beam/transforms/async_dofn_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,53 @@ def test_reset_state_concurrent_teardown(self):
522522
else:
523523
self.assertEqual(p.exitcode, 0)
524524

525+
def test_transient_rpc_failure_retry(self):
526+
# Verify DoFn exceptions wipe local active state so retries reschedule work.
527+
class FlakyDoFn(beam.DoFn):
528+
def __init__(self):
529+
self.attempts = 0
530+
self.lock = Lock()
531+
532+
def process(self, element):
533+
with self.lock:
534+
self.attempts += 1
535+
current_attempt = self.attempts
536+
if current_attempt == 1:
537+
raise RuntimeError("Transient RPC Error")
538+
yield element
539+
540+
dofn = FlakyDoFn()
541+
async_dofn = async_lib.AsyncWrapper(dofn, use_asyncio=self.use_asyncio)
542+
async_dofn.setup()
543+
fake_bag_state = FakeBagState([])
544+
fake_timer = FakeTimer(0)
545+
msg = ('key1', 1)
546+
547+
async_dofn.process(msg, to_process=fake_bag_state, timer=fake_timer)
548+
self.wait_for_empty(async_dofn)
549+
550+
# Attempt 1 should raise the RuntimeError stored in the future
551+
with self.assertRaises(RuntimeError):
552+
async_dofn.commit_finished_items(fake_bag_state, fake_timer)
553+
554+
# Verify the failed future was popped from local processing_elements
555+
with async_lib.AsyncWrapper._lock:
556+
self.assertNotIn(
557+
async_dofn._id_fn(msg[1]),
558+
async_lib.AsyncWrapper._processing_elements[async_dofn._uuid],
559+
)
560+
561+
# Simulate runner bundle retry: commit_finished_items runs again with msg still in state.
562+
# Because the dead future was popped, it will reschedule msg and succeed on Attempt 2.
563+
self.wait_for_empty(async_dofn)
564+
result = async_dofn.commit_finished_items(fake_bag_state, fake_timer)
565+
if not result:
566+
self.wait_for_empty(async_dofn)
567+
result = async_dofn.commit_finished_items(fake_bag_state, fake_timer)
568+
569+
self.check_output(result, [msg])
570+
self.assertEqual(fake_bag_state.items, [])
571+
525572

526573
if __name__ == '__main__':
527574
unittest.main()

0 commit comments

Comments
 (0)