This is an automated email from the ASF dual-hosted git repository.
Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 9d3694165e1 [Python] Add UnboundedSource SDF wrapper (#19137) (#38724)
9d3694165e1 is described below
commit 9d3694165e1106fa7bfeee1febf0d9e229400f9b
Author: Elia Liu <[email protected]>
AuthorDate: Fri Jun 26 04:47:06 2026 +1000
[Python] Add UnboundedSource SDF wrapper (#19137) (#38724)
* [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``.
---
sdks/python/apache_beam/io/iobase.py | 14 +-
sdks/python/apache_beam/io/iobase_test.py | 49 +-
sdks/python/apache_beam/io/unbounded_source.py | 996 ++++++++++++++
.../python/apache_beam/io/unbounded_source_test.py | 1380 ++++++++++++++++++++
4 files changed, 2434 insertions(+), 5 deletions(-)
diff --git a/sdks/python/apache_beam/io/iobase.py
b/sdks/python/apache_beam/io/iobase.py
index afc977406af..aa03280050f 100644
--- a/sdks/python/apache_beam/io/iobase.py
+++ b/sdks/python/apache_beam/io/iobase.py
@@ -918,7 +918,10 @@ class Read(ptransform.PTransform):
"""Initializes a Read transform.
Args:
- source: Data source to read from.
+ source: the data source to read from. May be a ``BoundedSource``, an
+ ``UnboundedSource``, or a ``PTransform`` (which is applied directly).
+ For any other source ``Read`` is treated as a primitive and relayed to
+ the runner implementation.
"""
super().__init__()
self.source = source
@@ -944,6 +947,11 @@ class Read(ptransform.PTransform):
| 'EmitSource' >>
core.Map(lambda _: self.source).with_output_types(BoundedSource)
| SDFBoundedSourceReader(display_data))
+ # Local import to avoid a circular dependency.
+ from apache_beam.io.unbounded_source import ReadFromUnboundedSource
+ from apache_beam.io.unbounded_source import UnboundedSource
+ if isinstance(self.source, UnboundedSource):
+ return pbegin | ReadFromUnboundedSource(self.source)
elif isinstance(self.source, ptransform.PTransform):
# The Read transform can also admit a full PTransform as an input
# rather than an anctual source. If the input is a PTransform, then
@@ -993,6 +1001,10 @@ class Read(ptransform.PTransform):
is_bounded=beam_runner_api_pb2.IsBounded.BOUNDED
if self.source.is_bounded() else
beam_runner_api_pb2.IsBounded.UNBOUNDED))
+ # Local import to avoid a circular dependency.
+ from apache_beam.io.unbounded_source import UnboundedSource
+ if isinstance(self.source, UnboundedSource):
+ return super().to_runner_api_parameter(context)
elif isinstance(self.source, ptransform.PTransform):
return self.source.to_runner_api_parameter(context)
raise NotImplementedError(
diff --git a/sdks/python/apache_beam/io/iobase_test.py
b/sdks/python/apache_beam/io/iobase_test.py
index eb9617cfae3..dbedf4681f4 100644
--- a/sdks/python/apache_beam/io/iobase_test.py
+++ b/sdks/python/apache_beam/io/iobase_test.py
@@ -21,15 +21,16 @@
import unittest
-import mock
-
import apache_beam as beam
-from apache_beam.io.concat_source import ConcatSource
-from apache_beam.io.concat_source_test import RangeSource
+import mock
from apache_beam.io import iobase
from apache_beam.io import range_trackers
+from apache_beam.io.concat_source import ConcatSource
+from apache_beam.io.concat_source_test import RangeSource
from apache_beam.io.iobase import SourceBundle
from apache_beam.options.pipeline_options import DebugOptions
+from apache_beam.portability import common_urns
+from apache_beam.portability import python_urns
from apache_beam.testing.util import assert_that
from apache_beam.testing.util import equal_to
@@ -220,5 +221,45 @@ class UseSdfBoundedSourcesTests(unittest.TestCase):
self._run_sdf_wrapper_pipeline(RangeSource(0, 4), [0, 1, 2, 3])
+class UseSdfUnboundedSourcesTests(unittest.TestCase):
+ """Covers the UnboundedSource branch in
+ ``iobase.Read.expand()``. Uses ``UnboundedCountingSource`` from
+ ``unbounded_source_test`` as a finite fake source (no network).
+ """
+ def test_read_end_to_end_unbounded(self):
+ from apache_beam.io.unbounded_source_test import UnboundedCountingSource
+ with beam.Pipeline() as p:
+ out = p | beam.io.Read(UnboundedCountingSource(5))
+ assert_that(out, equal_to([0, 1, 2, 3, 4]))
+
+ def test_read_unbounded_pcollection_is_unbounded(self):
+ from apache_beam.io.unbounded_source_test import UnboundedCountingSource
+ p = beam.Pipeline()
+ out = p | beam.io.Read(UnboundedCountingSource(3))
+ self.assertFalse(out.is_bounded)
+
+ def test_read_unbounded_serializes_as_expanded_composite(self):
+ from apache_beam.io.unbounded_source_test import UnboundedCountingSource
+ p = beam.Pipeline()
+ p | 'ReadIt' >> beam.io.Read(UnboundedCountingSource(3))
+
+ proto = p.to_runner_api(use_fake_coders=True)
+ transforms = proto.components.transforms.values()
+ deprecated_reads = [
+ transform.unique_name for transform in transforms
+ if transform.spec.urn == common_urns.deprecated_primitives.READ.urn
+ ]
+ read_transforms = [
+ transform for transform in proto.components.transforms.values()
+ if transform.unique_name == 'ReadIt'
+ ]
+
+ self.assertEqual([], deprecated_reads)
+ self.assertEqual(1, len(read_transforms))
+ self.assertEqual(
+ python_urns.GENERIC_COMPOSITE_TRANSFORM, read_transforms[0].spec.urn)
+ self.assertTrue(read_transforms[0].subtransforms)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/sdks/python/apache_beam/io/unbounded_source.py
b/sdks/python/apache_beam/io/unbounded_source.py
new file mode 100644
index 00000000000..3246602252e
--- /dev/null
+++ b/sdks/python/apache_beam/io/unbounded_source.py
@@ -0,0 +1,996 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Experimental ``UnboundedSource`` support for the Python SDK.
+
+``UnboundedSource`` support is currently experimental in the Python SDK; the
+API may change in backwards-incompatible ways.
+
+An unbounded source reads an effectively infinite stream of records (message
+queues, change-data-capture feeds, and similar) with checkpoint-based
+pause/resume, watermark reporting, and bundle finalization.
+
+To define a source, implement :class:`UnboundedSource`, an
+:class:`UnboundedReader`, and (when the reader has a resumable position) a
+:class:`CheckpointMark`::
+
+ import apache_beam as beam
+ from apache_beam.io.unbounded_source import (
+ CheckpointMark, UnboundedReader, UnboundedSource)
+ from apache_beam.utils.timestamp import MAX_TIMESTAMP
+
+ class MyCheckpointMark(CheckpointMark):
+ def __init__(self, position):
+ self.position = position
+
+ def finalize_checkpoint(self):
+ # Commit/acknowledge records up to ``position`` upstream, e.g. ack the
+ # consumed messages on a queue.
+ ...
+
+ class MyReader(UnboundedReader):
+ def start(self):
+ # Position at the first record; return whether one is available.
+ ...
+
+ def advance(self):
+ # Move to the next record; ``False`` means no data is available now.
+ ...
+
+ def get_current(self):
+ ...
+
+ def get_current_timestamp(self):
+ ... # event time of the current record
+
+ def get_watermark(self):
+ # Lower bound on the timestamps of future records. Return
+ # ``MAX_TIMESTAMP`` to signal the reader has permanently finished.
+ ...
+
+ def get_checkpoint_mark(self):
+ return MyCheckpointMark(...)
+
+ class MySource(UnboundedSource):
+ def split(self, desired_num_splits, options=None):
+ # Return independent sub-sources, or ``[self]`` when not splittable.
+ return [self]
+
+ def create_reader(self, options, checkpoint_mark):
+ # Build a reader; resume after ``checkpoint_mark`` when it is not None.
+ return MyReader(...)
+
+ def get_checkpoint_mark_coder(self):
+ return ... # a Coder for MyCheckpointMark
+
+Read the source in a pipeline with :class:`apache_beam.io.Read`::
+
+ with beam.Pipeline() as p:
+ p | beam.io.Read(MySource()) | beam.Map(print)
+"""
+
+import collections
+import dataclasses
+import logging
+import threading
+import time
+from typing import Any
+from typing import Callable
+from typing import Iterable
+from typing import Optional
+
+from apache_beam import coders
+from apache_beam.coders.coders import BooleanCoder
+from apache_beam.coders.coders import Coder
+from apache_beam.coders.coders import NullableCoder
+from apache_beam.coders.coders import TimestampCoder
+from apache_beam.coders.coders import TupleCoder
+from apache_beam.coders.coders import _MemoizingPickleCoder
+from apache_beam.io import iobase
+from apache_beam.io.watermark_estimators import ManualWatermarkEstimator
+from apache_beam.runners import sdf_utils
+from apache_beam.transforms import PTransform
+from apache_beam.transforms import core
+from apache_beam.transforms.window import TimestampedValue
+from apache_beam.utils.timestamp import MAX_TIMESTAMP
+from apache_beam.utils.timestamp import MIN_TIMESTAMP
+from apache_beam.utils.timestamp import Duration
+from apache_beam.utils.timestamp import Timestamp
+
+__all__ = [
+ 'CheckpointMark',
+ 'UnboundedReader',
+ 'UnboundedSource',
+ 'ReadFromUnboundedSource',
+]
+
+_LOGGER = logging.getLogger(__name__)
+
+# Sentinel used when a reader has no data available right now.
+# This is distinct from end-of-stream.
+_NO_DATA = object()
+
+_DEFAULT_POLL_INTERVAL_SECONDS = 1.0
+_DEFAULT_DESIRED_NUM_SPLITS = 20
+_DEFAULT_MAX_RECORDS_PER_BUNDLE = 10000
+_DEFAULT_MAX_READ_TIME_SECONDS = 10.0
+# A reader parked by a residual that never resumes is closed once idle this
+# long; the cache also caps its entry count as a memory backstop.
+_DEFAULT_READER_CACHE_IDLE_SECONDS = 60.0
+_DEFAULT_READER_CACHE_MAX_SIZE = 100
+
+# Encodes a source to a structural cache key. Internally consistent across park
+# and acquire; need not match the restriction wire coder.
+_SOURCE_KEY_CODER = _MemoizingPickleCoder()
+
+#
------------------------------------------------------------------------------
+# Public abstract base classes.
+#
------------------------------------------------------------------------------
+
+
+class CheckpointMark(object):
+ """A durable, serializable position in an :class:`UnboundedSource`.
+
+ Produced by :meth:`UnboundedReader.get_checkpoint_mark`, encoded with
+ :meth:`UnboundedSource.get_checkpoint_mark_coder`, and used to resume a
reader
+ (see :meth:`UnboundedSource.create_reader`).
+ """
+ def finalize_checkpoint(self) -> None:
+ """Called once the runner has durably committed work up to this mark.
+
+ Override to acknowledge/commit upstream (for example, ack the consumed
+ messages on a queue). The default is a no-op.
+
+ The runner calls this at most once for a committed checkpoint mark.
+ Finalization is best effort; a mark may never be finalized. An exception
+ raised here is logged. On bundle retry an uncommitted mark may be re-cut
+ over an overlapping span, so this method must be idempotent (acknowledge by
+ absolute position).
+ """
+ pass
+
+
+class UnboundedReader(object):
+ """Reads records from an :class:`UnboundedSource`.
+
+ Lifecycle: exactly one :meth:`start`, then any number of :meth:`advance`
+ calls; whenever one returns ``True`` the current record is available via
+ :meth:`get_current` / :meth:`get_current_timestamp`. A ``False`` return means
+ "no data available right now", which is distinct from end-of-stream: a reader
+ signals a permanent end by reporting a watermark of ``MAX_TIMESTAMP``.
+ """
+ def start(self) -> bool:
+ """Positions at the first record; returns whether one is available."""
+ raise NotImplementedError
+
+ def advance(self) -> bool:
+ """Advances to the next record. ``False`` means no data is available now.
+
+ Should not block. The wrapper enforces the per-bundle record and time caps
+ only between records, so a blocking ``start``/``advance`` can overrun the
+ time cap and stall the bundle. Return ``False`` when no data is currently
+ available instead of waiting.
+ """
+ raise NotImplementedError
+
+ def get_current(self) -> Any:
+ """Returns the record claimed by the last successful start/advance."""
+ raise NotImplementedError
+
+ def get_current_timestamp(self) -> Timestamp:
+ """Returns the event-time timestamp of the current record."""
+ raise NotImplementedError
+
+ def get_watermark(self) -> Timestamp:
+ """An approximate lower bound on timestamps of future records.
+
+ Treated as monotonic by the wrapper. Return ``MAX_TIMESTAMP`` to signal
that
+ this reader has permanently finished.
+ """
+ raise NotImplementedError
+
+ def get_checkpoint_mark(self) -> CheckpointMark:
+ """Returns a durable mark to resume from. Call only at a bundle
boundary."""
+ raise NotImplementedError
+
+ def close(self) -> None:
+ """Releases reader resources. Default no-op."""
+ pass
+
+
+class UnboundedSource(iobase.SourceBase):
+ """A source producing an unbounded stream of records with checkpointing.
+
+ Read it in a pipeline with :class:`apache_beam.io.Read`::
+
+ p | beam.io.Read(MyUnboundedSource())
+ """
+ def split(self,
+ desired_num_splits: int,
+ options: Optional[Any] = None) -> Iterable['UnboundedSource']:
+ """Splits into at most ``desired_num_splits`` independent sub-sources.
+
+ Each returned sub-source must be independent and must not share mutable
+ state with siblings (the runner may execute them concurrently across
+ workers). Return ``[self]`` if the source cannot be split. Splitting is
+ performed once, before any checkpoint exists; once a reader has
+ checkpointed, the restriction is kept intact.
+ """
+ raise NotImplementedError
+
+ def create_reader(
+ self, options: Optional[Any],
+ checkpoint_mark: Optional[CheckpointMark]) -> UnboundedReader:
+ """Creates a reader, optionally resuming from ``checkpoint_mark``.
+
+ Contract:
+ * When ``checkpoint_mark`` is ``None``, the returned reader's ``start()``
+ produces the very first record of the source (or returns ``False`` if
+ none yet).
+ * When ``checkpoint_mark`` is not ``None``, the returned reader's
+ ``start()`` produces the first record strictly after the position
+ encoded by ``checkpoint_mark``. The reader must not re-deliver records
+ already covered by the prior bundle.
+ """
+ raise NotImplementedError
+
+ def get_checkpoint_mark_coder(self) -> Coder:
+ """Returns the coder for this source's :class:`CheckpointMark` instances.
+
+ The SDK may call this while encoding or decoding source restrictions.
+ Implementations should be deterministic, side-effect free, and should not
+ perform I/O.
+ """
+ raise NotImplementedError(
+ '%s must override get_checkpoint_mark_coder() to return a Coder for '
+ 'its CheckpointMark subclass.' % type(self).__name__)
+
+ def is_bounded(self) -> bool:
+ # SourceBase.is_bounded raises; an unbounded source is, by definition, not.
+ return False
+
+ def default_output_coder(self) -> Coder:
+ # Permissive default; override for a tighter coder.
+ return coders.registry.get_coder(object)
+
+
+#
------------------------------------------------------------------------------
+# SDF wrapper internals: a private implementation detail of
+# ReadFromUnboundedSource.
+#
------------------------------------------------------------------------------
+
+
[email protected](frozen=True)
+class _UnboundedSourceRestriction(object):
+ """Durable SDF restriction describing where a reader should (re)start.
+
+ Holds only serializable state -- never a live reader. ``is_done`` marks the
+ terminal (MAX-watermark) transition. ``finalization_checkpoint_mark`` is kept
+ separate from ``checkpoint_mark`` so a done primary can carry a commit hook
+ without polluting the resume state.
+
+ Field roles:
+ * ``checkpoint_mark`` -- RESUME state. A reader rebuilt from this mark
+ must produce the first record strictly after it.
+ * ``finalization_checkpoint_mark`` -- COMMIT hook. Only set on a done
+ primary that was just cut this bundle. Registered with the runner's
+ bundle finalizer to acknowledge upstream. Independent of
+ ``checkpoint_mark`` so a residual's resume state can be ``None`` while
+ still recording what should be committed.
+ """
+ source: UnboundedSource
+ checkpoint_mark: Optional[CheckpointMark] = None
+ watermark: Timestamp = MIN_TIMESTAMP
+ is_done: bool = False
+ finalization_checkpoint_mark: Optional[CheckpointMark] = None
+
+
+class _UnboundedSourceRestrictionCoder(Coder):
+ """Encodes :class:`_UnboundedSourceRestriction` as a fixed 5-tuple.
+
+ Stateless: at encode time the source's own
+ :meth:`UnboundedSource.get_checkpoint_mark_coder` is looked up from the
+ restriction; at decode time the source is decoded first and its coder
+ drives the checkpoint-mark decoding. This avoids passing source-specific
+ coder state into the coder's constructor, which in turn lets
+ :class:`_UnboundedSourceRestrictionProvider` and
+ :class:`_ReadFromUnboundedSourceDoFn` be module-level classes.
+
+ Wire shape: source_bytes / checkpoint_bytes / watermark / done /
+ finalization_checkpoint_bytes -- the checkpoint and finalization bytes
+ are independently encoded with the (source-declared) checkpoint coder
+ wrapped in :class:`NullableCoder`.
+ """
+ def __init__(self):
+ self._source_coder = _MemoizingPickleCoder()
+ self._bytes_coder = coders.BytesCoder()
+ self._tuple_coder = TupleCoder((
+ self._bytes_coder, # source (pickled bytes)
+ self._bytes_coder, # checkpoint_mark (nullable-encoded bytes)
+ TimestampCoder(), # watermark
+ BooleanCoder(), # is_done
+ self._bytes_coder)) # finalization_checkpoint_mark (nullable-encoded)
+
+ def _checkpoint_coder(self, source: UnboundedSource) -> Coder:
+ return NullableCoder(source.get_checkpoint_mark_coder())
+
+ def encode(self, restriction: '_UnboundedSourceRestriction') -> bytes:
+ source_bytes = self._source_coder.encode(restriction.source)
+ cp_coder = self._checkpoint_coder(restriction.source)
+ return self._tuple_coder.encode((
+ source_bytes,
+ cp_coder.encode(restriction.checkpoint_mark),
+ restriction.watermark,
+ restriction.is_done,
+ cp_coder.encode(restriction.finalization_checkpoint_mark)))
+
+ def decode(self, encoded: bytes) -> '_UnboundedSourceRestriction':
+ (source_bytes, checkpoint_bytes, watermark, is_done,
+ finalization_bytes) = self._tuple_coder.decode(encoded)
+ source = self._source_coder.decode(source_bytes)
+ cp_coder = self._checkpoint_coder(source)
+ return _UnboundedSourceRestriction(
+ source=source,
+ checkpoint_mark=cp_coder.decode(checkpoint_bytes),
+ watermark=watermark,
+ is_done=is_done,
+ finalization_checkpoint_mark=cp_coder.decode(finalization_bytes))
+
+ def is_deterministic(self) -> bool:
+ # Pickled source and checkpoint are not guaranteed deterministic.
+ return False
+
+
+class _ReaderCache(object):
+ """Holds live readers between an SDF self-checkpoint and its resume.
+
+ A fresh tracker is built for every bundle, so a reader parked at a
+ self-checkpoint would otherwise be closed and rebuilt from its checkpoint
+ mark on the next bundle. Parking it here lets the resuming bundle reclaim the
+ same started reader, keyed by the residual's structural ``(source, checkpoint
+ mark)``. ``acquire`` removes the entry, so two trackers never drive one
+ reader.
+
+ A residual may be reassigned to another worker and never resume here; such a
+ reader is released once it falls idle past ``idle_seconds`` or when the entry
+ count exceeds ``max_size``, and the owning DoFn's teardown releases the rest.
+ One DoFn instance drives several trackers across threads, so access is
locked.
+ """
+ def __init__(
+ self,
+ idle_seconds: float = _DEFAULT_READER_CACHE_IDLE_SECONDS,
+ max_size: int = _DEFAULT_READER_CACHE_MAX_SIZE,
+ now: Optional[Callable[[], float]] = None):
+ self._idle_seconds = idle_seconds
+ self._max_size = max_size
+ self._now = now or time.monotonic
+ self._lock = threading.Lock()
+ # key -> (reader, started, parked_at); ordered oldest-first for eviction.
+ self._entries = collections.OrderedDict(
+ ) # type: collections.OrderedDict[Any, tuple[UnboundedReader, bool,
float]]
+
+ def acquire(self, key: Any) -> Optional[tuple['UnboundedReader', bool]]:
+ """Removes and returns ``(reader, started)`` for ``key``, or None."""
+ with self._lock:
+ entry = self._entries.pop(key, None)
+ stale = self._evict_idle()
+ self._close_readers(stale)
+ if entry is None:
+ return None
+ return entry[0], entry[1]
+
+ def park(self, key: Any, reader: 'UnboundedReader', started: bool) -> None:
+ """Stores ``reader`` under ``key`` for a later bundle to reclaim. A reader
+ already parked under ``key`` is closed."""
+ with self._lock:
+ replaced = self._entries.pop(key, None)
+ self._entries[key] = (reader, started, self._now())
+ stale = self._evict_idle()
+ while len(self._entries) > self._max_size:
+ _, oldest = self._entries.popitem(last=False)
+ stale.append(oldest[0])
+ if replaced is not None and replaced[0] is not reader:
+ stale.append(replaced[0])
+ self._close_readers(stale)
+
+ def close_all(self) -> None:
+ """Closes every parked reader. Called from the owning DoFn's teardown."""
+ with self._lock:
+ entries = list(self._entries.values())
+ self._entries.clear()
+ self._close_readers(entry[0] for entry in entries)
+
+ def _evict_idle(self) -> list:
+ # Caller holds the lock. Pops entries idle past the window (oldest first)
+ # and returns their readers for the caller to close after unlocking.
+ deadline = self._now() - self._idle_seconds
+ stale = []
+ while self._entries:
+ key, entry = next(iter(self._entries.items()))
+ if entry[2] > deadline:
+ break
+ del self._entries[key]
+ stale.append(entry[0])
+ return stale
+
+ def _close_readers(self, readers) -> None:
+ for reader in readers:
+ try:
+ reader.close()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.warning('Error closing UnboundedReader', exc_info=True)
+
+
+class _UnboundedSourceRestrictionTracker(iobase.RestrictionTracker):
+ """Drives an :class:`UnboundedReader` for one SDF restriction.
+
+ Owns the live reader (lazily created, never serialized): both
runner-initiated
+ ``defer_remainder`` self-checkpoints with ``try_split(0)``, which must
+ checkpoint the live reader.
+
+ A self-checkpoint parks the reader in the DoFn's :class:`_ReaderCache` for
the
+ next bundle to reclaim, keeping one started reader alive across bundles. The
+ DoFn injects ``_reader_cache`` at the start of ``process()``; with no cache
+ the tracker builds a fresh reader each bundle.
+
+ ``process()`` only sees a ``RestrictionTrackerView``, which hides custom
+ methods and whose ``try_claim`` returns just a bool, so the freshly-read
+ record is handed back through a one-element holder list passed as the
+ ``try_claim`` *position* argument.
+ """
+ def __init__(
+ self,
+ restriction: _UnboundedSourceRestriction,
+ options: Optional[Any] = None):
+ self._restriction = restriction
+ self._options = options
+ self._reader = None # type: Optional[UnboundedReader]
+ self._started = False
+ # True once a checkpoint has been cut this bundle (EOF or self-checkpoint).
+ self._checkpoint_taken = False
+ # Cross-bundle reader cache, injected by the DoFn; None disables caching.
+ self._reader_cache = None # type: Optional[_ReaderCache]
+
+ def _ensure_reader(self) -> None:
+ if self._reader is not None:
+ return
+ cached = self._acquire_cached_reader()
+ if cached is not None:
+ # A parked reader is already started and positioned past its checkpoint.
+ self._reader, self._started = cached
+ return
+ self._reader = self._restriction.source.create_reader(
+ self._options, self._restriction.checkpoint_mark)
+
+ def _cache_key(self,
+ restriction: _UnboundedSourceRestriction) -> Optional[Any]:
+ """Structural ``(source, checkpoint)`` key, or None when uncacheable.
+
+ Built from the source pickle and the source's own checkpoint coder so a
+ parked reader and its resuming restriction map to the same entry. A None
+ key disables caching for that restriction; the resume then rebuilds from
+ the checkpoint mark under the source's ``create_reader`` contract.
+ """
+ try:
+ source_bytes = _SOURCE_KEY_CODER.encode(restriction.source)
+ cp_coder = NullableCoder(restriction.source.get_checkpoint_mark_coder())
+ return source_bytes, cp_coder.encode(restriction.checkpoint_mark)
+ except Exception: # pylint: disable=broad-except
+ return None
+
+ def _acquire_cached_reader(self) -> Optional[tuple['UnboundedReader', bool]]:
+ if self._reader_cache is None:
+ return None
+ key = self._cache_key(self._restriction)
+ if key is None:
+ return None
+ return self._reader_cache.acquire(key)
+
+ def _park_or_close_reader(
+ self, residual: _UnboundedSourceRestriction) -> None:
+ """Hands the live reader to the cache for ``residual`` to reclaim, or
+ closes it when no cache is available or the restriction is uncacheable."""
+ if self._reader is None:
+ return
+ key = (
+ self._cache_key(residual) if self._reader_cache is not None else None)
+ if key is None:
+ self._close_reader_if_open()
+ return
+ reader, self._reader = self._reader, None
+ self._reader_cache.park(key, reader, self._started)
+
+ def _clone_checkpoint(
+ self, checkpoint: Optional[CheckpointMark]) -> Optional[CheckpointMark]:
+ """Returns an independent copy of a mark via the source's checkpoint coder.
+
+ Used to keep the primary's finalize hook and the residual's resume state
+ from sharing one object, since a user ``finalize_checkpoint()`` may mutate
+ the mark.
+ """
+ if checkpoint is None:
+ return None
+ coder = self._restriction.source.get_checkpoint_mark_coder()
+ return coder.decode(coder.encode(checkpoint))
+
+ def _close_reader_if_open(self) -> None:
+ """Idempotent reader release. Called by the EOF and split paths, and by
+ the DoFn's ``finally`` so an exception inside ``process()`` does not leak
+ sockets / file descriptors held by the live :class:`UnboundedReader`.
+ """
+ if self._reader is None:
+ return
+ try:
+ self._reader.close()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.warning('Error closing UnboundedReader', exc_info=True)
+ finally:
+ self._reader = None
+
+ def current_restriction(self) -> _UnboundedSourceRestriction:
+ return self._restriction
+
+ def try_claim(self, out: list[Any]) -> bool:
+ """Advances the reader by one record.
+
+ ``out[0]`` receives ``(value, record_timestamp, source_watermark)`` on the
+ has-data path, or the :data:`_NO_DATA` sentinel otherwise. The watermark is
+ the source's reported watermark, not the record's event time: the DoFn
+ advances the output watermark with the former and timestamps the record
+ with the latter. The argument doubles as the output holder (the
+ ``RestrictionTracker`` ABC treats it as a claim position), which the
+ threadsafe-tracker chain forwards opaquely.
+ """
+ try:
+ return self._try_claim_inner(out)
+ except Exception:
+ # Reader state is now indeterminate; release it before re-raising.
+ self._close_reader_if_open()
+ raise
+
+ def _try_claim_inner(self, out: list[Any]) -> bool:
+ if self._restriction.is_done:
+ out[0] = _NO_DATA
+ return False
+ self._ensure_reader()
+ if not self._started:
+ has_data = self._reader.start()
+ else:
+ has_data = self._reader.advance()
+ self._started = True
+ if has_data:
+ # Emit an available record before checking the watermark: a reader may
+ # report its last record and a MAX_TIMESTAMP watermark on the same call,
+ # and EOF is realized on the next data-less claim.
+ out[0] = (
+ self._reader.get_current(),
+ self._reader.get_current_timestamp(),
+ self._reader.get_watermark())
+ return True
+ watermark = self._reader.get_watermark()
+ if watermark >= MAX_TIMESTAMP:
+ # No data and watermark at MAX: cut a final checkpoint, close, mark done.
+ checkpoint = self._reader.get_checkpoint_mark()
+ self._close_reader_if_open()
+ self._restriction = dataclasses.replace(
+ self._restriction,
+ checkpoint_mark=None, # nothing left to resume from
+ watermark=MAX_TIMESTAMP,
+ is_done=True,
+ finalization_checkpoint_mark=checkpoint)
+ self._checkpoint_taken = True
+ out[0] = _NO_DATA
+ return False
+ # No data is available now. Refresh the watermark before deferring.
+ self._restriction = dataclasses.replace(
+ self._restriction, watermark=watermark)
+ out[0] = _NO_DATA
+ return True
+
+ def try_split(
+ self, fraction_of_remainder
+ ) -> Optional[tuple[_UnboundedSourceRestriction,
+ _UnboundedSourceRestriction]]:
+ """Cuts a checkpoint, returning (primary, residual) or None.
+
+ The cut checkpoint goes into ``primary.finalization_checkpoint_mark`` so
+ the DoFn can register a bundle-finalize callback for it. The same
+ checkpoint object also goes into ``residual.checkpoint_mark`` so the
+ resumed reader rebuilds at the correct position. The two fields are
+ independent on purpose (see :class:`_UnboundedSourceRestriction`
+ docstring): a runner that re-processes the primary alone must not see
+ a stale resume state, and a residual must not register finalize again
+ until ITS checkpoint is cut in a future bundle.
+ """
+ try:
+ return self._try_split_inner(fraction_of_remainder)
+ except Exception:
+ self._close_reader_if_open()
+ raise
+
+ def _try_split_inner(self, fraction_of_remainder):
+ # Only self-checkpoint (fraction 0) is supported; decline runner-initiated
+ # dynamic splits.
+ if fraction_of_remainder != 0:
+ return None
+ if self._reader is None or not self._started or self._restriction.is_done:
+ return None
+ checkpoint = self._reader.get_checkpoint_mark()
+ # The residual watermark is advisory; the SDF watermark estimator state is
+ # the authoritative cross-bundle watermark.
+ watermark = self._reader.get_watermark()
+ # Keep the two channels independent: the primary carries only the finalize
+ # hook, the residual only the resume state. The residual gets its own clone
+ # so a finalize_checkpoint() that mutates the primary's mark cannot corrupt
+ # the residual's resume position before the runner encodes it.
+ primary = dataclasses.replace(
+ self._restriction,
+ checkpoint_mark=None,
+ is_done=True,
+ finalization_checkpoint_mark=checkpoint)
+ residual = _UnboundedSourceRestriction(
+ source=self._restriction.source,
+ checkpoint_mark=self._clone_checkpoint(checkpoint),
+ watermark=watermark,
+ is_done=False,
+ finalization_checkpoint_mark=None)
+ self._restriction = primary
+ self._checkpoint_taken = True
+ # Park the reader so the resuming bundle reclaims it; on a cache miss the
+ # residual rebuilds one from its checkpoint mark.
+ self._park_or_close_reader(residual)
+ return primary, residual
+
+ def check_done(self) -> bool:
+ # Called after every process(); must raise if work is left unaccounted for.
+ if self._restriction.is_done or self._checkpoint_taken:
+ return True
+ raise ValueError(
+ 'UnboundedSource restriction was neither finished nor checkpointed; '
+ 'process() must self-checkpoint via defer_remainder() or run to EOF: '
+ '%r' % (self._restriction, ))
+
+ def current_progress(self) -> 'iobase.RestrictionProgress':
+ # Backlog-based progress is not implemented; report a coarse done/not-done
+ # signal via ``completed`` / ``remaining``.
+ if self._restriction.is_done:
+ return iobase.RestrictionProgress(completed=1.0, remaining=0.0)
+ return iobase.RestrictionProgress(completed=0.0, remaining=1.0)
+
+ def is_bounded(self) -> bool:
+ return False
+
+
+class _UnboundedSourceRestrictionProvider(core.RestrictionProvider):
+ """Wraps an :class:`UnboundedSource` element as an SDF restriction.
+
+ Stateless module-level singleton (see :data:`_PROVIDER`): all
+ source-specific state (e.g. the source's checkpoint coder) is derived
+ per-call from the restriction's ``source`` field, which lets
+ :class:`_ReadFromUnboundedSourceDoFn` live at module level too. The provider
+ currently passes ``None`` for the ``options`` forwarded to
+ :meth:`UnboundedSource.split`.
+ """
+ def __init__(self):
+ self._restriction_coder = _UnboundedSourceRestrictionCoder()
+
+ def initial_restriction(
+ self, element: UnboundedSource) -> _UnboundedSourceRestriction:
+ if not isinstance(element, UnboundedSource):
+ raise TypeError(
+ 'ReadFromUnboundedSource expected an UnboundedSource element, got %r'
+ % (element, ))
+ return _UnboundedSourceRestriction(source=element)
+
+ def create_tracker(
+ self, restriction: _UnboundedSourceRestriction
+ ) -> _UnboundedSourceRestrictionTracker:
+ return _UnboundedSourceRestrictionTracker(restriction)
+
+ def split(self, element,
+ restriction) -> Iterable[_UnboundedSourceRestriction]:
+ if restriction.is_done or restriction.checkpoint_mark is not None:
+ yield restriction
+ return
+
+ # ``source.split`` is user code and may refuse to split; fall back to a
+ # single restriction on error.
+ try:
+ split_sources = list(
+ restriction.source.split(_DEFAULT_DESIRED_NUM_SPLITS, None))
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.warning(
+ 'Exception while splitting UnboundedSource. Source not split.',
+ exc_info=True)
+ yield restriction
+ return
+
+ if not split_sources:
+ yield restriction
+ return
+
+ # A non-UnboundedSource split result is a contract violation, not a
+ # refusal, so fail loudly (outside the try/except above).
+ for split_source in split_sources:
+ if not isinstance(split_source, UnboundedSource):
+ raise TypeError(
+ 'UnboundedSource.split() produced %r, expected UnboundedSource' %
+ (split_source, ))
+
+ for split_source in split_sources:
+ yield dataclasses.replace(
+ restriction,
+ source=split_source,
+ checkpoint_mark=None,
+ is_done=False,
+ finalization_checkpoint_mark=None)
+
+ def restriction_size(self, element, restriction) -> int:
+ # TODO(https://github.com/apache/beam/issues/19137): implement backlog
+ # estimation.
+ return 1
+
+ def restriction_coder(self) -> Coder:
+ return self._restriction_coder
+
+ def truncate(self, element, restriction):
+ # On drain, stop emitting new records.
+ return None
+
+
+# Module-level stateless singleton, captured via ``RestrictionParam`` at the
+# DoFn's class-definition time.
+_PROVIDER = _UnboundedSourceRestrictionProvider()
+
+
+class _FinalizeCheckpointOnce(object):
+ def __init__(self, checkpoint_mark: CheckpointMark):
+ self._checkpoint_mark = checkpoint_mark
+ # The lock keeps finalization idempotent if a runner ever invokes the
+ # callback more than once.
+ self._lock = threading.Lock()
+ self._finalized = False
+
+ def __call__(self) -> None:
+ with self._lock:
+ if self._finalized:
+ return
+ self._finalized = True
+ # Finalization is best effort: log and swallow so a failing user override
+ # does not fail the bundle (matches CheckpointMark.finalize_checkpoint).
+ try:
+ self._checkpoint_mark.finalize_checkpoint()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.warning(
+ 'Error finalizing UnboundedSource checkpoint mark.', exc_info=True)
+
+
+class _ReadFromUnboundedSourceDoFn(core.DoFn):
+ """SDF wrapper driving an :class:`UnboundedReader` for one restriction.
+
+ Module-level so stdlib pickle and cloudpickle can serialise the DoFn. The
+ restriction provider is the module-level :data:`_PROVIDER` singleton.
+ """
+ def __init__(
+ self,
+ poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
+ max_records_per_bundle: int = _DEFAULT_MAX_RECORDS_PER_BUNDLE,
+ max_read_time_seconds: float = _DEFAULT_MAX_READ_TIME_SECONDS,
+ _now: Optional[Callable[[], float]] = None):
+ self._poll_interval = poll_interval
+ self._max_records_per_bundle = max_records_per_bundle
+ self._max_read_time_seconds = max_read_time_seconds
+ # Monotonic clock seam; tests inject a deterministic clock.
+ self._now = _now
+ # Per-worker reader cache; created in setup(), never serialized.
+ self._reader_cache = None # type: Optional[_ReaderCache]
+
+ def setup(self):
+ self._reader_cache = _ReaderCache()
+
+ def teardown(self):
+ if self._reader_cache is not None:
+ self._reader_cache.close_all()
+ self._reader_cache = None
+
+ @core.DoFn.unbounded_per_element()
+ def process(
+ self,
+ unused_element,
+ bundle_finalizer=core.DoFn.BundleFinalizerParam,
+ tracker=core.DoFn.RestrictionParam(_PROVIDER),
+ watermark_estimator=core.DoFn.WatermarkEstimatorParam(
+ ManualWatermarkEstimator.default_provider())):
+ # Positional params (element, bundle finalizer) must precede the
+ # kwarg-injected ones (tracker, watermark estimator).
+ assert isinstance(tracker, sdf_utils.RestrictionTrackerView)
+ inner_tracker = _unwrap_tracker(tracker)
+ if inner_tracker is not None and self._reader_cache is not None:
+ # Let this bundle reclaim a reader parked by the prior bundle and re-park
+ # it on self-checkpoint. No cache means setup() was skipped.
+ inner_tracker._reader_cache = self._reader_cache
+ initial = tracker.current_restriction()
+ now = self._now or time.monotonic
+ records_emitted = 0
+ # Armed on the first emitted record so reader startup is excluded.
+ read_deadline = None # type: Optional[float]
+ try:
+ while True:
+ holder = [None]
+ if not tracker.try_claim(holder):
+ # EOF: advance the estimator to the tracker's MAX watermark so
+ # downstream event-time windows can close.
+ _set_watermark_if_greater(
+ watermark_estimator, tracker.current_restriction().watermark)
+ break
+ record = holder[0]
+ if record is _NO_DATA:
+ # No data now: advance the watermark and self-checkpoint with the
+ # poll delay so an idle source backs off before resuming.
+ _set_watermark_if_greater(
+ watermark_estimator, tracker.current_restriction().watermark)
+ tracker.defer_remainder(Duration(seconds=self._poll_interval))
+ break
+ # The third tuple field is the source watermark. The record timestamp
+ # remains the output event time. Emit the element before advancing the
+ # estimator so a reader that reports MAX on the same claim as its final
+ # record cannot push the output watermark past that record first.
+ value, record_timestamp, source_watermark = record
+ yield TimestampedValue(value, record_timestamp)
+ _set_watermark_if_greater(watermark_estimator, source_watermark)
+ records_emitted += 1
+ if read_deadline is None:
+ read_deadline = now() + self._max_read_time_seconds
+ # A busy reader never hits the EOF or no-data branch. Bound the bundle
+ # by record count and elapsed time so the runner commits the checkpoint
+ # and runs finalization, then resume with no delay. The deadline is
+ # checked between records; a reader that blocks inside advance() can
+ # overrun it, so the record cap is the hard backstop.
+ reached_record_cap = records_emitted >= self._max_records_per_bundle
+ if reached_record_cap or now() >= read_deadline:
+ tracker.defer_remainder()
+ break
+ finally:
+ current = tracker.current_restriction()
+ try:
+ # Register finalization only when a checkpoint was cut this bundle.
+ # The SDK bundle finalizer applies no deadline, so finalization is
+ # unbounded best effort.
+ finalize_mark = current.finalization_checkpoint_mark
+ if current is not initial and finalize_mark is not None:
+ bundle_finalizer.register(_FinalizeCheckpointOnce(finalize_mark))
+ finally:
+ # The EOF and self-checkpoint paths already closed or parked the
+ # reader, so this is a no-op there. It closes a reader still held when
+ # process() exits early, e.g. a downstream yield raised before any
+ # checkpoint.
+ if inner_tracker is not None:
+ inner_tracker._close_reader_if_open()
+ else:
+ _LOGGER.warning(
+ 'UnboundedSource DoFn could not close a reader because the SDF '
+ 'tracker wrapper did not expose '
+ '_UnboundedSourceRestrictionTracker (got %s). Reader resources '
+ 'may remain open until garbage collection.',
+ type(tracker).__name__)
+
+
+def _unwrap_tracker(
+ tracker: Any) -> Optional['_UnboundedSourceRestrictionTracker']:
+ """Returns the :class:`_UnboundedSourceRestrictionTracker` behind the SDF
+ view and threadsafe wrappers, or None when the chain is unexpected."""
+ inner = tracker
+ if hasattr(inner, '_threadsafe_restriction_tracker'):
+ inner = inner._threadsafe_restriction_tracker
+ if hasattr(inner, '_restriction_tracker'):
+ inner = inner._restriction_tracker
+ if isinstance(inner, _UnboundedSourceRestrictionTracker):
+ return inner
+ return None
+
+
+def _set_watermark_if_greater(
+ watermark_estimator, new_watermark: Timestamp) -> None:
+ # ManualWatermarkEstimator.set_watermark raises on regression, so only ever
+ # advance it (a regressing reader watermark is absorbed here).
+ current = watermark_estimator.current_watermark()
+ if current is None or new_watermark > current:
+ watermark_estimator.set_watermark(new_watermark)
+
+
+class ReadFromUnboundedSource(PTransform):
+ """Reads an :class:`UnboundedSource`.
+
+ Most users should prefer :class:`apache_beam.io.Read`, which dispatches an
+ ``UnboundedSource`` here automatically::
+
+ p | beam.io.Read(MyUnboundedSource())
+
+ Args:
+ source: the :class:`UnboundedSource` to read.
+ poll_interval: resume delay in seconds applied when the reader has no data,
+ which bounds how often an idle source is polled. Must be >= 0.
+ max_records_per_bundle: a busy reader self-checkpoints after emitting this
+ many records in one bundle. Must be >= 1. Defaults to 10000.
+ max_read_time_seconds: a busy reader self-checkpoints after this many
+ seconds in one bundle. Must be > 0. Defaults to 10.0. The deadline is
+ checked between records, so a reader that blocks inside ``advance()`` may
+ overrun it; ``max_records_per_bundle`` is the hard backstop.
+
+ The bundle self-checkpoints as soon as either cap is reached.
+ """
+ def __init__(
+ self,
+ source: UnboundedSource,
+ poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
+ max_records_per_bundle: int = _DEFAULT_MAX_RECORDS_PER_BUNDLE,
+ max_read_time_seconds: float = _DEFAULT_MAX_READ_TIME_SECONDS):
+ if not isinstance(source, UnboundedSource):
+ raise TypeError('source must be an UnboundedSource, got %r' % (source, ))
+ if max_records_per_bundle < 1:
+ raise ValueError(
+ 'max_records_per_bundle must be >= 1, got %r' %
+ (max_records_per_bundle, ))
+ if max_read_time_seconds <= 0:
+ raise ValueError(
+ 'max_read_time_seconds must be > 0, got %r' %
+ (max_read_time_seconds, ))
+ if poll_interval < 0:
+ raise ValueError(
+ 'poll_interval must be >= 0, got %r' % (poll_interval, ))
+ super().__init__()
+ self._source = source
+ self._poll_interval = poll_interval
+ self._max_records_per_bundle = max_records_per_bundle
+ self._max_read_time_seconds = max_read_time_seconds
+
+ def expand(self, pbegin):
+ source = self._source
+ output_coder = source.default_output_coder()
+ # The source is the SDF element used to derive the initial restriction.
+ # process() reads from the restriction, so it does not use the element
+ # directly.
+ output = (
+ pbegin
+ | 'Create' >> core.Create([source])
+ | 'ReadUnbounded' >> core.ParDo(
+ _ReadFromUnboundedSourceDoFn(
+ self._poll_interval,
+ self._max_records_per_bundle,
+ self._max_read_time_seconds)))
+ # Surface an element type only when the global registry already maps it to
+ # an equivalent coder. Avoid mutating ``coders.registry`` for a
+ # parameterized coder whose instance state would be lost.
+ try:
+ type_hint = output_coder.to_type_hint()
+ except NotImplementedError:
+ type_hint = None
+ if type_hint is not None:
+ try:
+ registered_coder = coders.registry.get_coder(type_hint)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.warning(
+ 'Could not look up the registered coder for element type %s.',
+ type_hint,
+ exc_info=True)
+ else:
+ if registered_coder == output_coder:
+ output.element_type = type_hint
+ return output
+
+ def _infer_output_coder(self, input_type=None, input_coder=None):
+ return self._source.default_output_coder()
diff --git a/sdks/python/apache_beam/io/unbounded_source_test.py
b/sdks/python/apache_beam/io/unbounded_source_test.py
new file mode 100644
index 00000000000..3b63d0d2de9
--- /dev/null
+++ b/sdks/python/apache_beam/io/unbounded_source_test.py
@@ -0,0 +1,1380 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Tests for apache_beam.io.unbounded_source.
+
+Semantics are covered by deterministic unit tests; the end-to-end DirectRunner
+tests assert ordering and termination only (no flaky defer-timing assertions).
+"""
+
+import logging
+import unittest
+
+from typing_extensions import override
+
+import apache_beam as beam
+from apache_beam import coders
+from apache_beam.io import unbounded_source as _unbounded_source_module
+from apache_beam.io.unbounded_source import _NO_DATA
+from apache_beam.io.unbounded_source import CheckpointMark
+from apache_beam.io.unbounded_source import ReadFromUnboundedSource
+from apache_beam.io.unbounded_source import UnboundedReader
+from apache_beam.io.unbounded_source import UnboundedSource
+from apache_beam.io.unbounded_source import _FinalizeCheckpointOnce
+from apache_beam.io.unbounded_source import _ReaderCache
+from apache_beam.io.unbounded_source import _ReadFromUnboundedSourceDoFn
+from apache_beam.io.unbounded_source import _set_watermark_if_greater
+from apache_beam.io.unbounded_source import _UnboundedSourceRestriction
+from apache_beam.io.unbounded_source import _UnboundedSourceRestrictionCoder
+from apache_beam.io.unbounded_source import _UnboundedSourceRestrictionProvider
+from apache_beam.io.unbounded_source import _UnboundedSourceRestrictionTracker
+from apache_beam.io.watermark_estimators import ManualWatermarkEstimator
+from apache_beam.runners import sdf_utils
+from apache_beam.testing.test_pipeline import TestPipeline
+from apache_beam.testing.util import assert_that
+from apache_beam.testing.util import equal_to
+from apache_beam.transforms import core
+from apache_beam.transforms.window import FixedWindows
+from apache_beam.utils.timestamp import MAX_TIMESTAMP
+from apache_beam.utils.timestamp import MIN_TIMESTAMP
+from apache_beam.utils.timestamp import Timestamp
+
+# pylint: disable=expression-not-assigned
+
+# Realistic event-time base away from the Unix epoch.
+_EVENT_TIME_BASE = Timestamp(1729987200) # 2024-10-27T00:00:00Z
+
+#
------------------------------------------------------------------------------
+# In-memory demo source emitting integers 0..count-1 with event time
+# ``_EVENT_TIME_BASE + index``. It self-terminates at EOF, resumes from
+# ``last_index + 1``, and splits into even/odd sub-sources when configured.
+#
------------------------------------------------------------------------------
+
+
+class _CountingCheckpointMark(CheckpointMark):
+ def __init__(self, last_index, finalize_log=None):
+ self.last_index = last_index
+ self._finalize_log = finalize_log
+
+ @override
+ def finalize_checkpoint(self):
+ if self._finalize_log is not None:
+ self._finalize_log.append(self.last_index)
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, _CountingCheckpointMark) and
+ other.last_index == self.last_index)
+
+ def __hash__(self):
+ return hash(self.last_index)
+
+ def __repr__(self):
+ return '_CountingCheckpointMark(last_index=%r)' % (self.last_index, )
+
+
+class _CountingReader(UnboundedReader):
+ def __init__(
+ self, count, start_index, finalize_log=None, modulus=1, residue=0):
+ self._count = count
+ self._next = start_index
+ self._modulus = modulus
+ self._residue = residue
+ self._current = None
+ self._exhausted = False
+ self._finalize_log = finalize_log
+ self.closed = False
+
+ def _read_next(self):
+ while self._next < self._count:
+ index = self._next
+ self._next += 1
+ if index % self._modulus == self._residue:
+ self._current = index
+ return True
+ self._exhausted = True
+ return False
+
+ @override
+ def start(self):
+ return self._read_next()
+
+ @override
+ def advance(self):
+ return self._read_next()
+
+ @override
+ def get_current(self):
+ return self._current
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE + self._current
+
+ @override
+ def get_watermark(self):
+ if self._exhausted:
+ return MAX_TIMESTAMP
+ if self._current is None:
+ return MIN_TIMESTAMP
+ return _EVENT_TIME_BASE + self._current
+
+ @override
+ def get_checkpoint_mark(self):
+ last = self._current if self._current is not None else self._next - 1
+ return _CountingCheckpointMark(last, finalize_log=self._finalize_log)
+
+ @override
+ def close(self):
+ self.closed = True
+
+
+class UnboundedCountingSource(UnboundedSource):
+ def __init__(
+ self,
+ count,
+ finalize_log=None,
+ is_splittable=False,
+ modulus=1,
+ residue=0):
+ self._count = count
+ self._finalize_log = finalize_log
+ self._is_splittable = is_splittable
+ self._modulus = modulus
+ self._residue = residue
+ self.last_reader = None
+
+ @override
+ def split(self, desired_num_splits, options=None):
+ if not self._is_splittable or desired_num_splits < 2:
+ return [self]
+ # Split into independent even/odd sub-sources (each non-splittable).
+ return [
+ UnboundedCountingSource(
+ self._count,
+ finalize_log=self._finalize_log,
+ modulus=2,
+ residue=residue) for residue in (0, 1)
+ ]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ start_index = (
+ 0 if checkpoint_mark is None else checkpoint_mark.last_index + 1)
+ self.last_reader = _CountingReader(
+ self._count,
+ start_index,
+ finalize_log=self._finalize_log,
+ modulus=self._modulus,
+ residue=self._residue)
+ return self.last_reader
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+
+class _StringCountingReader(_CountingReader):
+ @override
+ def get_current(self):
+ return 'v%s' % self._current
+
+
+class _StringCountingSource(UnboundedCountingSource):
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ start_index = (
+ 0 if checkpoint_mark is None else checkpoint_mark.last_index + 1)
+ self.last_reader = _StringCountingReader(
+ self._count, start_index, finalize_log=self._finalize_log)
+ return self.last_reader
+
+ @override
+ def default_output_coder(self):
+ return coders.StrUtf8Coder()
+
+
+class _PrefixStrCoder(coders.Coder):
+ def __init__(self, prefix):
+ self._prefix = prefix
+
+ @override
+ def encode(self, value):
+ if not value.startswith(self._prefix):
+ raise ValueError('expected %r prefix' % self._prefix)
+ return value[len(self._prefix):].encode('utf-8')
+
+ @override
+ def decode(self, value):
+ return self._prefix + value.decode('utf-8')
+
+ @override
+ def is_deterministic(self):
+ return True
+
+ @override
+ def to_type_hint(self):
+ return str
+
+
+class _PrefixStringReader(_StringCountingReader):
+ @override
+ def get_current(self):
+ return 'prefix:%s' % super().get_current()
+
+
+class _PrefixStringSource(_StringCountingSource):
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ start_index = (
+ 0 if checkpoint_mark is None else checkpoint_mark.last_index + 1)
+ self.last_reader = _PrefixStringReader(
+ self._count, start_index, finalize_log=self._finalize_log)
+ return self.last_reader
+
+ @override
+ def default_output_coder(self):
+ return _PrefixStrCoder('prefix:')
+
+
+class _NoDataReader(UnboundedReader):
+ """Always reports temporary absence of data with watermark below MAX."""
+ @override
+ def start(self):
+ return False
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ raise AssertionError('no data available')
+
+ @override
+ def get_current_timestamp(self):
+ raise AssertionError('no data available')
+
+ @override
+ def get_watermark(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(-1)
+
+
+class _NoDataSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _NoDataReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+
+class _MutatingCheckpointMark(CheckpointMark):
+ """A mark that mutates itself on finalize, to test primary/residual mark
+ isolation across a checkpoint cut."""
+ def __init__(self, last_index):
+ self.last_index = last_index
+
+ @override
+ def finalize_checkpoint(self):
+ self.last_index = -999
+
+
+class _MutatingReader(UnboundedReader):
+ def __init__(self):
+ self._index = -1
+
+ @override
+ def start(self):
+ self._index = 0
+ return True
+
+ @override
+ def advance(self):
+ self._index += 1
+ return True
+
+ @override
+ def get_current(self):
+ return self._index
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE + self._index
+
+ @override
+ def get_watermark(self):
+ return _EVENT_TIME_BASE + self._index
+
+ @override
+ def get_checkpoint_mark(self):
+ return _MutatingCheckpointMark(self._index)
+
+
+class _MutatingSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _MutatingReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+
+class _MaxOnLastReader(UnboundedReader):
+ """Returns its only record with a MAX_TIMESTAMP watermark on the same claim,
+ then EOF on the next claim."""
+ @override
+ def start(self):
+ return True
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ return 7
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_watermark(self):
+ return MAX_TIMESTAMP
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(0)
+
+
+class _MaxOnLastSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _MaxOnLastReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+
+def _new_tracker(source, checkpoint=None):
+ restriction = _UnboundedSourceRestriction(
+ source=source, checkpoint_mark=checkpoint)
+ return _UnboundedSourceRestrictionTracker(restriction)
+
+
+def _claim(tracker):
+ """Claims once; returns (claimed_bool, holder_value)."""
+ holder = [None]
+ claimed = tracker.try_claim(holder)
+ return claimed, holder[0]
+
+
+#
------------------------------------------------------------------------------
+# Tests
+#
------------------------------------------------------------------------------
+
+
+class AbcContractTest(unittest.TestCase):
+ def test_checkpointmark_default_finalize_is_noop(self):
+ self.assertIsNone(CheckpointMark().finalize_checkpoint())
+
+ def test_unboundedsource_is_bounded_false(self):
+ self.assertFalse(UnboundedCountingSource(3).is_bounded())
+
+ def test_reader_lifecycle_start_advance_eof(self):
+ reader = UnboundedCountingSource(3).create_reader(None, None)
+ self.assertTrue(reader.start())
+ self.assertEqual(reader.get_current(), 0)
+ self.assertEqual(reader.get_current_timestamp(), _EVENT_TIME_BASE)
+ self.assertTrue(reader.advance())
+ self.assertEqual(reader.get_current(), 1)
+ self.assertTrue(reader.advance())
+ self.assertEqual(reader.get_current(), 2)
+ self.assertFalse(reader.advance())
+ self.assertEqual(reader.get_watermark(), MAX_TIMESTAMP)
+
+
+class RestrictionCoderTest(unittest.TestCase):
+ def test_roundtrip_no_checkpoint(self):
+ source = UnboundedCountingSource(3)
+ coder = _UnboundedSourceRestrictionCoder()
+ decoded = coder.decode(
+ coder.encode(_UnboundedSourceRestriction(source=source)))
+ self.assertIsNone(decoded.checkpoint_mark)
+ self.assertEqual(decoded.watermark, MIN_TIMESTAMP)
+ self.assertFalse(decoded.is_done)
+ reader = decoded.source.create_reader(None, None)
+ self.assertTrue(reader.start())
+ self.assertEqual(reader.get_current(), 0)
+
+ def test_roundtrip_with_checkpoint_resumes(self):
+ source = UnboundedCountingSource(5)
+ coder = _UnboundedSourceRestrictionCoder()
+ restriction = _UnboundedSourceRestriction(
+ source=source,
+ checkpoint_mark=_CountingCheckpointMark(1),
+ watermark=Timestamp(1),
+ is_done=False)
+ decoded = coder.decode(coder.encode(restriction))
+ self.assertEqual(decoded.checkpoint_mark.last_index, 1)
+ self.assertEqual(decoded.watermark, Timestamp(1))
+ self.assertFalse(decoded.is_done)
+ # A reader built from the decoded checkpoint resumes at the next index.
+ reader = decoded.source.create_reader(None, decoded.checkpoint_mark)
+ self.assertTrue(reader.start())
+ self.assertEqual(reader.get_current(), 2)
+
+
+class RestrictionProviderTest(unittest.TestCase):
+ def test_initial_split_calls_source_split(self):
+ split_log = []
+
+ class _NamedSource(UnboundedCountingSource):
+ def __init__(self, name):
+ super().__init__(0)
+ self.name = name
+
+ @override
+ def split(self, desired_num_splits, options=None):
+ split_log.append((desired_num_splits, options))
+ return [_NamedSource('a'), _NamedSource('b')]
+
+ source = _NamedSource('root')
+ provider = _UnboundedSourceRestrictionProvider()
+ restriction = _UnboundedSourceRestriction(
+ source=source, watermark=Timestamp(7))
+
+ splits = list(provider.split(source, restriction))
+
+ # The provider is a stateless module-level singleton, so it always
+ # passes ``None`` as the ``options`` argument to ``UnboundedSource.split``.
+ self.assertEqual(split_log, [(20, None)])
+ self.assertEqual([split.source.name for split in splits], ['a', 'b'])
+ self.assertEqual([split.watermark for split in splits], [Timestamp(7)] * 2)
+ self.assertTrue(all(split.checkpoint_mark is None for split in splits))
+ self.assertTrue(
+ all(split.finalization_checkpoint_mark is None for split in splits))
+
+ def test_initial_split_does_not_split_checkpointed_restriction(self):
+ split_log = []
+
+ class _SplitSource(UnboundedCountingSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ split_log.append((desired_num_splits, options))
+ return [self]
+
+ source = _SplitSource(5)
+ provider = _UnboundedSourceRestrictionProvider()
+ restriction = _UnboundedSourceRestriction(
+ source=source, checkpoint_mark=_CountingCheckpointMark(2))
+
+ self.assertEqual(list(provider.split(source, restriction)), [restriction])
+ self.assertEqual(split_log, [])
+
+ def test_initial_split_falls_back_to_original_on_split_error(self):
+ class _BoomSource(UnboundedCountingSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ raise RuntimeError('split boom')
+
+ source = _BoomSource(5)
+ provider = _UnboundedSourceRestrictionProvider()
+ restriction = _UnboundedSourceRestriction(source=source)
+
+ self.assertEqual(list(provider.split(source, restriction)), [restriction])
+
+ def test_truncate_returns_none_for_drain(self):
+ # On drain the SDF stops emitting; truncate yields no residual.
+ provider = _UnboundedSourceRestrictionProvider()
+ source = UnboundedCountingSource(5)
+ restriction = _UnboundedSourceRestriction(source=source)
+ self.assertIsNone(provider.truncate(source, restriction))
+
+ def test_splittable_source_partitions_into_independent_subsources(self):
+ # A splittable source fans out into two sub-sources; reading each in
+ # isolation yields the even and the odd integers, and their union is the
+ # full sequence with no overlap.
+ source = UnboundedCountingSource(6, is_splittable=True)
+ provider = _UnboundedSourceRestrictionProvider()
+ restriction = _UnboundedSourceRestriction(source=source)
+
+ splits = list(provider.split(source, restriction))
+ self.assertEqual(len(splits), 2)
+
+ shards = []
+ for split in splits:
+ tracker = _UnboundedSourceRestrictionTracker(split)
+ shard = []
+ while True:
+ claimed, record = _claim(tracker)
+ if not claimed:
+ break
+ if record is not _NO_DATA:
+ shard.append(record[0])
+ shards.append(shard)
+ self.assertEqual(sorted(shards), [[0, 2, 4], [1, 3, 5]])
+
+
+class RestrictionTrackerTest(unittest.TestCase):
+ def test_claim_emits_in_order(self):
+ tracker = _new_tracker(UnboundedCountingSource(3))
+ values = []
+ while True:
+ claimed, record = _claim(tracker)
+ if not claimed:
+ break
+ self.assertIsNot(record, _NO_DATA)
+ values.append(record[0])
+ self.assertEqual(values, [0, 1, 2])
+ self.assertTrue(tracker.check_done())
+
+ def test_claim_emits_final_record_when_watermark_is_max(self):
+ # A reader may return its last record with a MAX_TIMESTAMP watermark on the
+ # same call; the record must still be emitted (EOF comes on the next
claim).
+ class _FinalRecordReader(UnboundedReader):
+ @override
+ def start(self):
+ return True
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ return 'last'
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_watermark(self):
+ return MAX_TIMESTAMP
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(0)
+
+ class _FinalSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _FinalRecordReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+ tracker = _new_tracker(_FinalSource())
+ claimed, record = _claim(tracker)
+ self.assertTrue(claimed)
+ self.assertIsNot(record, _NO_DATA)
+ self.assertEqual(record[0], 'last')
+ # The next claim observes EOF and finishes (no second, phantom record).
+ claimed_again, _ = _claim(tracker)
+ self.assertFalse(claimed_again)
+ self.assertTrue(tracker.check_done())
+
+ def test_try_split_zero_produces_resumable_residual(self):
+ source = UnboundedCountingSource(5)
+ tracker = _new_tracker(source)
+ # Claim 0 and 1.
+ self.assertEqual(_claim(tracker)[1][0], 0)
+ self.assertEqual(_claim(tracker)[1][0], 1)
+
+ split = tracker.try_split(0)
+ self.assertIsNotNone(split)
+ primary, residual = split
+ self.assertTrue(primary.is_done)
+ self.assertFalse(residual.is_done)
+ # Resume / finalize channel separation: primary carries only the
+ # finalize hook, residual carries only the resume state.
+ self.assertIsNone(primary.checkpoint_mark)
+ self.assertIsNotNone(primary.finalization_checkpoint_mark)
+ self.assertEqual(primary.finalization_checkpoint_mark.last_index, 1)
+ self.assertEqual(residual.checkpoint_mark.last_index, 1)
+ self.assertIsNone(residual.finalization_checkpoint_mark)
+ # check_done passes on the (now done) primary.
+ self.assertTrue(tracker.check_done())
+
+ # Resuming from the residual continues at index 2.
+ resumed = _new_tracker(source, checkpoint=residual.checkpoint_mark)
+ self.assertEqual(_claim(resumed)[1][0], 2)
+
+ def test_try_split_isolates_residual_from_finalize_mutation(self):
+ # The primary's finalize hook and the residual's resume state must not
+ # share one object, so a mutating finalize_checkpoint() cannot corrupt the
+ # residual's resume position.
+ tracker = _new_tracker(_MutatingSource())
+ _claim(tracker) # start -> index 0
+ split = tracker.try_split(0)
+ self.assertIsNotNone(split)
+ primary, residual = split
+ self.assertIsNot(
+ primary.finalization_checkpoint_mark, residual.checkpoint_mark)
+ self.assertEqual(residual.checkpoint_mark.last_index, 0)
+ primary.finalization_checkpoint_mark.finalize_checkpoint()
+ self.assertEqual(residual.checkpoint_mark.last_index, 0)
+
+ def test_try_split_nonzero_declined(self):
+ source = UnboundedCountingSource(5)
+ tracker = _new_tracker(source)
+ self.assertEqual(_claim(tracker)[1][0], 0)
+
+ self.assertIsNone(tracker.try_split(0.5))
+ self.assertFalse(tracker.current_restriction().is_done)
+ self.assertIsNotNone(tracker._reader)
+ self.assertEqual(_claim(tracker)[1][0], 1)
+
+ def test_no_data_returns_sentinel_without_finishing(self):
+ tracker = _new_tracker(_NoDataSource())
+ claimed, record = _claim(tracker)
+ self.assertTrue(claimed)
+ self.assertIs(record, _NO_DATA)
+ # A self-checkpoint is still possible (poll/resume path).
+ self.assertIsNotNone(tracker.try_split(0))
+
+ def test_check_done_raises_when_not_done(self):
+ tracker = _new_tracker(UnboundedCountingSource(3))
+ with self.assertRaises(ValueError):
+ tracker.check_done()
+
+ def test_is_bounded_false(self):
+ self.assertFalse(_new_tracker(UnboundedCountingSource(3)).is_bounded())
+
+
+class _RecordingBundleFinalizer:
+ def __init__(self):
+ self.registered = []
+
+ def register(self, callback):
+ self.registered.append(callback)
+
+
+class _ManualClock:
+ """A deterministic monotonic clock for the time-cap tests."""
+ def __init__(self, now=0.0):
+ self.now = now
+
+ def __call__(self):
+ return self.now
+
+
+class BundleCapTest(unittest.TestCase):
+ """A busy reader self-checkpoints once the per-bundle record or time cap is
+ reached, so the runner can commit progress and run finalization."""
+ def _bundle(self, dofn, source, checkpoint=None, estimator=None):
+ """Builds the SDF tracker chain and returns the process() generator plus
the
+ tracker, threadsafe tracker, finalizer, and watermark estimator."""
+ tracker = _UnboundedSourceRestrictionTracker(
+ _UnboundedSourceRestriction(source=source, checkpoint_mark=checkpoint))
+ threadsafe = sdf_utils.ThreadsafeRestrictionTracker(tracker)
+ view = sdf_utils.RestrictionTrackerView(threadsafe)
+ finalizer = _RecordingBundleFinalizer()
+ estimator = estimator or ManualWatermarkEstimator(None)
+ gen = dofn.process(
+ None,
+ bundle_finalizer=finalizer,
+ tracker=view,
+ watermark_estimator=estimator)
+ return gen, tracker, threadsafe, finalizer, estimator
+
+ def test_record_cap_checkpoints_busy_source(self):
+ finalize_log = []
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=5, max_read_time_seconds=1e9)
+ # 1000 records is effectively unbounded against a cap of 5.
+ gen, tracker, threadsafe, finalizer, estimator = self._bundle(
+ dofn, UnboundedCountingSource(1000, finalize_log=finalize_log))
+ outputs = list(gen)
+
+ self.assertEqual([tv.value for tv in outputs], [0, 1, 2, 3, 4])
+ self.assertTrue(tracker.current_restriction().is_done)
+ self.assertTrue(tracker.check_done())
+ # The estimator holds the last emitted record's source watermark.
+ self.assertEqual(estimator.current_watermark(), _EVENT_TIME_BASE + 4)
+ # Residual resumes after the cut and carries no finalize hook.
+ residual, _ = threadsafe.deferred_status()
+ self.assertEqual(residual.checkpoint_mark.last_index, 4)
+ self.assertIsNone(residual.finalization_checkpoint_mark)
+ # Exactly one finalizer is registered; firing it commits the cut index
once.
+ self.assertEqual(len(finalizer.registered), 1)
+ finalizer.registered[0]()
+ finalizer.registered[0]()
+ self.assertEqual(finalize_log, [4])
+
+ def test_time_cap_checkpoints_busy_source(self):
+ clock = _ManualClock(1000.0)
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0,
+ max_records_per_bundle=10**9,
+ max_read_time_seconds=5.0,
+ _now=clock)
+ gen, tracker, threadsafe, _, _ = self._bundle(
+ dofn, UnboundedCountingSource(1000))
+
+ # The deadline arms at 1000 + 5 after the first record and is checked
+ # between records, so records keep flowing until the clock passes it.
+ self.assertEqual(next(gen).value, 0)
+ self.assertEqual(next(gen).value, 1)
+ self.assertEqual(next(gen).value, 2)
+ clock.now = 1006.0
+ with self.assertRaises(StopIteration):
+ next(gen)
+
+ self.assertTrue(tracker.current_restriction().is_done)
+ residual, _ = threadsafe.deferred_status()
+ self.assertEqual(residual.checkpoint_mark.last_index, 2)
+
+ def test_cap_residual_resumes_in_next_bundle(self):
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=5, max_read_time_seconds=1e9)
+ source = UnboundedCountingSource(1000)
+ # Bundle 1 emits 0-4 and cuts a residual at index 4.
+ gen1, _, threadsafe1, _, _ = self._bundle(dofn, source)
+ self.assertEqual([tv.value for tv in gen1], [0, 1, 2, 3, 4])
+ residual1, _ = threadsafe1.deferred_status()
+
+ # Bundle 2 rebuilds the reader from the residual and emits 5-9.
+ gen2, _, threadsafe2, _, _ = self._bundle(
+ dofn, source, checkpoint=residual1.checkpoint_mark)
+ self.assertEqual([tv.value for tv in gen2], [5, 6, 7, 8, 9])
+ residual2, _ = threadsafe2.deferred_status()
+ self.assertEqual(residual2.checkpoint_mark.last_index, 9)
+
+ def test_busy_reader_is_reused_across_bundles(self):
+ # The self-checkpoint parks the reader; the resuming bundle reclaims the
+ # same started reader.
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=5, max_read_time_seconds=1e9)
+ dofn.setup() # creates the cross-bundle reader cache
+ source = UnboundedCountingSource(1000)
+ gen1, _, threadsafe1, _, _ = self._bundle(dofn, source)
+ self.assertEqual([tv.value for tv in gen1], [0, 1, 2, 3, 4])
+ reader1 = source.last_reader
+ self.assertFalse(reader1.closed) # parked, not closed
+ residual1, _ = threadsafe1.deferred_status()
+
+ gen2, _, _, _, _ = self._bundle(
+ dofn, source, checkpoint=residual1.checkpoint_mark)
+ self.assertEqual([tv.value for tv in gen2], [5, 6, 7, 8, 9])
+ # No new reader was created; source.last_reader is unchanged.
+ self.assertIs(source.last_reader, reader1)
+
+ def test_teardown_closes_parked_readers(self):
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=5, max_read_time_seconds=1e9)
+ dofn.setup()
+ source = UnboundedCountingSource(1000)
+ gen, _, _, _, _ = self._bundle(dofn, source)
+ list(gen) # the bundle parks its reader
+ reader = source.last_reader
+ self.assertFalse(reader.closed)
+ dofn.teardown()
+ self.assertTrue(reader.closed)
+
+ def test_eof_exactly_at_cap_resumes_then_finishes(self):
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=5, max_read_time_seconds=1e9)
+ source = UnboundedCountingSource(5) # exactly cap records
+ # Bundle 1 hits the cap on the last record before observing EOF.
+ gen1, t1, threadsafe1, _, _ = self._bundle(dofn, source)
+ self.assertEqual([tv.value for tv in gen1], [0, 1, 2, 3, 4])
+ self.assertTrue(t1.current_restriction().is_done)
+ residual1, _ = threadsafe1.deferred_status()
+ self.assertEqual(residual1.checkpoint_mark.last_index, 4)
+
+ # Bundle 2 resumes at index 5, finds EOF, and finishes with no output.
+ gen2, t2, threadsafe2, _, _ = self._bundle(
+ dofn, source, checkpoint=residual1.checkpoint_mark)
+ self.assertEqual(list(gen2), [])
+ self.assertTrue(t2.current_restriction().is_done)
+ self.assertIsNone(threadsafe2.deferred_status())
+
+ def test_eof_before_cap_finishes_without_residual(self):
+ dofn = _ReadFromUnboundedSourceDoFn(
+ poll_interval=0, max_records_per_bundle=100, max_read_time_seconds=1e9)
+ gen, tracker, threadsafe, _, _ = self._bundle(
+ dofn, UnboundedCountingSource(3))
+
+ self.assertEqual([tv.value for tv in gen], [0, 1, 2])
+ self.assertTrue(tracker.current_restriction().is_done)
+ self.assertIsNone(threadsafe.deferred_status())
+
+ def test_max_watermark_on_final_record_emitted_before_estimator_advances(
+ self):
+ # A reader that returns its final record with a MAX_TIMESTAMP watermark on
+ # the same claim must have that record emitted before the estimator reaches
+ # MAX, so the element is not stranded behind the output watermark.
+ dofn = _ReadFromUnboundedSourceDoFn(poll_interval=0)
+ gen, _, _, _, estimator = self._bundle(dofn, _MaxOnLastSource())
+
+ first = next(gen)
+ self.assertEqual(first.value, 7)
+ # The estimator has not yet been advanced to MAX when the record is
yielded.
+ self.assertNotEqual(estimator.current_watermark(), MAX_TIMESTAMP)
+ # Draining the bundle then advances the estimator to MAX on EOF.
+ self.assertEqual(list(gen), [])
+ self.assertEqual(estimator.current_watermark(), MAX_TIMESTAMP)
+
+
+class WatermarkTest(unittest.TestCase):
+ def test_set_watermark_is_monotonic(self):
+ estimator = ManualWatermarkEstimator(None)
+ _set_watermark_if_greater(estimator, Timestamp(5))
+ self.assertEqual(estimator.current_watermark(), Timestamp(5))
+ # A regression is ignored (would otherwise raise inside set_watermark).
+ _set_watermark_if_greater(estimator, Timestamp(3))
+ self.assertEqual(estimator.current_watermark(), Timestamp(5))
+ _set_watermark_if_greater(estimator, Timestamp(7))
+ self.assertEqual(estimator.current_watermark(), Timestamp(7))
+
+
+class FinalizationTest(unittest.TestCase):
+ def test_finalize_checkpoint_callback_is_at_most_once(self):
+ finalize_log = []
+ finalize_once = _FinalizeCheckpointOnce(
+ _CountingCheckpointMark(1, finalize_log=finalize_log))
+
+ finalize_once()
+ finalize_once()
+
+ self.assertEqual(finalize_log, [1])
+
+ def test_finalize_checkpoint_invoked(self):
+ # Unit-level finalize test (the e2e finalize may run in a worker process);
+ # the hook lives on the primary, independent of the residual's resume
state.
+ finalize_log = []
+ source = UnboundedCountingSource(5, finalize_log=finalize_log)
+ tracker = _new_tracker(source)
+ _claim(tracker) # 0
+ _claim(tracker) # 1
+ primary, _ = tracker.try_split(0)
+ primary.finalization_checkpoint_mark.finalize_checkpoint()
+ self.assertEqual(finalize_log, [1])
+
+
+class EndToEndTest(unittest.TestCase):
+ def test_direct_runner_emits_all_in_order(self):
+ with TestPipeline() as p:
+ out = p | ReadFromUnboundedSource(UnboundedCountingSource(5))
+ self.assertFalse(out.is_bounded)
+ assert_that(out, equal_to([0, 1, 2, 3, 4]))
+
+ def test_eof_lets_event_time_window_fire(self):
+ # On EOF the DoFn advances the watermark estimator to MAX_TIMESTAMP so the
+ # downstream FixedWindow closes and the GroupByKey fires; otherwise the
+ # output would be empty.
+ with TestPipeline() as p:
+ out = (
+ p
+ | ReadFromUnboundedSource(UnboundedCountingSource(5))
+ | beam.WindowInto(FixedWindows(100))
+ | beam.Map(lambda v: ('all', v))
+ | beam.GroupByKey()
+ | beam.MapTuple(lambda _key, values: sorted(values)))
+ assert_that(out, equal_to([[0, 1, 2, 3, 4]]))
+
+ def test_read_dispatches_through_iobase_read(self):
+ # ``beam.io.Read(source)`` must produce the same records as
+ # ``ReadFromUnboundedSource(source)``.
+ with TestPipeline() as p:
+ out = p | beam.io.Read(UnboundedCountingSource(5))
+ self.assertFalse(out.is_bounded)
+ assert_that(out, equal_to([0, 1, 2, 3, 4]))
+
+ def test_splittable_source_reads_all_records_across_splits(self):
+ # A splittable source fans out into even/odd sub-sources during initial
+ # SDF splitting; the union of all sub-source reads is the full sequence.
+ with TestPipeline() as p:
+ out = p | beam.io.Read(UnboundedCountingSource(6, is_splittable=True))
+ assert_that(out, equal_to([0, 1, 2, 3, 4, 5]))
+
+ def test_source_default_output_coder_sets_output_type(self):
+ with TestPipeline() as p:
+ out = p | ReadFromUnboundedSource(_StringCountingSource(2))
+ self.assertEqual(out.element_type, str)
+ assert_that(out, equal_to(['v0', 'v1']))
+
+ def test_small_cap_self_checkpoints_and_resumes_to_eof(self):
+ # A small per-bundle cap forces several self-checkpoint/resume cycles
+ # through the runner before EOF, exercising the cross-bundle reader cache
+ # over real residual encode/decode. All records still arrive in order.
+ with TestPipeline() as p:
+ out = p | ReadFromUnboundedSource(
+ UnboundedCountingSource(20), max_records_per_bundle=5)
+ assert_that(out, equal_to(list(range(20))))
+
+
+class ReadFromUnboundedSourceCoderTest(unittest.TestCase):
+ def test_parameterized_output_coder_does_not_mutate_global_registry(self):
+ try:
+ p = beam.Pipeline()
+ out = p | ReadFromUnboundedSource(_PrefixStringSource(1))
+
+ self.assertNotEqual(out.element_type, str)
+ self.assertEqual(coders.registry.get_coder(str), coders.StrUtf8Coder())
+ self.assertEqual(
+
ReadFromUnboundedSource(_PrefixStringSource(1))._infer_output_coder(),
+ _PrefixStrCoder('prefix:'))
+ finally:
+ coders.registry.register_coder(str, coders.StrUtf8Coder)
+
+
+#
------------------------------------------------------------------------------
+# Reader lifecycle, watermark, and contract regression tests (reader close on
+# every exit path, the NotImplementedError message, finalize idempotency).
+#
------------------------------------------------------------------------------
+
+
+class ReaderCloseTest(unittest.TestCase):
+ """Reader lifecycle: close() must run on every tracker-driven exit path."""
+ def test_tracker_closes_reader_on_eof(self):
+ source = UnboundedCountingSource(0) # immediately exhausted
+ tracker = _new_tracker(source)
+ holder = [None]
+ self.assertFalse(tracker.try_claim(holder))
+ self.assertIsNone(tracker._reader)
+ self.assertTrue(source.last_reader.closed)
+
+ def test_tracker_closes_reader_on_split_without_cache(self):
+ # With no cache injected, a self-checkpoint closes the reader.
+ source = UnboundedCountingSource(5)
+ tracker = _new_tracker(source)
+ _claim(tracker) # creates reader, claims 0
+ reader = source.last_reader
+ self.assertFalse(reader.closed)
+ split = tracker.try_split(0)
+ self.assertIsNotNone(split)
+ self.assertIsNone(tracker._reader)
+ self.assertTrue(reader.closed)
+
+ def test_tracker_parks_reader_on_split_with_cache(self):
+ # With a cache, a self-checkpoint parks the reader for the residual to
+ # reclaim.
+ source = UnboundedCountingSource(5)
+ cache = _ReaderCache()
+ tracker = _new_tracker(source)
+ tracker._reader_cache = cache
+ _claim(tracker)
+ reader = source.last_reader
+ _, residual = tracker.try_split(0)
+ self.assertIsNone(tracker._reader)
+ self.assertFalse(reader.closed)
+ # The residual's key reclaims the same started reader.
+ self.assertEqual(
+ cache.acquire(tracker._cache_key(residual)), (reader, True))
+
+ def test_close_helper_is_idempotent_and_safe_on_empty_tracker(self):
+ tracker = _new_tracker(UnboundedCountingSource(3))
+ # No reader yet -- helper must be a no-op.
+ tracker._close_reader_if_open()
+ _claim(tracker)
+ reader = tracker._reader
+ tracker._close_reader_if_open()
+ self.assertTrue(reader.closed)
+ self.assertIsNone(tracker._reader)
+ # Second call is a no-op (no reader to close).
+ tracker._close_reader_if_open()
+
+ def test_close_helper_swallows_reader_close_errors(self):
+ class _BoomReader(UnboundedReader):
+ @override
+ def start(self):
+ return True
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ return 'x'
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_watermark(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_checkpoint_mark(self):
+ return CheckpointMark()
+
+ @override
+ def close(self):
+ raise RuntimeError('close blew up')
+
+ class _BoomSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _BoomReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+ tracker = _new_tracker(_BoomSource())
+ _claim(tracker)
+ with self.assertLogs(_unbounded_source_module._LOGGER, 'WARNING') as logs:
+ tracker._close_reader_if_open()
+ self.assertTrue(
+ any('Error closing UnboundedReader' in line for line in logs.output))
+ self.assertIsNone(tracker._reader)
+
+
+class ReaderCacheTest(unittest.TestCase):
+ """The cache parks a reader under one key, hands it to the next acquirer,
+ bounds itself by idle time and entry count, and closes on teardown."""
+ def _reader(self):
+ class _FakeReader(UnboundedReader):
+ def __init__(self):
+ self.closed = False
+
+ @override
+ def close(self):
+ self.closed = True
+
+ return _FakeReader()
+
+ def test_park_then_acquire_returns_same_reader_and_started_flag(self):
+ cache = _ReaderCache()
+ reader = self._reader()
+ cache.park('k', reader, True)
+ self.assertEqual(cache.acquire('k'), (reader, True))
+ # Acquire removes the entry so two trackers cannot share one reader.
+ self.assertIsNone(cache.acquire('k'))
+ self.assertFalse(reader.closed)
+
+ def test_acquire_miss_returns_none(self):
+ self.assertIsNone(_ReaderCache().acquire('absent'))
+
+ def test_park_replacing_same_key_closes_displaced_reader(self):
+ # Two parks under one key without an intervening acquire (e.g. a bundle
+ # retry) must not leak the first reader.
+ cache = _ReaderCache()
+ old, new = self._reader(), self._reader()
+ cache.park('k', old, True)
+ cache.park('k', new, True)
+ self.assertTrue(old.closed)
+ self.assertFalse(new.closed)
+ self.assertEqual(cache.acquire('k'), (new, True))
+
+ def test_idle_reader_is_closed_on_next_touch(self):
+ clock = _ManualClock(1000.0)
+ cache = _ReaderCache(idle_seconds=30.0, now=clock)
+ reader = self._reader()
+ cache.park('k', reader, True)
+ clock.now = 1031.0 # past the idle window
+ cache.park('other', self._reader(), False) # triggers idle eviction
+ self.assertTrue(reader.closed)
+ self.assertIsNone(cache.acquire('k'))
+
+ def test_max_size_evicts_and_closes_oldest(self):
+ cache = _ReaderCache(max_size=2)
+ readers = [self._reader() for _ in range(3)]
+ cache.park('a', readers[0], True)
+ cache.park('b', readers[1], True)
+ cache.park('c', readers[2], True) # exceeds the cap, evicts 'a'
+ self.assertTrue(readers[0].closed)
+ self.assertIsNone(cache.acquire('a'))
+ self.assertIsNotNone(cache.acquire('b'))
+ self.assertIsNotNone(cache.acquire('c'))
+
+ def test_close_all_closes_every_parked_reader(self):
+ cache = _ReaderCache()
+ readers = [self._reader() for _ in range(3)]
+ for i, reader in enumerate(readers):
+ cache.park(str(i), reader, True)
+ cache.close_all()
+ self.assertTrue(all(reader.closed for reader in readers))
+ self.assertIsNone(cache.acquire('0'))
+
+ def test_close_all_swallows_reader_close_errors(self):
+ class _BoomReader(UnboundedReader):
+ @override
+ def close(self):
+ raise RuntimeError('close blew up')
+
+ cache = _ReaderCache()
+ cache.park('k', _BoomReader(), True)
+ with self.assertLogs(_unbounded_source_module._LOGGER, 'WARNING') as logs:
+ cache.close_all()
+ self.assertTrue(
+ any('Error closing UnboundedReader' in line for line in logs.output))
+
+
+class TrackerContractRegressionTest(unittest.TestCase):
+ """Tracker contract: source-watermark on the data path, finalize/resume
+ channel separation, and reader close on a reader-method failure."""
+ def test_data_path_holder_carries_source_watermark(self):
+ class _LaggingReader(UnboundedReader):
+ @override
+ def start(self):
+ return True
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ return 'rec'
+
+ @override
+ def get_current_timestamp(self):
+ return Timestamp(1000) # record event time
+
+ @override
+ def get_watermark(self):
+ return Timestamp(990) # source watermark lags 10us behind
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(0)
+
+ class _LaggingSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _LaggingReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+ tracker = _new_tracker(_LaggingSource())
+ claimed, record = _claim(tracker)
+ self.assertTrue(claimed)
+ self.assertIsNot(record, _NO_DATA)
+ value, record_timestamp, source_watermark = record
+ self.assertEqual(value, 'rec')
+ self.assertEqual(record_timestamp, Timestamp(1000))
+ # Critical: watermark slot is the SOURCE watermark, NOT record timestamp.
+ self.assertEqual(source_watermark, Timestamp(990))
+ self.assertNotEqual(source_watermark, record_timestamp)
+
+ def test_split_separates_finalize_and_resume_channels(self):
+ source = UnboundedCountingSource(5)
+ tracker = _new_tracker(source)
+ _claim(tracker) # claim 0 so reader has progress
+ primary, residual = tracker.try_split(0)
+ # Primary carries ONLY the finalize hook -- no resume state.
+ self.assertIsNone(primary.checkpoint_mark)
+ self.assertIsNotNone(primary.finalization_checkpoint_mark)
+ self.assertTrue(primary.is_done)
+ # Residual carries ONLY the resume state -- no finalize hook (a future
+ # bundle that splits THIS residual will produce ITS own finalize mark).
+ self.assertIsNotNone(residual.checkpoint_mark)
+ self.assertIsNone(residual.finalization_checkpoint_mark)
+ self.assertFalse(residual.is_done)
+ # The two marks reference the same underlying checkpoint object.
+ self.assertEqual(
+ primary.finalization_checkpoint_mark.last_index,
+ residual.checkpoint_mark.last_index)
+
+ def test_eof_populates_finalize_and_clears_resume(self):
+ # EOF transition: restriction.checkpoint_mark goes to None (no more
+ # records to resume from), finalization_checkpoint_mark carries the
+ # final commit hook.
+ source = UnboundedCountingSource(0) # immediately exhausted
+ tracker = _new_tracker(source)
+ holder = [None]
+ self.assertFalse(tracker.try_claim(holder))
+ r = tracker.current_restriction()
+ self.assertTrue(r.is_done)
+ self.assertEqual(r.watermark, MAX_TIMESTAMP)
+ self.assertIsNone(r.checkpoint_mark)
+ self.assertIsNotNone(r.finalization_checkpoint_mark)
+
+ def test_tracker_closes_reader_when_advance_raises(self):
+ # try_claim closes the reader before re-raising a reader-method failure, so
+ # the DoFn's finally need not traverse the SDF chain for these.
+ class _BoomReader(UnboundedReader):
+ def __init__(self):
+ self.closed = False
+
+ @override
+ def start(self):
+ return True
+
+ @override
+ def advance(self):
+ raise RuntimeError('advance boom')
+
+ @override
+ def get_current(self):
+ return 'first'
+
+ @override
+ def get_current_timestamp(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_watermark(self):
+ return _EVENT_TIME_BASE
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(0)
+
+ @override
+ def close(self):
+ self.closed = True
+
+ class _BoomSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _BoomReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+ src = _BoomSource()
+ tracker = _new_tracker(src)
+ # First claim succeeds (start returns True).
+ self.assertTrue(tracker.try_claim([None]))
+ reader_after_first = tracker._reader
+ self.assertIsNotNone(reader_after_first)
+ # The second claim's advance() raises; the tracker must close the reader
+ # before propagating.
+ with self.assertRaises(RuntimeError):
+ tracker.try_claim([None])
+ self.assertTrue(reader_after_first.closed)
+ self.assertIsNone(tracker._reader)
+
+ def test_tracker_closes_reader_when_get_watermark_raises(self):
+ # Reader method failures other than advance() also trigger close.
+ class _WatermarkBoomReader(UnboundedReader):
+ def __init__(self):
+ self.closed = False
+
+ @override
+ def start(self):
+ return False # no data -> drops into get_watermark path
+
+ @override
+ def advance(self):
+ return False
+
+ @override
+ def get_current(self):
+ raise AssertionError
+
+ @override
+ def get_current_timestamp(self):
+ raise AssertionError
+
+ @override
+ def get_watermark(self):
+ raise RuntimeError('watermark boom')
+
+ @override
+ def get_checkpoint_mark(self):
+ return _CountingCheckpointMark(0)
+
+ @override
+ def close(self):
+ self.closed = True
+
+ class _WatermarkBoomSource(UnboundedSource):
+ @override
+ def split(self, desired_num_splits, options=None):
+ return [self]
+
+ @override
+ def create_reader(self, options, checkpoint_mark):
+ return _WatermarkBoomReader()
+
+ @override
+ def get_checkpoint_mark_coder(self):
+ return coders.PickleCoder()
+
+ src = _WatermarkBoomSource()
+ tracker = _new_tracker(src)
+ with self.assertRaises(RuntimeError):
+ tracker.try_claim([None])
+ self.assertIsNone(tracker._reader)
+
+
+class UnboundedSourceContractTest(unittest.TestCase):
+ def test_get_checkpoint_mark_coder_default_names_subclass(self):
+ class MySource(UnboundedSource):
+ pass
+
+ with self.assertRaises(NotImplementedError) as cm:
+ MySource().get_checkpoint_mark_coder()
+ self.assertIn('MySource', str(cm.exception))
+
+
+class ReadFromUnboundedSourceValidationTest(unittest.TestCase):
+ def test_non_source_argument_raises(self):
+ with self.assertRaises(TypeError):
+ ReadFromUnboundedSource('not-a-source') # type: ignore[arg-type]
+
+ def test_invalid_caps_raise(self):
+ source = UnboundedCountingSource(1)
+ with self.assertRaises(ValueError):
+ ReadFromUnboundedSource(source, max_records_per_bundle=0)
+ with self.assertRaises(ValueError):
+ ReadFromUnboundedSource(source, max_read_time_seconds=0)
+ with self.assertRaises(ValueError):
+ ReadFromUnboundedSource(source, poll_interval=-1)
+
+
+if __name__ == '__main__':
+ logging.getLogger().setLevel(logging.INFO)
+ unittest.main()