Skip to content

Commit 4289ea2

Browse files
authored
Allow stateful exception handling (#35965)
* [WIP] Allow stateful exception handling * Fix state * A bit more conservative * Linting * lint
1 parent f725e85 commit 4289ea2

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

sdks/python/apache_beam/transforms/core.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import types
3131
import typing
3232
from collections import defaultdict
33+
from functools import wraps
3334
from itertools import dropwhile
3435

3536
from apache_beam import coders
@@ -1596,7 +1597,8 @@ def with_exception_handling(
15961597
timeout=None,
15971598
error_handler=None,
15981599
on_failure_callback: typing.Optional[typing.Callable[
1599-
[Exception, typing.Any], None]] = None):
1600+
[Exception, typing.Any], None]] = None,
1601+
allow_unsafe_userstate_in_process=False):
16001602
"""Automatically provides a dead letter output for saving bad inputs.
16011603
This can allow a pipeline to continue successfully rather than fail or
16021604
continuously throw errors on retry when bad elements are encountered.
@@ -1653,6 +1655,13 @@ def with_exception_handling(
16531655
the exception will be of type `TimeoutError`. Be careful with this
16541656
callback - if you set a timeout, it will not apply to the callback,
16551657
and if the callback fails it will not be retried.
1658+
allow_unsafe_userstate_in_process: If False, user state will not be
1659+
permitted in the DoFn's process method. This is disabled by default
1660+
because user state is potentially unsafe with exception handling
1661+
since it can be successfully stored or cleared even if the associated
1662+
element fails and is routed to a dead letter queue. Semantics around
1663+
state in this kind of failure scenario are not well defined and are
1664+
subject to change.
16561665
"""
16571666
args, kwargs = self.raw_side_inputs
16581667
return self.label >> _ExceptionHandlingWrapper(
@@ -1668,7 +1677,8 @@ def with_exception_handling(
16681677
threshold_windowing,
16691678
timeout,
16701679
error_handler,
1671-
on_failure_callback)
1680+
on_failure_callback,
1681+
allow_unsafe_userstate_in_process)
16721682

