Skip to content

Commit abe3fc7

Browse files
Fix silent per-char iteration when DoFn returns str/bytes/dict (#38429)
* Fix silent per-char iteration when DoFn returns str/bytes/dict * Use %r in error message for correct repr of dict/bytes * Address review: 'is not allowed' wording, move entry to Breaking Changes * Address review: check only first output, skip generator DoFns --------- Co-authored-by: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com>
1 parent aaad9b4 commit abe3fc7

4 files changed

Lines changed: 96 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
## Breaking Changes
7676

7777
* (Python) Removed `google-perftools` from the SDK container images. Users who wish to use `--profiler_agent=tcmalloc` should install google-perftools APT package in their custom container images separately ([#39323](https://github.com/apache/beam/issues/39323)).
78+
* `DoFn.process` returning a `str`, `bytes`, or `dict` (instead of an iterable wrapping one) now raises a `TypeError` rather than silently iterating per-character/byte/key (Python) ([#18712](https://github.com/apache/beam/issues/18712)).
7879
* (Java) Added `DRAINING` and `DRAINED` states to `PipelineResult`, including runner state mappings and Dataflow update handling ([#39020](https://github.com/apache/beam/issues/39020)).
7980

8081
## Deprecations
@@ -129,6 +130,7 @@
129130
* (Python) Typehints of dataclass fields are honored during type inferences. To restore the behavior of fallback-to-any,
130131
use pipeline option `--exclude_infer_dataclass_field_type` ([#38797](https://github.com/apache/beam/issues/38797)).
131132
However fixing forward is recommended.
133+
* X behavior was changed ([#X](https://github.com/apache/beam/issues/X)).
132134

133135
## Bugfixes
134136

sdks/python/apache_beam/runners/common.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ cdef class _OutputHandler(OutputHandler):
150150
cdef object output_batch_converter
151151
cdef bint _process_batch_yields_elements
152152
cdef bint _process_yields_batches
153+
cdef bint _check_user_dofn_output
153154

154155
@cython.locals(windowed_value=WindowedValue,
155156
windowed_batch=WindowedBatch,

sdks/python/apache_beam/runners/common.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# pytype: skip-file
2424

2525
# ruff: noqa: UP006
26+
import inspect
2627
import logging
2728
import sys
2829
import threading
@@ -1461,6 +1462,17 @@ def __init__(
14611462
else:
14621463
per_element_output_counter = None
14631464

1465+
# Only validate the output of user class-based DoFns, and only when
1466+
# process() uses `return` (not `yield`). A generator process() returns a
1467+
# generator object, which can never be a str/bytes/dict, so the #18712 bug
1468+
# is impossible there and the check would be pure overhead. Callable-wrapped
1469+
# DoFns (Map/FlatMap) are also excluded, since flattening a returned
1470+
# str/bytes/dict is a legitimate use case for them.
1471+
check_user_dofn_output = (
1472+
not isinstance(fn, core.CallableWrapperDoFn) and
1473+
not inspect.isgeneratorfunction(
1474+
do_fn_signature.process_method.method_value))
1475+
14641476
output_handler = _OutputHandler(
14651477
windowing.windowfn,
14661478
main_receivers,
@@ -1475,6 +1487,7 @@ def __init__(
14751487
do_fn_signature.process_batch_method.method_value,
14761488
'_beam_yields_elements',
14771489
False),
1490+
check_user_dofn_output=check_user_dofn_output,
14781491
)
14791492

14801493
if do_fn_signature.is_stateful_dofn() and not user_state_context:
@@ -1633,6 +1646,7 @@ def __init__(
16331646
output_batch_converter, # type: Optional[BatchConverter]
16341647
process_yields_batches, # type: bool
16351648
process_batch_yields_elements, # type: bool
1649+
check_user_dofn_output=False, # type: bool
16361650
):
16371651
"""Initializes ``_OutputHandler``.
16381652
@@ -1642,6 +1656,12 @@ def __init__(
16421656
tagged_receivers: main receiver object.
16431657
per_element_output_counter: per_element_output_counter of one work_item.
16441658
could be none if experimental flag turn off
1659+
check_user_dofn_output: if True, validate that a user-class DoFn does not
1660+
return a str/bytes/dict (a common bug — see
1661+
https://github.com/apache/beam/issues/18712).
1662+
Skipped for callable-wrapped DoFns (Map/FlatMap)
1663+
where iterating a returned str/bytes/dict is a
1664+
legitimate flatten use case.
16451665
"""
16461666
self.window_fn = window_fn
16471667
self.main_receivers = main_receivers
@@ -1654,6 +1674,7 @@ def __init__(
16541674
self.output_batch_converter = output_batch_converter
16551675
self._process_yields_batches = process_yields_batches
16561676
self._process_batch_yields_elements = process_batch_yields_elements
1677+
self._check_user_dofn_output = check_user_dofn_output
16571678

16581679
def handle_process_outputs(
16591680
self, windowed_input_element, results, watermark_estimator=None):
@@ -1664,6 +1685,20 @@ def handle_process_outputs(
16641685
A value wrapped in a TaggedOutput object will be unwrapped and
16651686
then dispatched to the appropriate indexed output.
16661687
"""
1688+
if self._check_user_dofn_output:
1689+
# This bug is deterministic per DoFn: if process() returns a
1690+
# str/bytes/dict once, it does so for every element. So we only need to
1691+
# validate the first output and can then disable the check to avoid
1692+
# per-element overhead (see
1693+
# https://github.com/apache/beam/issues/18712).
1694+
self._check_user_dofn_output = False
1695+
if isinstance(results, (str, bytes, dict)):
1696+
object_type = type(results).__name__
1697+
raise TypeError(
1698+
'Returning a %s from a ParDo or FlatMap is not allowed. '
1699+
'Please use list(%r) if you really want this behavior.' %
1700+
(object_type, results))
1701+
16671702
if results is None:
16681703
results = []
16691704

sdks/python/apache_beam/runners/common_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,64 @@ def process(self, element, mykey=DoFn.KeyParam):
154154
test_stream = (TestStream().advance_watermark_to(10).add_elements([1, 2]))
155155
(p | test_stream | beam.ParDo(DoFnProcessWithKeyparam()))
156156

157+
def test_dofn_returning_str_raises_clear_error(self):
158+
"""Regression test for https://github.com/apache/beam/issues/18712.
159+
160+
A DoFn returning a str instead of an iterable wrapping one used to
161+
silently iterate per-character. It should now raise a clear TypeError.
162+
"""
163+
class BadDoFn(DoFn):
164+
def process(self, element):
165+
return 'hello'
166+
167+
# Use base Exception (matching existing convention in
168+
# typecheck_test.py::test_do_fn_returning_non_iterable_throws_error)
169+
# because the runner's _reraise_augmented wraps the TypeError before
170+
# it surfaces to the test framework.
171+
with self.assertRaisesRegex(
172+
Exception, 'Returning a str from a ParDo or FlatMap is not allowed'):
173+
with TestPipeline() as p:
174+
_ = p | beam.Create([0]) | beam.ParDo(BadDoFn())
175+
176+
def test_dofn_returning_bytes_raises_clear_error(self):
177+
"""Regression test for https://github.com/apache/beam/issues/18712."""
178+
class BadDoFn(DoFn):
179+
def process(self, element):
180+
return b'hello'
181+
182+
with self.assertRaisesRegex(
183+
Exception, 'Returning a bytes from a ParDo or FlatMap is not allowed'):
184+
with TestPipeline() as p:
185+
_ = p | beam.Create([0]) | beam.ParDo(BadDoFn())
186+
187+
def test_dofn_returning_dict_raises_clear_error(self):
188+
"""Regression test for https://github.com/apache/beam/issues/18712."""
189+
class BadDoFn(DoFn):
190+
def process(self, element):
191+
return {'k': 'v'}
192+
193+
with self.assertRaisesRegex(
194+
Exception, 'Returning a dict from a ParDo or FlatMap is not allowed'):
195+
with TestPipeline() as p:
196+
_ = p | beam.Create([0]) | beam.ParDo(BadDoFn())
197+
198+
def test_dofn_yielding_str_is_not_flagged(self):
199+
"""A generator (yield) process() can't hit the #18712 bug.
200+
201+
Yielding a str emits the whole str as one element and must not be
202+
treated as the "returned a str" error case.
203+
"""
204+
class YieldDoFn(DoFn):
205+
def process(self, element):
206+
yield 'hello'
207+
208+
with TestPipeline() as p:
209+
(
210+
p | beam.Create([0]) | beam.ParDo(YieldDoFn())
211+
| beam.ParDo(self.record_dofn()))
212+
213+
self.assertEqual(['hello'], DoFnProcessTest.all_records)
214+
157215
def test_pardo_with_unbounded_per_element_dofn(self):
158216
class UnboundedDoFn(beam.DoFn):
159217
@beam.DoFn.unbounded_per_element()

0 commit comments

Comments
 (0)