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

tvalentyn 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 4a94900f5d4 [Python] Finish async bundles after processing outputs 
(#40000)
4a94900f5d4 is described below

commit 4a94900f5d49e8f142261454061edcbe9cd9614d
Author: Bruno Volpato <[email protected]>
AuthorDate: Wed Oct 14 18:32:36 2026 -0400

    [Python] Finish async bundles after processing outputs (#40000)
    
    * [Python] Finish async bundles after processing outputs
    
    * Document async bundle lifecycle fix
    
    * [Python] Move AsyncWrapper release note into 2.78.0 bugfixes
    
    Move the entry out of the commented changelog template.
---
 CHANGES.md                                         |  1 +
 sdks/python/apache_beam/transforms/async_dofn.py   | 40 ++++-------
 .../apache_beam/transforms/async_dofn_test.py      | 80 +++++++++++++++++++++-
 3 files changed, 95 insertions(+), 26 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 02f3818b1a8..1848d909db9 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -103,6 +103,7 @@
 * (Java) BigQueryIO now treats a 404 when deleting a temporary table or 
dataset as success, so a replayed work item whose earlier attempt already 
deleted it no longer retries forever 
([#24997](https://github.com/apache/beam/issues/24997)).
 * (Java) IcebergIO now writes rows containing `EnumerationType` (proto enum) 
fields as strings, instead of throwing `Unsupported Beam logical type Enum` 
([#40299](https://github.com/apache/beam/issues/40299)).
 * (Python) Fixed stateful DoFns with side inputs sometimes taking the timer 
key coder from a side input instead of the main input, which could make the 
worker fail to decode timer keys with `Unknown type tag` 
([#40374](https://github.com/apache/beam/issues/40374)).
+* (Python) Fixed `AsyncWrapper` finalizing bundles before generator 
processing, which could drop buffered writes, and nesting iterable outputs 
([#40000](https://github.com/apache/beam/pull/40000)).
 * (Python) `Duration` built from float seconds now rounds to the nearest 
microsecond instead of truncating, which could lose a microsecond 
([#40263](https://github.com/apache/beam/issues/40263)).
 * (Python) `ReadFromText` with `escapechar` no longer skips a delimiter that 
directly follows an escaped delimiter, which merged two records into one 
([#40459](https://github.com/apache/beam/issues/40459)).
 * (Python) DataFrame API: `agg` with a list that includes `'count'` or 
`'size'` now labels those rows `count`/`size` instead of `sum` 
([#40504](https://github.com/apache/beam/issues/40504)).
diff --git a/sdks/python/apache_beam/transforms/async_dofn.py 
b/sdks/python/apache_beam/transforms/async_dofn.py
index 8f9ac6dc8ac..6ef87bfa4f5 100644
--- a/sdks/python/apache_beam/transforms/async_dofn.py
+++ b/sdks/python/apache_beam/transforms/async_dofn.py
@@ -213,9 +213,9 @@ class AsyncWrapper(beam.DoFn):
     self._sync_fn.teardown()
 
   def sync_fn_process(self, element, *args, **kwargs):
-    """Makes the call to the wrapped dofn's start_bundle, process
+    """Calls the wrapped dofn's start_bundle, process and finish_bundle 
methods.
 
-    methods.  It will then combine the results into a single generator.
+    Collects process outputs before finishing the bundle.
 
     Args:
       element: The element to process.
@@ -226,31 +226,21 @@ class AsyncWrapper(beam.DoFn):
         called with.
 
     Returns:
-      A generator of elements produced by the input element.
+      A list of elements produced by the input element.
     """
+    def _collect(result):
+      if result is None:
+        return []
+      if isinstance(result, Iterable) and not isinstance(result, (str, bytes)):
+        return list(result)
+      return [result]
+
     self._sync_fn.start_bundle()
-    process_result = self._sync_fn.process(element, *args, **kwargs)
-    bundle_result = self._sync_fn.finish_bundle()
-
-    # both process and finish bundle may or may not return generators. We want
-    # to combine whatever results have been returned into a single generator. 
If
-    # they are single elements then wrap them in lists so that we can combine
-    # them.
-    if not process_result:
-      process_result = []
-    elif not isinstance(process_result, GeneratorType):
-      process_result = [process_result]
-    if not bundle_result:
-      bundle_result = []
-    elif not isinstance(bundle_result, GeneratorType):
-      bundle_result = [bundle_result]
-
-    to_return = []
-    for x in process_result:
-      to_return.append(x)
-    for x in bundle_result:
-      to_return.append(x)
-    return to_return
+    # Exhaust lazy process outputs before finish_bundle flushes or closes any
+    # resources used by the process iterator.
+    process_result = _collect(self._sync_fn.process(element, *args, **kwargs))
+    bundle_result = _collect(self._sync_fn.finish_bundle())
+    return process_result + bundle_result
 
   async def async_fn_process(self, element, *args, **kwargs):
     """Makes the call to the wrapped dofn's start_bundle, process
diff --git a/sdks/python/apache_beam/transforms/async_dofn_test.py 
b/sdks/python/apache_beam/transforms/async_dofn_test.py
index d44375bc6a7..dc4b258e836 100644
--- a/sdks/python/apache_beam/transforms/async_dofn_test.py
+++ b/sdks/python/apache_beam/transforms/async_dofn_test.py
@@ -29,7 +29,7 @@ from parameterized import parameterized_class
 
 import apache_beam as beam
 import apache_beam.transforms.async_dofn as async_lib
-from apache_beam.transforms.window import IntervalWindow
+from apache_beam.transforms.window import GlobalWindows, IntervalWindow
 
 
 class BasicDofn(beam.DoFn):
@@ -752,6 +752,84 @@ class AsyncTest(unittest.TestCase):
     else:
       self.assertEqual(p.exitcode, 0)
 
+  def _schedule_lifecycle_item(self, dofn):
+    wrapper = async_lib.AsyncWrapper(
+        dofn, parallelism=1, use_asyncio=self.use_asyncio)
+    wrapper.setup()
+    state, timer = FakeBagState([]), FakeTimer(0)
+    wrapper.process(('key', 7), to_process=state, timer=timer)
+    self.wait_for_empty(wrapper)
+    return wrapper, state, timer
+
+  def test_finish_bundle_flushes_generator_process(self):
+    class BufferedWriter(beam.DoFn):
+      def __init__(self):
+        self.persisted = []
+
+      def start_bundle(self):
+        self.pending = []
+
+      def process(self, element):
+        self.pending.append(element)
+        yield from ()
+
+      def finish_bundle(self):
+        self.persisted.extend(self.pending)
+        self.pending.clear()
+
+    dofn = BufferedWriter()
+    wrapper, state, timer = self._schedule_lifecycle_item(dofn)
+    self.assertEqual([], wrapper.commit_finished_items(state, timer))
+    self.assertEqual([], state.items)
+    self.assertEqual([('key', 7)], dofn.persisted)
+    self.assertEqual([], dofn.pending)
+
+  def test_process_accepts_non_generator_iterables(self):
+    for iterable_type in (list, tuple, iter):
+      with self.subTest(iterable_type=iterable_type):
+
+        class IterableOutput(beam.DoFn):
+          def process(self, element):
+            return iterable_type([element, element])
+
+        wrapper, state, timer = self._schedule_lifecycle_item(IterableOutput())
+        self.assertEqual([('key', 7), ('key', 7)],
+                         wrapper.commit_finished_items(state, timer))
+        self.assertEqual([], state.items)
+
+  def test_finish_bundle_accepts_list_output(self):
+    output = GlobalWindows.windowed_value(('key', 'finished'))
+
+    class ListFinishOutput(beam.DoFn):
+      def process(self, element):
+        return None
+
+      def finish_bundle(self):
+        return [output]
+
+    wrapper, state, timer = self._schedule_lifecycle_item(ListFinishOutput())
+    self.assertEqual([output], wrapper.commit_finished_items(state, timer))
+    self.assertEqual([], state.items)
+
+  def test_generator_failure_does_not_finish_bundle(self):
+    class FailingProcess(beam.DoFn):
+      def __init__(self):
+        self.finished = False
+
+      def process(self, element):
+        yield element
+        raise RuntimeError('process iteration failed')
+
+      def finish_bundle(self):
+        self.finished = True
+
+    dofn = FailingProcess()
+    wrapper, state, timer = self._schedule_lifecycle_item(dofn)
+    with self.assertRaisesRegex(RuntimeError, 'process iteration failed'):
+      wrapper.commit_finished_items(state, timer)
+    self.assertEqual([('key', 7)], state.items)
+    self.assertFalse(dofn.finished)
+
   def test_transient_rpc_failure_retry(self):
     # Verify DoFn exceptions wipe local active state so retries reschedule 
work.
     class FlakyDoFn(beam.DoFn):

Reply via email to