16731683
def with_error_handler(self, error_handler, **exception_handling_kwargs):
16741684
"""An alias for `with_exception_handling(error_handler=error_handler, ...)`
@@ -2273,7 +2283,8 @@ def __init__(
22732283
threshold_windowing,
22742284
timeout,
22752285
error_handler,
2276-
on_failure_callback):
2286+
on_failure_callback,
2287+
allow_unsafe_userstate_in_process):
22772288
if partial and use_subprocess:
22782289
raise ValueError('partial and use_subprocess are mutually incompatible.')
22792290
self._fn = fn
@@ -2289,8 +2300,17 @@ def __init__(
22892300
self._timeout = timeout
22902301
self._error_handler = error_handler
22912302
self._on_failure_callback = on_failure_callback
2303+
self._allow_unsafe_userstate_in_process = allow_unsafe_userstate_in_process
22922304

22932305
def expand(self, pcoll):
2306+
if self._allow_unsafe_userstate_in_process:
2307+
if self._use_subprocess or self._timeout:
2308+
# TODO(https://github.com/apache/beam/issues/35976): Implement this
2309+
raise Exception(
2310+
'allow_unsafe_userstate_in_process is incompatible with ' +
2311+
'exception handling done with subprocesses or timeouts. If you ' +
2312+
'need this feature, comment in ' +
2313+
'https://github.com/apache/beam/issues/35976')
22942314
if self._use_subprocess:
22952315
wrapped_fn = _SubprocessDoFn(self._fn, timeout=self._timeout)
22962316
elif self._timeout:
@@ -2303,7 +2323,8 @@ def expand(self, pcoll):
23032323
self._dead_letter_tag,
23042324
self._exc_class,
23052325
self._partial,
2306-
self._on_failure_callback),
2326+
self._on_failure_callback,
2327+
self._allow_unsafe_userstate_in_process),
23072328
*self._args,
23082329
**self._kwargs).with_outputs(
23092330
self._dead_letter_tag, main=self._main_tag, allow_unknown_tags=True)
@@ -2347,21 +2368,52 @@ def check_threshold(bad, total, threshold, window=DoFn.WindowParam):
23472368

23482369
class _ExceptionHandlingWrapperDoFn(DoFn):
23492370
def __init__(
2350-
self, fn, dead_letter_tag, exc_class, partial, on_failure_callback):
2371+
self,
2372+
fn,
2373+
dead_letter_tag,
2374+
exc_class,
2375+
partial,
2376+
on_failure_callback,
2377+
allow_unsafe_userstate_in_process):
23512378
self._fn = fn
23522379
self._dead_letter_tag = dead_letter_tag
23532380
self._exc_class = exc_class
23542381
self._partial = partial
23552382
self._on_failure_callback = on_failure_callback
23562383

2384+
# Wrap process and expose any top level state params so that process can
2385+
# handle state and timers.
2386+
if allow_unsafe_userstate_in_process:
2387+
2388+
@wraps(self._fn.process)
2389+
def process_wrapper(self, *args, **kwargs):
2390+
return self.exception_handling_wrapper_do_fn_custom_process(
2391+
*args, **kwargs)
2392+
2393+
self.process = types.MethodType(process_wrapper, self)
2394+
else:
2395+
self.process = self.exception_handling_wrapper_do_fn_custom_process
2396+
process_sig = inspect.signature(self._fn.process)
2397+
for name, param in process_sig.parameters.items():
2398+
if isinstance(param.default, (DoFn.StateParam, DoFn.TimerParam)):
2399+
logging.warning(
2400+
'State or timer parameter {} detected in process method of ' +
2401+
'{}. State and timers are unsupported when using ' +
2402+
'with_exception_handling and may lead to errors. To enable ' +
2403+
'state and timers with limited consistency guarantees, pass ' +
2404+
'in the allow_unsafe_userstate_in_process parameters to the ' +
2405+
'with_exception_handling method.',
2406+
name,
2407+
self.fn)
2408+
23572409
def __getattribute__(self, name):
23582410
if (name.startswith('__') or name in self.__dict__ or
23592411
name in _ExceptionHandlingWrapperDoFn.__dict__):
23602412
return object.__getattribute__(self, name)
23612413
else:
23622414
return getattr(self._fn, name)
23632415

2364-
def process(self, *args, **kwargs):
2416+
def exception_handling_wrapper_do_fn_custom_process(self, *args, **kwargs):
23652417
try:
23662418
result = self._fn.process(*args, **kwargs)
23672419
if not self._partial:

sdks/python/apache_beam/transforms/core_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@
2727
import pytest
2828

2929
import apache_beam as beam
30+
from apache_beam.coders import coders
3031
from apache_beam.testing.util import assert_that
3132
from apache_beam.testing.util import equal_to
33+
from apache_beam.transforms.userstate import BagStateSpec
34+
from apache_beam.transforms.userstate import ReadModifyWriteStateSpec
35+
from apache_beam.transforms.userstate import TimerSpec
36+
from apache_beam.transforms.userstate import on_timer
3237
from apache_beam.transforms.window import FixedWindows
3338
from apache_beam.typehints import TypeCheckError
3439
from apache_beam.typehints import row_type
@@ -120,6 +125,42 @@ def process(self, element):
120125
return
121126

122127

128+
class TestDoFnStateful(beam.DoFn):
129+
STATE_SPEC = ReadModifyWriteStateSpec('num_elements', coders.VarIntCoder())
130+
"""test process with a stateful dofn"""
131+
def process(self, element, state=beam.DoFn.StateParam(STATE_SPEC)):
132+
if len(element[1]) > 3:
133+
raise ValueError('Not allowed to have long elements')
134+
current_value = state.read() or 1
135+
state.write(current_value + 1)
136+
yield current_value
137+
138+
139+
class TestDoFnWithTimer(beam.DoFn):
140+
ALL_ELEMENTS = BagStateSpec('buffer', coders.VarIntCoder())
141+
TIMER = TimerSpec('timer', beam.TimeDomain.WATERMARK)
142+
"""test process with a stateful dofn"""
143+
def process(
144+
self,
145+
element,
146+
t=beam.DoFn.TimestampParam,
147+
state=beam.DoFn.StateParam(ALL_ELEMENTS),
148+
timer=beam.DoFn.TimerParam(TIMER)):
149+
if element[1] > 3:
150+
raise ValueError('Not allowed to have large numbers')
151+
state.add(element[1])
152+
timer.set(t)
153+
154+
return []
155+
156+
@on_timer(TIMER)
157+
def expiry_callback(self, state=beam.DoFn.StateParam(ALL_ELEMENTS)):
158+
unique_elements = list(state.read())
159+
state.clear()
160+
161+
return unique_elements
162+
163+
123164
class CreateTest(unittest.TestCase):
124165
@pytest.fixture(autouse=True)
125166
def inject_fixtures(self, caplog):
@@ -296,6 +337,30 @@ def failure_callback(e, el):
296337
assert_that(bad_elements, equal_to([]), 'bad')
297338
self.assertFalse(os.path.isfile(tmp_path))
298339

340+
def test_stateful_exception_handling(self):
341+
with beam.Pipeline() as pipeline:
342+
good, bad = (
343+
pipeline | beam.Create([(1, 'abc'), (1, 'long_word'),
344+
(1, 'foo'), (1, 'bar'), (1, 'foobar')])
345+
| beam.ParDo(TestDoFnStateful()).with_exception_handling(
346+
allow_unsafe_userstate_in_process=True)
347+
)
348+
bad_elements = bad | beam.Keys()
349+
assert_that(good, equal_to([1, 2, 3]), 'good')
350+
assert_that(
351+
bad_elements, equal_to([(1, 'long_word'), (1, 'foobar')]), 'bad')
352+
353+
def test_timer_exception_handling(self):
354+
with beam.Pipeline() as pipeline:
355+
good, bad = (
356+
pipeline | beam.Create([(1, 0), (1, 1), (1, 2), (1, 5), (1, 10)])
357+
| beam.ParDo(TestDoFnWithTimer()).with_exception_handling(
358+
allow_unsafe_userstate_in_process=True)
359+
)
360+
bad_elements = bad | beam.Keys()
361+
assert_that(good, equal_to([0, 1, 2]), 'good')
362+
assert_that(bad_elements, equal_to([(1, 5), (1, 10)]), 'bad')
363+
299364

300365
def test_callablewrapper_typehint():
301366
T = TypeVar("T")

0 commit comments

Comments
 (0)