3030import types
3131import typing
3232from collections import defaultdict
33+ from functools import wraps
3334from itertools import dropwhile
3435
3536from 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
23482369class _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 :
0 commit comments