Skip to content

Commit e132e01

Browse files
committed
Fix stateful DoFn runtime typecheck duplicate specs error
1 parent a2a14f5 commit e132e01

2 files changed

Lines changed: 167 additions & 46 deletions

File tree

sdks/python/apache_beam/typehints/typecheck.py

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -84,25 +84,81 @@ def teardown(self):
8484
return self.dofn.teardown()
8585

8686

87-
class OutputCheckWrapperDoFn(AbstractDoFnWrapper):
88-
"""A DoFn that verifies against common errors in the output type."""
89-
def __init__(self, dofn, full_label):
87+
class RuntimeTypeCheckWrapperDoFn(AbstractDoFnWrapper):
88+
"""A DoFn wrapper performing runtime type-checking of inputs and outputs.
89+
90+
This single wrapper performs the work that was previously split between
91+
two nested wrappers (``TypeCheckWrapperDoFn`` wrapped inside
92+
``OutputCheckWrapperDoFn``): type-checking of inputs and outputs against
93+
declared type hints, verification against common output errors (such as
94+
returning a plain ``str``, ``bytes`` or ``dict`` from a ``ParDo``), and
95+
labeling of raised ``TypeCheckError`` messages with the offending
96+
transform's full label. Merging the wrappers removes one level of Python
97+
call indirection per processed element when ``--runtime_type_check`` is
98+
enabled.
99+
"""
100+
def __init__(self, dofn, type_hints, full_label):
90101
super().__init__(dofn)
91102
self.full_label = full_label
103+
# Note that a *bound* process method must not be cached on the instance:
104+
# an attribute holding a bound method is visible to the stateful DoFn
105+
# reflection in userstate.get_dofn_specs, and a cached copy can diverge
106+
# from self.dofn.process across (de)serialization, in which case stateful
107+
# DoFn validation would see duplicate StateSpecs/TimerSpecs. Caching the
108+
# underlying (unbound) function is safe: plain functions stored on an
109+
# instance are not bound methods, so DoFn reflection ignores them.
110+
process_fn = dofn._process_argspec_fn()
111+
if hasattr(process_fn, '__func__'):
112+
self._process_fn = process_fn.__func__
113+
self._process_fn_needs_self = True
114+
else:
115+
self._process_fn = process_fn
116+
self._process_fn_needs_self = False
117+
if type_hints.input_types:
118+
input_args, input_kwargs = type_hints.input_types
119+
self._input_hints = getcallargs_forhints(
120+
process_fn, *input_args, **input_kwargs)
121+
else:
122+
self._input_hints = None
123+
# TODO(robertwb): Multi-output.
124+
self._output_type_hint = type_hints.simple_output_type(full_label)
92125

93126
def wrapper(self, method, args, kwargs):
94127
try:
95-
result = method(*args, **kwargs)
128+
result = self._type_check_result(method(*args, **kwargs))
96129
except TypeCheckError as e:
97-
# TODO(BEAM-10710): Remove the 'ParDo' prefix for the label name
98-
error_msg = (
99-
'Runtime type violation detected within ParDo(%s): '
100-
'%s' % (self.full_label, e))
101-
_, _, tb = sys.exc_info()
102-
raise TypeCheckError(error_msg).with_traceback(tb)
130+
raise self._add_label(e)
131+
else:
132+
return self._check_type(result)
133+
134+
def process(self, *args, **kwargs):
135+
try:
136+
if self._input_hints:
137+
if self._process_fn_needs_self:
138+
actual_inputs = inspect.getcallargs(
139+
self._process_fn, self.dofn, *args, **kwargs) # pylint: disable=deprecated-method
140+
else:
141+
actual_inputs = inspect.getcallargs(self._process_fn, *args, **kwargs) # pylint: disable=deprecated-method
142+
for var, hint in self._input_hints.items():
143+
if hint is actual_inputs[var]:
144+
# self parameter
145+
continue
146+
_check_instance_type(hint, actual_inputs[var], var, True)
147+
result = self._type_check_result(self.dofn.process(*args, **kwargs))
148+
except TypeCheckError as e:
149+
raise self._add_label(e)
103150
else:
104151
return self._check_type(result)
105152

