Skip to content

Commit 3cb42b1

Browse files
committed
Fix stateful DoFn runtime typecheck duplicate specs error
1 parent 78fc51d commit 3cb42b1

2 files changed

Lines changed: 57 additions & 3 deletions

File tree

sdks/python/apache_beam/typehints/typecheck.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,15 @@ class RuntimeTypeCheckWrapperDoFn(AbstractDoFnWrapper):
100100
def __init__(self, dofn, type_hints, full_label):
101101
super().__init__(dofn)
102102
self.full_label = full_label
103-
self._process_fn = self.dofn._process_argspec_fn()
103+
# Note that the 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.
104108
if type_hints.input_types:
105109
input_args, input_kwargs = type_hints.input_types
106110
self._input_hints = getcallargs_forhints(
107-
self._process_fn, *input_args, **input_kwargs)
111+
dofn._process_argspec_fn(), *input_args, **input_kwargs)
108112
else:
109113
self._input_hints = None
110114
# TODO(robertwb): Multi-output.
@@ -121,7 +125,8 @@ def wrapper(self, method, args, kwargs):
121125
def process(self, *args, **kwargs):
122126
try:
123127
if self._input_hints:
124-
actual_inputs = inspect.getcallargs(self._process_fn, *args, **kwargs) # pylint: disable=deprecated-method
128+
actual_inputs = inspect.getcallargs(
129+
self.dofn._process_argspec_fn(), *args, **kwargs) # pylint: disable=deprecated-method
125130
for var, hint in self._input_hints.items():
126131
if hint is actual_inputs[var]:
127132
# self parameter

sdks/python/apache_beam/typehints/typecheck_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from apache_beam.testing.test_pipeline import TestPipeline
3939
from apache_beam.testing.util import assert_that
4040
from apache_beam.testing.util import equal_to
41+
from apache_beam.transforms import combiners
42+
from apache_beam.transforms import userstate
4143
from apache_beam.typehints import decorators
4244
from apache_beam.typehints import typecheck
4345
from apache_beam.typehints import with_input_types
@@ -348,5 +350,52 @@ def test_wrapper_preserves_results(self):
348350
self.assertEqual(list(dofn.process(1)), [2])
349351

350352

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+
351400
if __name__ == '__main__':
352401
unittest.main()

0 commit comments

Comments
 (0)