This is an automated email from the ASF dual-hosted git repository.
shunping 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 3bef0c0cb08 [Python] Fail bundles on state write errors (#39992)
3bef0c0cb08 is described below
commit 3bef0c0cb0835fc7eaf43c27e5f4627cfbca1dd3
Author: Bruno Volpato <[email protected]>
AuthorDate: Mon Oct 19 14:47:20 2026 -0400
[Python] Fail bundles on state write errors (#39992)
* [Python] Fail bundles on state write errors
* [Python] Link state fix in changelog
---
CHANGES.md | 1 +
.../apache_beam/runners/worker/bundle_processor.py | 35 ++++---
.../runners/worker/bundle_processor_test.py | 111 +++++++++++++++++++++
3 files changed, 133 insertions(+), 14 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 1ec1dbe8086..c49c27c929c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -100,6 +100,7 @@
* (Python) DaskRunner now preserves outputs emitted by `DoFn.finish_bundle()`
instead of silently dropping them
([#40519](https://github.com/apache/beam/issues/40519)).
* (Python) BigQuery streaming inserts now reset the retry budget and backoff
for each batch instead of sharing them across a bundle
([#39995](https://github.com/apache/beam/pull/39995)).
+* (Python) State write failures reported by portable runners now fail the
bundle instead of silently losing state updates
([#39992](https://github.com/apache/beam/pull/39992)).
* (Go) Fixed a data race on the Prism runner's artifact cache map in
JobServices ([#32656](https://github.com/apache/beam/issues/32656)).
* (Go) Fixed the harness leaking Data/State gRPC streams after the worker
stops, and a deadlock when Send returns EOF
([#40260](https://github.com/apache/beam/issues/40260)).
* (Go) Wrap last dataWriter/timerWriter send errors on Close
([#40405](https://github.com/apache/beam/issues/40405)).
diff --git a/sdks/python/apache_beam/runners/worker/bundle_processor.py
b/sdks/python/apache_beam/runners/worker/bundle_processor.py
index e62e522c00c..c9f80ce063f 100644
--- a/sdks/python/apache_beam/runners/worker/bundle_processor.py
+++ b/sdks/python/apache_beam/runners/worker/bundle_processor.py
@@ -620,6 +620,17 @@ class _ConcatIterable(object):
coder_impl.FastPrimitivesCoderImpl.register_iterable_like_type(_ConcatIterable)
+def _await_state_commit(futures) -> None:
+ # Resolve every write before reporting errors, including clears followed by
+ # appends. State futures return error responses rather than raising them.
+ responses = [future.get() for future in futures if future]
+ errors = [
+ response.error for response in responses if response and response.error
+ ]
+ if errors:
+ raise RuntimeError('\n'.join(errors))
+
+
class SynchronousBagRuntimeState(userstate.BagRuntimeState):
def __init__(
self,
@@ -647,15 +658,16 @@ class
SynchronousBagRuntimeState(userstate.BagRuntimeState):
self._added_elements = []
def commit(self) -> None:
- to_await = None
+ futures = []
if self._cleared:
- to_await = self._state_handler.clear(self._state_key)
+ futures.append(self._state_handler.clear(self._state_key))
if self._added_elements:
- to_await = self._state_handler.extend(
- self._state_key, self._value_coder.get_impl(), self._added_elements)
- if to_await:
- # To commit, we need to wait on the last state request future to
complete.
- to_await.get()
+ futures.append(
+ self._state_handler.extend(
+ self._state_key,
+ self._value_coder.get_impl(),
+ self._added_elements))
+ _await_state_commit(futures)
class SynchronousSetRuntimeState(userstate.SetRuntimeState):
@@ -727,9 +739,7 @@ class SynchronousSetRuntimeState(userstate.SetRuntimeState):
all_futures = self._futures
self._futures = []
- for f in all_futures:
- if f:
- f.get()
+ _await_state_commit(all_futures)
class RangeSet:
@@ -898,10 +908,7 @@ class
SynchronousOrderedListRuntimeState(userstate.OrderedListRuntimeState):
self._state_key, self._elem_coder.get_impl(), items_to_add))
self._pending_adds = SortedDict()
- if len(futures):
- # To commit, we need to wait on every state request futures to complete.
- for to_await in futures:
- to_await.get()
+ _await_state_commit(futures)
self._cleared = False
diff --git a/sdks/python/apache_beam/runners/worker/bundle_processor_test.py
b/sdks/python/apache_beam/runners/worker/bundle_processor_test.py
index ad2089c9938..ffe76c1eb81 100644
--- a/sdks/python/apache_beam/runners/worker/bundle_processor_test.py
+++ b/sdks/python/apache_beam/runners/worker/bundle_processor_test.py
@@ -31,6 +31,7 @@ from apache_beam.runners import common
from apache_beam.runners.portability.fn_api_runner.worker_handlers import
StateServicer
from apache_beam.runners.worker import bundle_processor
from apache_beam.runners.worker import operations
+from apache_beam.runners.worker import sdk_worker
from apache_beam.runners.worker.bundle_processor import BeamTransformFactory
from apache_beam.runners.worker.bundle_processor import BundleProcessor
from apache_beam.runners.worker.bundle_processor import DataInputOperation
@@ -430,6 +431,116 @@ class EnvironmentCompatibilityTest(unittest.TestCase):
"beam:version:sdk_base:apache/beam_python3.5_sdk:2.1.0-custom"))
+class RuntimeStateCommitTest(unittest.TestCase):
+ STATE_TYPES = (
+ bundle_processor.SynchronousBagRuntimeState,
+ bundle_processor.SynchronousSetRuntimeState,
+ SynchronousOrderedListRuntimeState)
+
+ def setUp(self):
+ # Keep set compaction deterministic; it is exercised separately below.
+ patcher = mock.patch.object(random, 'random', return_value=0)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def _create_state(self, state_type, append_errors=('', ), clear_error=''):
+ futures = []
+
+ def response_future(error):
+ future = sdk_worker._Future().set(
+ beam_fn_api_pb2.StateResponse(error=error))
+ future.get = mock.Mock(wraps=future.get)
+ futures.append(future)
+ return future
+
+ append_errors = iter(append_errors)
+ underlying = mock.Mock(spec=sdk_worker.StateHandler)
+ underlying.append_raw.side_effect = (
+ lambda *args: response_future(next(append_errors)))
+ underlying.clear.side_effect = lambda *args: response_future(clear_error)
+ underlying.get_raw.return_value = (b'', None)
+ handler = GlobalCachingStateHandler(StateCache(0), underlying)
+ if state_type is SynchronousOrderedListRuntimeState:
+ key = beam_fn_api_pb2.StateKey(
+
ordered_list_user_state=beam_fn_api_pb2.StateKey.OrderedListUserState(
+ ))
+ else:
+ key = beam_fn_api_pb2.StateKey(
+ bag_user_state=beam_fn_api_pb2.StateKey.BagUserState())
+ return state_type(handler, key, StrUtf8Coder()), futures
+
+ def _add(self, state, value):
+ if isinstance(state, SynchronousOrderedListRuntimeState):
+ state.add((timestamp.Timestamp(1), value))
+ else:
+ state.add(value)
+
+ def test_commit_rejects_failed_append(self):
+ for state_type in self.STATE_TYPES:
+ with self.subTest(state_type=state_type):
+ state, _ = self._create_state(
+ state_type, append_errors=('append failed', ))
+ self._add(state, 'value')
+ with self.assertRaisesRegex(RuntimeError, 'append failed'):
+ state.commit()
+
+ def test_commit_rejects_failed_clear(self):
+ for state_type in self.STATE_TYPES:
+ with self.subTest(state_type=state_type):
+ state, _ = self._create_state(state_type, clear_error='clear failed')
+ state.clear()
+ with self.assertRaisesRegex(RuntimeError, 'clear failed'):
+ state.commit()
+
+ def test_clear_then_append_checks_every_response(self):
+ for state_type in self.STATE_TYPES:
+ for clear_error, append_error in (
+ ('', ''), ('clear failed', ''), ('clear failed', 'append failed')):
+ with self.subTest(state_type=state_type,
+ clear_error=clear_error,
+ append_error=append_error):
+ state, futures = self._create_state(
+ state_type, (append_error, ), clear_error)
+ state.clear()
+ self._add(state, 'replacement')
+ if clear_error:
+ with self.assertRaises(RuntimeError) as raised:
+ state.commit()
+ self.assertEqual(
+ str(raised.exception),
+ '\n'.join(
+ error for error in (clear_error, append_error) if error))
+ else:
+ state.commit()
+ self.assertEqual(len(futures), 2)
+ for future in futures:
+ future.get.assert_called_once()
+
+ def test_commit_rejects_failure_in_earlier_append_chunk(self):
+ state, futures = self._create_state(
+ bundle_processor.SynchronousBagRuntimeState,
+ append_errors=('first append failed', ''))
+ state.add('first')
+ state.add('second')
+ with mock.patch.object(sdk_worker.data_plane,
+ '_DEFAULT_SIZE_FLUSH_THRESHOLD',
+ 1):
+ with self.assertRaisesRegex(RuntimeError, 'first append failed'):
+ state.commit()
+ self.assertEqual(len(futures), 2)
+ for future in futures:
+ future.get.assert_called_once()
+
+ def test_commit_rejects_failed_set_compaction(self):
+ state, futures = self._create_state(
+ bundle_processor.SynchronousSetRuntimeState, clear_error='clear
failed')
+ with mock.patch.object(random, 'random', return_value=1):
+ state.add('value')
+ with self.assertRaisesRegex(RuntimeError, 'clear failed'):
+ state.commit()
+ self.assertEqual(len(futures), 2)
+
+
class OrderedListStateTest(unittest.TestCase):
class NoStateCache(StateCache):
def __init__(self):