Skip to content

Commit 4786d94

Browse files
committed
[Python] Add UnboundedSource SDF wrapper (#19137)
Brings Java's ``UnboundedSource`` / ``UnboundedReader`` / ``CheckpointMark`` abstractions to the Python SDK as a Splittable-DoFn wrapper runnable on the portable Fn API (DirectRunner / FnApiRunner). Wires the new source type into ``iobase.Read.expand()`` so ``p | beam.io.Read(my_unbounded_source)`` dispatches alongside the existing ``BoundedSource`` branch. Loosely inspired by Java's ``Read.UnboundedSourceAsSDFWrapperFn``; the streaming-SDF template followed for the process loop / watermark / defer plumbing is ``apache_beam.transforms.periodicsequence``. addresses #19137 What's added ------------ ``sdks/python/apache_beam/io/unbounded_source.py`` Public ABCs (``CheckpointMark``, ``UnboundedReader``, ``UnboundedSource``, ``ReadFromUnboundedSource``) plus the SDF wrapper internals (``_UnboundedSourceRestriction``, ``_UnboundedSourceRestrictionCoder``, ``_UnboundedSourceRestrictionTracker``, ``_UnboundedSourceRestrictionProvider``). ``sdks/python/apache_beam/io/unbounded_source_test.py`` 42 deterministic tests covering ABC contracts, restriction coder round-trip, tracker state machine (claim / split / EOF / no-data / check_done / progress / is_bounded), finalize idempotency, fan-out via ``source.split``, source-watermark vs. record-event-time, finalize vs. resume channel separation, tracker-internal exception close on reader-method failures, DoFn generator close (unit + integration with downstream raising ``Map``), cloudpickle round-trip, circular import in three subprocess orderings, and an end-to-end DirectRunner pipeline. What's changed in iobase.py --------------------------- * ``Read.expand`` gains an ``UnboundedSource`` branch (function-local lazy import to break the iobase <-> unbounded_source cycle) that delegates to ``ReadFromUnboundedSource``. * ``Read.to_runner_api_parameter`` widens the source ``isinstance`` to ``(BoundedSource, UnboundedSource)``, writing ``READ.urn`` + ``ReadPayload(is_bounded=UNBOUNDED)``. Decode rides the existing ``PICKLED_SOURCE`` URN on ``SourceBase``. Runner-side ``IsBounded.UNBOUNDED`` dispatch in ``bundle_processor.IMPULSE_READ_TRANSFORM`` remains W2 -- execution flows through the composite's expanded ``Impulse | Map | SDF-ParDo``. Correctness covered ------------------- * Data-path watermark uses ``reader.get_watermark()`` (Java ``Read.java:594`` parity), not the per-record event time. Holder is ``(value, record_ts, source_wm)``. * Restriction has separate ``checkpoint_mark`` (resume) and ``finalization_checkpoint_mark`` (commit hook) channels; coder is a fixed 5-tuple. * Reader is closed on every exit path -- tracker-internal close on EOF / split / reader-method exception; DoFn ``finally`` defense-in-depth for yield / downstream raise via the SDF wrapper's private chain (isinstance guard + warning log if the chain ever moves upstream). * EOF advances the watermark estimator to ``MAX_TIMESTAMP`` so downstream event-time windows can close. * ``UnboundedSource.split(desired_num_splits=20, options)`` is honoured; returned sub-sources are validated as ``UnboundedSource`` (raises ``TypeError`` outside the split-refusal ``except``); on split-exception the provider falls back to a single restriction with WARNING. * ``default_output_coder`` reaches the output PCollection via ``coders.registry.register_coder`` + ``element_type``. * ``ReadFromUnboundedSource`` validates ``poll_interval_seconds > 0``. Out of scope (tracked under #19137) ----------------------------------- Listed exhaustively in the module docstring at ``sdks/python/apache_beam/io/unbounded_source.py``: * Record-id-based deduplication (Java's ``ValueWithRecordId``). * Backlog-byte reporting (``restriction_size`` is constant 1; ``current_progress`` is binary 0.0 / 1.0). * Dynamic split fractions / runner-initiated work stealing. * Source-specific checkpoint coders threaded through the SDF restriction coder (checkpoint marks are pickled today regardless of the source's ``get_checkpoint_mark_coder``). * Reader caching across bundles (Java uses a Guava cache). * ``EmptyUnboundedSource`` terminal-state marker (this PoC uses an ``is_done`` flag). * Runner-side ``IsBounded.UNBOUNDED`` dispatch in ``bundle_processor.IMPULSE_READ_TRANSFORM``. Tests: 42/42 ``unbounded_source_test.py``, 16/16 ``iobase_test.py``, yapf + isort clean.
1 parent 5a65f6e commit 4786d94

4 files changed

Lines changed: 2060 additions & 2 deletions

File tree

sdks/python/apache_beam/io/iobase.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,11 @@ def __init__(self, source: SourceBase) -> None:
919919
"""Initializes a Read transform.
920920
921921
Args:
922-
source: Data source to read from.
922+
source: Data source to read from. A ``BoundedSource`` is wrapped in the
923+
bounded SDF reader; an ``UnboundedSource`` is dispatched through
924+
:class:`apache_beam.io.unbounded_source.ReadFromUnboundedSource` with
925+
the default poll interval (users wanting a custom poll cadence must
926+
instantiate ``ReadFromUnboundedSource`` directly).
923927
"""
924928
super().__init__()
925929
self.source = source
@@ -945,6 +949,16 @@ def expand(self, pbegin):
945949
| 'EmitSource' >>
946950
core.Map(lambda _: self.source).with_output_types(BoundedSource)
947951
| SDFBoundedSourceReader(display_data))
952+
# Lazy import to break the iobase <-> unbounded_source cycle: the
953+
# unbounded_source module imports iobase (UnboundedSource extends
954+
# SourceBase). Pattern matches the _PubSubSource lazy import below.
955+
from apache_beam.io.unbounded_source import ReadFromUnboundedSource
956+
from apache_beam.io.unbounded_source import UnboundedSource
957+
if isinstance(self.source, UnboundedSource):
958+
# Delegate to the dedicated SDF PTransform; identical to the user
959+
# writing `p | ReadFromUnboundedSource(self.source)` directly. Custom
960+
# poll_interval_seconds requires using ReadFromUnboundedSource directly.
961+
return pbegin | ReadFromUnboundedSource(self.source)
948962
elif isinstance(self.source, ptransform.PTransform):
949963
# The Read transform can also admit a full PTransform as an input
950964
# rather than an anctual source. If the input is a PTransform, then
@@ -986,7 +1000,15 @@ def to_runner_api_parameter(
9861000
timestamp_attribute=self.source.timestamp_attribute,
9871001
with_attributes=self.source.with_attributes,
9881002
id_attribute=self.source.id_label))
989-
if isinstance(self.source, BoundedSource):
1003+
# Lazy import to avoid the iobase <-> unbounded_source cycle.
1004+
from apache_beam.io.unbounded_source import UnboundedSource
1005+
if isinstance(self.source, (BoundedSource, UnboundedSource)):
1006+
# READ.urn covers both source flavours; the IsBounded enum distinguishes
1007+
# them. NB: today the bundle_processor.py IMPULSE_READ_TRANSFORM handler
1008+
# only consumes BOUNDED - the UNBOUNDED branch round-trips correctly
1009+
# through the protobuf graph but execution still flows through this
1010+
# composite's expanded sub-transforms (Impulse | Map | SDF-ParDo), not
1011+
# through the URN-handler. Runner-side UNBOUNDED dispatch is W2 work.
9901012
return (
9911013
common_urns.deprecated_primitives.READ.urn,
9921014
beam_runner_api_pb2.ReadPayload(

sdks/python/apache_beam/io/iobase_test.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,5 +220,83 @@ def test_sdf_wrap_range_source(self):
220220
self._run_sdf_wrapper_pipeline(RangeSource(0, 4), [0, 1, 2, 3])
221221

222222

223+
class UseSdfUnboundedSourcesTests(unittest.TestCase):
224+
"""Mirrors UseSdfBoundedSourcesTests for the new UnboundedSource branch in
225+
iobase.Read.expand(). Uses CountingSource from unbounded_source_test as the
226+
fake finite UnboundedSource (avoids dragging the network in).
227+
"""
228+
229+
def test_read_dispatches_to_read_from_unbounded_source(self):
230+
from apache_beam.io.unbounded_source_test import CountingSource
231+
with mock.patch(
232+
'apache_beam.io.unbounded_source.ReadFromUnboundedSource.expand'
233+
) as mock_expand:
234+
mock_expand.side_effect = (
235+
lambda pbegin: pbegin | beam.Impulse() | beam.Map(lambda _: 'fake'))
236+
with beam.Pipeline() as p:
237+
out = p | beam.io.Read(CountingSource(3))
238+
assert_that(out, equal_to(['fake']))
239+
mock_expand.assert_called_once()
240+
241+
def test_read_end_to_end_unbounded(self):
242+
from apache_beam.io.unbounded_source_test import CountingSource
243+
with beam.Pipeline() as p:
244+
out = p | beam.io.Read(CountingSource(5))
245+
assert_that(out, equal_to([0, 1, 2, 3, 4]))
246+
247+
def test_read_unbounded_pcollection_is_unbounded(self):
248+
from apache_beam.io.unbounded_source_test import CountingSource
249+
with beam.Pipeline() as p:
250+
out = p | beam.io.Read(CountingSource(3))
251+
self.assertFalse(out.is_bounded)
252+
253+
def test_to_runner_api_emits_unbounded_read_payload(self):
254+
"""``Read.to_runner_api_parameter`` must serialize an UnboundedSource as
255+
``READ.urn`` with ``IsBounded.UNBOUNDED``. The runner-side handler is W2
256+
and ignores this enum today, but the wire format must round-trip
257+
consistently for pipeline persistence / cross-runner submission.
258+
"""
259+
from apache_beam.io.unbounded_source_test import CountingSource
260+
from apache_beam.portability import common_urns
261+
from apache_beam.portability.api import beam_runner_api_pb2
262+
from apache_beam.runners.pipeline_context import PipelineContext
263+
264+
read = beam.io.Read(CountingSource(5))
265+
urn, payload = read.to_runner_api_parameter(PipelineContext())
266+
267+
self.assertEqual(urn, common_urns.deprecated_primitives.READ.urn)
268+
self.assertIsInstance(payload, beam_runner_api_pb2.ReadPayload)
269+
self.assertEqual(
270+
payload.is_bounded, beam_runner_api_pb2.IsBounded.UNBOUNDED)
271+
# The source field must be populated -- a non-empty FunctionSpec proto.
272+
self.assertTrue(payload.source.urn)
273+
274+
def test_read_unbounded_round_trips_through_runner_api(self):
275+
"""Encode then decode via the runner-API protobuf. The restored
276+
transform must be a ``Read`` wrapping an equivalent UnboundedSource.
277+
"""
278+
from apache_beam.io.unbounded_source import UnboundedSource
279+
from apache_beam.io.unbounded_source_test import CountingSource
280+
from apache_beam.portability import common_urns
281+
from apache_beam.portability.api import beam_runner_api_pb2
282+
from apache_beam.runners.pipeline_context import PipelineContext
283+
284+
original = beam.io.Read(CountingSource(7))
285+
context = PipelineContext()
286+
urn, payload = original.to_runner_api_parameter(context)
287+
288+
transform_proto = beam_runner_api_pb2.PTransform()
289+
transform_proto.spec.urn = urn
290+
restored = iobase.Read.from_runner_api_parameter(
291+
transform_proto, payload, context)
292+
293+
self.assertIsInstance(restored, iobase.Read)
294+
self.assertIsInstance(restored.source, UnboundedSource)
295+
self.assertIsInstance(restored.source, CountingSource)
296+
self.assertFalse(restored.source.is_bounded())
297+
# Verify the source's internal state survived pickle round-trip.
298+
self.assertEqual(restored.source._count, 7)
299+
300+
223301
if __name__ == '__main__':
224302
unittest.main()

0 commit comments

Comments
 (0)