153+
def _add_label(self, e):
154+
"""Returns a TypeCheckError labeled with this transform's full label."""
155+
# TODO(BEAM-10710): Remove the 'ParDo' prefix for the label name
156+
error_msg = (
157+
'Runtime type violation detected within ParDo(%s): '
158+
'%s' % (self.full_label, e))
159+
_, _, tb = sys.exc_info()
160+
return TypeCheckError(error_msg).with_traceback(tb)
161+
106162
@staticmethod
107163
def _check_type(output):
108164
if output is None:
@@ -120,36 +176,6 @@ def _check_type(output):
120176
'iterable. %s was returned instead.' % type(output))
121177
return output
122178

123-
124-
class TypeCheckWrapperDoFn(AbstractDoFnWrapper):
125-
"""A wrapper around a DoFn which performs type-checking of input and output.
126-
"""
127-
def __init__(self, dofn, type_hints, label=None):
128-
super().__init__(dofn)
129-
self._process_fn = self.dofn._process_argspec_fn()
130-
if type_hints.input_types:
131-
input_args, input_kwargs = type_hints.input_types
132-
self._input_hints = getcallargs_forhints(
133-
self._process_fn, *input_args, **input_kwargs)
134-
else:
135-
self._input_hints = None
136-
# TODO(robertwb): Multi-output.
137-
self._output_type_hint = type_hints.simple_output_type(label)
138-
139-
def wrapper(self, method, args, kwargs):
140-
result = method(*args, **kwargs)
141-
return self._type_check_result(result)
142-
143-
def process(self, *args, **kwargs):
144-
if self._input_hints:
145-
actual_inputs = inspect.getcallargs(self._process_fn, *args, **kwargs) # pylint: disable=deprecated-method
146-
for var, hint in self._input_hints.items():
147-
if hint is actual_inputs[var]:
148-
# self parameter
149-
continue
150-
_check_instance_type(hint, actual_inputs[var], var, True)
151-
return self._type_check_result(self.dofn.process(*args, **kwargs))
152-
153179
def _type_check_result(self, transform_results):
154180
if self._output_type_hint is None or transform_results is None:
155181
return transform_results
@@ -289,11 +315,9 @@ def visit_transform(self, applied_transform):
289315
if isinstance(transform.fn, core.CombineValuesDoFn):
290316
transform.fn.combinefn = self._wrapped_fn
291317
else:
292-
transform.fn = transform.dofn = OutputCheckWrapperDoFn(
293-
TypeCheckWrapperDoFn(
294-
transform.fn,
295-
transform.get_type_hints(),
296-
applied_transform.full_label),
318+
transform.fn = transform.dofn = RuntimeTypeCheckWrapperDoFn(
319+
transform.fn,
320+
transform.get_type_hints(),
297321
applied_transform.full_label)
298322

299323

sdks/python/apache_beam/typehints/typecheck_test.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@
3434
from apache_beam import Pipeline
3535
from apache_beam.options.pipeline_options import PipelineOptions
3636
from apache_beam.options.pipeline_options import TypeOptions
37+
from apache_beam.pipeline import PipelineVisitor
3738
from apache_beam.testing.test_pipeline import TestPipeline
3839
from apache_beam.testing.util import assert_that
3940
from apache_beam.testing.util import equal_to
41+
from apache_beam.transforms import combiners
42+
from apache_beam.transforms import userstate
4043
from apache_beam.typehints import decorators
44+
from apache_beam.typehints import typecheck
4145
from apache_beam.typehints import with_input_types
4246
from apache_beam.typehints import with_output_types
4347

@@ -105,7 +109,7 @@ def test_wrapper_pass_through(self):
105109
# not the same one that actually runs in the pipeline (it is serialized
106110
# here and deserialized in the worker).
107111
with tempfile.TemporaryDirectory() as tmp_dirname:
108-
path = os.path.join(tmp_dirname + "tmp_filename")
112+
path = os.path.join(tmp_dirname, "tmp_filename")
109113
dofn = MyDoFn(path)
110114
result = self.p | beam.Create([1, 2, 3]) | beam.ParDo(dofn)
111115
assert_that(result, equal_to([1, 2, 3]))
@@ -300,5 +304,98 @@ def process(self, element, *args, **kwargs):
300304
self.p.run().wait_until_finish()
301305

302306

307+
@with_input_types(int)
308+
@with_output_types(int)
309+
class _AddOneDoFn(beam.DoFn):
310+
def process(self, element):
311+
yield element + 1
312+
313+
314+
class RuntimeTypeCheckWrapperDoFnTest(unittest.TestCase):
315+
"""Tests for the merged runtime type-checking wrapper (BEAM-9489)."""
316+
def test_visitor_applies_single_wrapper_layer(self):
317+
# A single RuntimeTypeCheckWrapperDoFn should wrap the user's DoFn
318+
# directly, with no intermediate wrapper layers.
319+
p = beam.Pipeline(options=PipelineOptions(runtime_type_check=True))
320+
_ = (p | beam.Create([1, 2]) | 'TypedStep' >> beam.ParDo(_AddOneDoFn()))
321+
p.visit(typecheck.TypeCheckVisitor())
322+
323+
wrapped_dofns = []
324+
325+
class _Collector(PipelineVisitor):
326+
def visit_transform(self, applied_transform):
327+
transform = applied_transform.transform
328+
if isinstance(transform, beam.ParDo) and isinstance(
329+
getattr(transform, 'fn', None), typecheck.AbstractDoFnWrapper):
330+
wrapped_dofns.append(transform.fn)
331+
332+
p.visit(_Collector())
333+
self.assertTrue(wrapped_dofns)
334+
for wrapper in wrapped_dofns:
335+
self.assertIsInstance(wrapper, typecheck.RuntimeTypeCheckWrapperDoFn)
336+
# The wrapped DoFn must be the user's DoFn, not another wrapper.
337+
self.assertNotIsInstance(wrapper.dofn, typecheck.AbstractDoFnWrapper)
338+
339+
def test_wrapper_labels_type_check_errors(self):
340+
dofn = typecheck.RuntimeTypeCheckWrapperDoFn(
341+
_AddOneDoFn(), _AddOneDoFn().get_type_hints(), 'MyStep')
342+
with self.assertRaisesRegex(
343+
typecheck.TypeCheckError,
344+
r'Runtime type violation detected within ParDo\(MyStep\)'):
345+
dofn.process('not-an-int')
346+
347+
def test_wrapper_preserves_results(self):
348+
dofn = typecheck.RuntimeTypeCheckWrapperDoFn(
349+
_AddOneDoFn(), _AddOneDoFn().get_type_hints(), 'MyStep')
350+
self.assertEqual(list(dofn.process(1)), [2])
351+
352+
353+
def _make_stateful_dofn():
354+
# Defined inside a function so that the class is serialized by value
355+
# (like the DoFn created by GroupIntoBatches), which is the case where a
356+
# cached bound method diverges from the reconstructed class's process.
357+
count_state = userstate.CombiningValueStateSpec(
358+
'count', combiners.CountCombineFn())
359+
360+
class _CountingStatefulDoFn(beam.DoFn):
361+
def process(self, element, count=beam.DoFn.StateParam(count_state)):
362+
count.add(1)
363+
yield element[1]
364+
365+
return _CountingStatefulDoFn()
366+
367+
368+
class RuntimeTypeCheckStatefulDoFnTest(unittest.TestCase):
369+
"""Regression tests for runtime_type_check with stateful DoFns.
370+
371+
The type-check wrapper must not expose duplicate StateSpecs/TimerSpecs to
372+
stateful DoFn validation, including after the runner serializes and
373+
reconstructs the wrapped DoFn.
374+
"""
375+
def test_wrapper_does_not_cache_bound_process(self):
376+
dofn = _make_stateful_dofn()
377+
wrapper = typecheck.RuntimeTypeCheckWrapperDoFn(
378+
dofn, dofn.get_type_hints(), 'Step')
379+
# A bound method cached in the instance __dict__ is visible to
380+
# userstate.get_dofn_specs and can diverge from self.dofn.process
381+
# across (de)serialization.
382+
for attr, value in wrapper.__dict__.items():
383+
self.assertFalse(
384+
hasattr(value, '__self__'),
385+
'wrapper caches bound method %r, which breaks stateful DoFn '
386+
'validation' % attr)
387+
userstate.validate_stateful_dofn(wrapper)
388+
389+
def test_stateful_dofn_with_runtime_type_check(self):
390+
options = PipelineOptions()
391+
options.view_as(TypeOptions).runtime_type_check = True
392+
with TestPipeline(options=options) as p:
393+
result = (
394+
p
395+
| beam.Create([('k', 1), ('k', 2), ('k', 3)])
396+
| beam.ParDo(_make_stateful_dofn()))
397+
assert_that(result, equal_to([1, 2, 3]))
398+
399+
303400
if __name__ == '__main__':
304401
unittest.main()

0 commit comments

Comments
 (0)