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 9c3f51e56e0 [Python] Add Watch transform with growth_of polling SDF 
(#39023)
9c3f51e56e0 is described below

commit 9c3f51e56e09f9b5dd7e1b30c5f78dc55d51142b
Author: Elia Liu <[email protected]>
AuthorDate: Fri Jul 24 04:17:55 2026 +1000

    [Python] Add Watch transform with growth_of polling SDF (#39023)
    
    * [Python] Add Watch transform with growth_of polling SDF
---
 sdks/python/apache_beam/io/watch.py      | 730 +++++++++++++++++++++++++++++++
 sdks/python/apache_beam/io/watch_test.py | 456 +++++++++++++++++++
 2 files changed, 1186 insertions(+)

diff --git a/sdks/python/apache_beam/io/watch.py 
b/sdks/python/apache_beam/io/watch.py
new file mode 100644
index 00000000000..2fcee7a8080
--- /dev/null
+++ b/sdks/python/apache_beam/io/watch.py
@@ -0,0 +1,730 @@
+#
+# 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 ``Watch`` transform for the Python SDK.
+
+``Watch`` continuously watches a growing set of outputs for each input element,
+calling a user poll function on an interval until a per-input termination
+condition fires. It is the engine behind periodic file-discovery and any
+periodic polling source.
+
+For every input element the transform runs an independent loop::
+
+    poll -> keep never-seen-before outputs -> emit them (timestamped) ->
+    update watermark -> check termination -> wait(poll_interval) -> poll -> ...
+
+The output is an unbounded ``PCollection`` of ``(input, output)`` pairs. Each
+output carries the event time the poll function first reported it. Dedup
+hashes each output's key: the output itself by default, or
+``output_key_fn(output)`` when one is given. The key coder is inferred when
+not passed explicitly and converted to its deterministic form, so equal keys
+hash equally across workers and restarts.
+
+Example::
+
+    from apache_beam.io.watch import Watch, PollResult, after_total_of
+    from apache_beam.transforms.window import TimestampedValue
+    from apache_beam.utils.timestamp import Duration, Timestamp
+
+    def poll(prefix) -> PollResult[str]:
+      now = Timestamp.now()
+      outputs = [TimestampedValue(prefix + str(i), now) for i in range(3)]
+      return PollResult.complete(outputs)
+
+    watched = inputs | Watch(
+        poll,
+        poll_interval=Duration(seconds=5),
+        termination=after_total_of(60))
+
+This API is experimental and may change in backwards-incompatible ways.
+"""
+
+import collections
+import dataclasses
+import hashlib
+import inspect
+import time
+import typing
+from typing import Any
+from typing import Callable
+from typing import Generic
+from typing import Iterable
+from typing import Optional
+from typing import Tuple
+from typing import TypeVar
+
+from apache_beam import coders
+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.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 Duration
+from apache_beam.utils.timestamp import Timestamp
+
+__all__ = [
+    'Watch',
+    'PollResult',
+    'PollFn',
+    'TerminationCondition',
+    'never',
+    'after_total_of',
+]
+
+_HASH_DIGEST_SIZE = 16  # 128-bit digest width.
+
+OutputT = TypeVar('OutputT')
+
+# 
------------------------------------------------------------------------------
+# Public API.
+# 
------------------------------------------------------------------------------
+
+
[email protected](frozen=True)
+class PollResult(Generic[OutputT]):
+  """Outputs produced by one poll, plus an optional explicit watermark.
+
+  ``watermark`` of ``None`` lets the transform infer the watermark from the
+  earliest new output. A watermark of ``MAX_TIMESTAMP`` (set by
+  :meth:`complete`) marks the input finished, so polling stops.
+
+  The ``OutputT`` type parameter can annotate a poll function's return type,
+  as in ``-> PollResult[str]``; the transform infers the output coder from it.
+  """
+  outputs: Tuple[TimestampedValue, ...]
+  watermark: Optional[Timestamp] = None
+
+  @property
+  def is_complete(self) -> bool:
+    return self.watermark == MAX_TIMESTAMP
+
+  @staticmethod
+  def _normalize(outputs, timestamp) -> Tuple[TimestampedValue, ...]:
+    if timestamp is None:
+      default_ts = Timestamp.now()
+    else:
+      default_ts = Timestamp.of(timestamp)
+    normalized = []
+    for output in outputs:
+      if isinstance(output, TimestampedValue):
+        normalized.append(output)
+      else:
+        normalized.append(TimestampedValue(output, default_ts))
+    return tuple(normalized)
+
+  @staticmethod
+  def incomplete(outputs: Iterable, timestamp=None) -> 'PollResult':
+    """Reports outputs and expects more; the transform infers the watermark.
+
+    A raw (non-:class:`TimestampedValue`) output is stamped with ``timestamp``
+    when given, else with the current processing time.
+    """
+    return PollResult(PollResult._normalize(outputs, timestamp), 
watermark=None)
+
+  @staticmethod
+  def complete(outputs: Iterable, timestamp=None) -> 'PollResult':
+    """Reports the final outputs for an input, after which polling stops.
+
+    A raw (non-:class:`TimestampedValue`) output is stamped with ``timestamp``
+    when given, else with the current processing time.
+    """
+    return PollResult(
+        PollResult._normalize(outputs, timestamp), watermark=MAX_TIMESTAMP)
+
+  def with_watermark(self, watermark) -> 'PollResult':
+    return dataclasses.replace(self, watermark=Timestamp.of(watermark))
+
+
+class PollFn(object):
+  """Optional base for a poll function ``input -> PollResult``.
+
+  Any callable with that signature works; subclass only to attach an output
+  coder hint via :meth:`default_output_coder`::
+
+      from apache_beam import coders
+
+      class ListFiles(PollFn):
+        def __call__(self, prefix):
+          return PollResult.incomplete(list_files(prefix))
+
+        def default_output_coder(self):
+          return coders.StrUtf8Coder()
+
+  A plain function can instead annotate its return type as ``PollResult[V]``
+  and have the output coder inferred from ``V``.
+  """
+  def __call__(self, element: Any) -> PollResult:
+    raise NotImplementedError
+
+  def default_output_coder(self) -> Optional[Coder]:
+    return None
+
+
+class TerminationCondition(object):
+  """Per-input stop policy with immutable, encodable state.
+
+  Hooks follow the lifecycle of one input's polling loop. ``state`` flows from
+  :meth:`for_new_input` through the per-round hooks and is serialized with
+  :meth:`state_coder`.
+  """
+  def for_new_input(self, now: Timestamp, element: Any) -> Any:
+    raise NotImplementedError
+
+  def on_seen_new_output(self, now: Timestamp, state: Any) -> Any:
+    return state
+
+  def on_poll_complete(self, state: Any) -> Any:
+    return state
+
+  def can_stop_polling(self, now: Timestamp, state: Any) -> bool:
+    raise NotImplementedError
+
+  def state_coder(self) -> Coder:
+    raise NotImplementedError
+
+
+class _Never(TerminationCondition):
+  """Polls until the poll function returns :meth:`PollResult.complete`."""
+  def for_new_input(self, now, element):
+    return 0
+
+  def can_stop_polling(self, now, state):
+    return False
+
+  def state_coder(self):
+    return coders.VarIntCoder()
+
+
+class _AfterTotalOf(TerminationCondition):
+  """Stops once the wall-clock time since the input was first seen exceeds a
+  fixed duration."""
+  def __init__(self, duration: Duration):
+    self._duration_micros = duration.micros
+
+  def for_new_input(self, now, element):
+    return (now, self._duration_micros)
+
+  def can_stop_polling(self, now, state):
+    start, duration_micros = state
+    return (now - start).micros > duration_micros
+
+  def state_coder(self):
+    return TupleCoder([TimestampCoder(), coders.VarIntCoder()])
+
+
+def never() -> TerminationCondition:
+  """Polls until :meth:`PollResult.complete`."""
+  return _Never()
+
+
+def after_total_of(duration) -> TerminationCondition:
+  """Stops polling an input after ``duration`` (a :class:`Duration` or seconds)
+  has elapsed since it was first seen."""
+  return _AfterTotalOf(_as_duration(duration))
+
+
+# 
------------------------------------------------------------------------------
+# Restriction state.
+# 
------------------------------------------------------------------------------
+
+
+class _GrowthState:
+  """Base for the two restriction variants a Watch input can hold."""
+
+
[email protected](frozen=True)
+class _PollingGrowthState(_GrowthState):
+  """Keep-polling state: emitted-output hashes, watermark, termination state.
+
+  ``completed`` maps a 16-byte output-key hash to the event time it was first
+  seen. It is insertion-ordered and treated as immutable; a new mapping is
+  built for each residual.
+  """
+  completed: 'collections.OrderedDict[bytes, Timestamp]'
+  poll_watermark: Optional[Timestamp]
+  termination_state: Any
+
+
[email protected](frozen=True)
+class _NonPollingGrowthState(_GrowthState):
+  """Replay-then-stop state: the outputs already emitted this round.
+
+  Produced as the checkpoint primary so a bundle retry re-emits exactly those
+  outputs.
+  """
+  pending: PollResult
+
+
+# Primary used when a checkpoint arrives before any claim; replays nothing.
+_EMPTY_STATE = _NonPollingGrowthState(PollResult((), None))
+
+# 
------------------------------------------------------------------------------
+# Coders.
+# 
------------------------------------------------------------------------------
+
+
+class _TimestampedValueCoder(Coder):
+  """Coder for :class:`TimestampedValue`.
+
+  ``TimestampedValue`` is normally unwrapped into a ``WindowedValue`` on the
+  wire, so the SDK ships no standalone coder for it. Watch keeps it inside the
+  restriction state, so this encodes the ``(value, timestamp)`` pair with a
+  :class:`TupleCoder` and rebuilds the ``TimestampedValue`` on decode.
+  """
+  def __init__(self, value_coder: Coder):
+    self._tuple_coder = TupleCoder([value_coder, TimestampCoder()])
+
+  def encode(self, value: TimestampedValue) -> bytes:
+    return self._tuple_coder.encode((value.value, value.timestamp))
+
+  def decode(self, encoded: bytes) -> TimestampedValue:
+    value, timestamp = self._tuple_coder.decode(encoded)
+    return TimestampedValue(value, timestamp)
+
+  def is_deterministic(self) -> bool:
+    return self._tuple_coder.is_deterministic()
+
+
+class _GrowthStateCoder(Coder):
+  """Encodes a :class:`_PollingGrowthState` or :class:`_NonPollingGrowthState`.
+
+  A ``(tag, payload)`` envelope selects the variant; the payload is a
+  variant-specific :class:`TupleCoder`. ``completed`` is encoded as an ordered
+  list of ``(hash, timestamp)`` pairs so insertion order survives a round trip.
+  This format is internal to the Python SDK.
+  """
+  def __init__(self, output_coder: Coder, termination: TerminationCondition):
+    nullable_ts = NullableCoder(TimestampCoder())
+    self._envelope_coder = TupleCoder(
+        [coders.VarIntCoder(), coders.BytesCoder()])
+    self._polling_coder = TupleCoder([
+        termination.state_coder(),
+        nullable_ts,
+        coders.ListCoder(TupleCoder([coders.BytesCoder(), TimestampCoder()])),
+    ])
+    self._non_polling_coder = TupleCoder([
+        nullable_ts,
+        coders.ListCoder(_TimestampedValueCoder(output_coder)),
+    ])
+
+  def encode(self, state: _GrowthState) -> bytes:
+    if isinstance(state, _PollingGrowthState):
+      payload = self._polling_coder.encode((
+          state.termination_state,
+          state.poll_watermark,
+          list(state.completed.items())))
+      return self._envelope_coder.encode((0, payload))
+    payload = self._non_polling_coder.encode(
+        (state.pending.watermark, list(state.pending.outputs)))
+    return self._envelope_coder.encode((1, payload))
+
+  def decode(self, encoded: bytes) -> _GrowthState:
+    tag, payload = self._envelope_coder.decode(encoded)
+    if tag == 0:
+      termination_state, poll_watermark, items = self._polling_coder.decode(
+          payload)
+      return _PollingGrowthState(
+          collections.OrderedDict(items), poll_watermark, termination_state)
+    if tag == 1:
+      watermark, outputs = self._non_polling_coder.decode(payload)
+      return _NonPollingGrowthState(PollResult(tuple(outputs), watermark))
+    raise ValueError('unknown Watch growth state tag: %r' % (tag, ))
+
+  def is_deterministic(self) -> bool:
+    return False
+
+
+# 
------------------------------------------------------------------------------
+# Restriction tracker.
+# 
------------------------------------------------------------------------------
+
+
+def _identity(value: Any) -> Any:
+  return value
+
+
+def _hash_output(key_coder: Coder, value: Any) -> bytes:
+  return hashlib.blake2b(
+      key_coder.encode(value), digest_size=_HASH_DIGEST_SIZE).digest()
+
+
+def _max_watermark(left: Optional[Timestamp],
+                   right: Optional[Timestamp]) -> Optional[Timestamp]:
+  if left is None:
+    return right
+  if right is None:
+    return left
+  return max(left, right)
+
+
+def _never_seen_before(
+    restriction: _PollingGrowthState,
+    result: PollResult,
+    key_fn: Callable[[Any], Any],
+    key_coder: Coder) -> PollResult:
+  """Filters a poll result down to outputs whose key was never seen before.
+
+  Dedup hashes ``key_fn(output.value)`` against the restriction's completed
+  set, also dropping in-round duplicates. Outputs are sorted by timestamp so
+  the earliest one can serve as the inferred watermark.
+  """
+  new_outputs = []
+  seen_this_round = set()
+  for output in result.outputs:
+    key_hash = _hash_output(key_coder, key_fn(output.value))
+    if key_hash in restriction.completed or key_hash in seen_this_round:
+      continue
+    seen_this_round.add(key_hash)
+    new_outputs.append(output)
+  new_outputs.sort(key=lambda output: output.timestamp)
+  return dataclasses.replace(result, outputs=tuple(new_outputs))
+
+
+class _GrowthRestrictionTracker(iobase.RestrictionTracker):
+  """Tracks one input's polling restriction over claimed poll rounds.
+
+  The claimed position is one poll round: a ``(PollResult, termination_state)``
+  pair whose ``PollResult`` holds only never-seen-before outputs. ``process()``
+  polls and dedups before claiming, so a slow poll never holds the tracker
+  lock; the tracker validates each claim against the restriction and derives
+  the checkpoint split from the claimed round in :meth:`try_split`.
+  """
+  def __init__(
+      self,
+      restriction: _GrowthState,
+      key_fn: Callable[[Any], Any],
+      key_coder: Coder):
+    self._restriction = restriction
+    self._key_fn = key_fn
+    self._key_coder = key_coder
+    self._claimed_result = None  # type: Optional[PollResult]
+    self._claimed_termination_state = None  # type: Any
+    self._claimed_hashes = None  # type: Optional[collections.OrderedDict]
+    self._should_stop = False
+
+  def _hash(self, value: Any) -> bytes:
+    return _hash_output(self._key_coder, self._key_fn(value))
+
+  def current_restriction(self) -> _GrowthState:
+    return self._restriction
+
+  def try_claim(self, position: Tuple[PollResult, Any]) -> bool:
+    """Claims one poll round; at most one claim succeeds per ``process()``.
+
+    The claim is rejected after a checkpoint already stopped this invocation,
+    when a claimed output key was already completed, or when a replay does not
+    match the pending outputs exactly.
+    """
+    if self._should_stop:
+      return False
+    result, termination_state = position
+    claimed_hashes = collections.OrderedDict()
+    for output in result.outputs:
+      claimed_hashes[self._hash(output.value)] = output.timestamp
+    if isinstance(self._restriction, _PollingGrowthState):
+      if any(key_hash in self._restriction.completed
+             for key_hash in claimed_hashes):
+        return False
+    else:
+      expected = set(
+          self._hash(output.value)
+          for output in self._restriction.pending.outputs)
+      if expected != set(claimed_hashes):
+        return False
+    self._should_stop = True
+    self._claimed_result = result
+    self._claimed_termination_state = termination_state
+    self._claimed_hashes = claimed_hashes
+    return True
+
+  def try_split(self, fraction_of_remainder):
+    # Every split checkpoints at the claimed poll round; splitting a round
+    # further is not supported.
+    if self._claimed_result is None:
+      # No claim happened this invocation: the residual is all the work and
+      # the primary replays nothing.
+      residual = self._restriction
+      self._restriction = _EMPTY_STATE
+    elif isinstance(self._restriction, _NonPollingGrowthState):
+      # The claimed replay was the entire restriction, so nothing remains.
+      residual = _EMPTY_STATE
+    else:
+      # The primary becomes a replay of the claimed round; the residual
+      # resumes polling with the claimed keys marked completed.
+      merged = collections.OrderedDict(self._restriction.completed)
+      merged.update(self._claimed_hashes)
+      residual = _PollingGrowthState(
+          merged,
+          _max_watermark(
+              self._restriction.poll_watermark, 
self._claimed_result.watermark),
+          self._claimed_termination_state)
+      self._restriction = _NonPollingGrowthState(self._claimed_result)
+    self._should_stop = True
+    return self._restriction, residual
+
+  def check_done(self) -> bool:
+    # Called after every process(); the single claim or a split sets the flag.
+    if self._should_stop:
+      return True
+    raise ValueError(
+        'Watch restriction was neither claimed nor checkpointed: %r' %
+        (self._restriction, ))
+
+  def current_progress(self) -> 'iobase.RestrictionProgress':
+    if self._should_stop:
+      return iobase.RestrictionProgress(completed=1.0, remaining=0.0)
+    return iobase.RestrictionProgress(completed=0.0, remaining=1.0)
+
+  def is_bounded(self) -> bool:
+    # A polling restriction is unbounded; a replay-then-stop one is bounded.
+    return isinstance(self._restriction, _NonPollingGrowthState)
+
+
+# 
------------------------------------------------------------------------------
+# Splittable DoFn (its own restriction provider).
+# 
------------------------------------------------------------------------------
+
+
+class _WatchGrowthDoFn(core.DoFn, core.RestrictionProvider):
+  """Polling SDF that emits ``(input, output)`` pairs.
+
+  The DoFn is its own ``RestrictionProvider``: ``RestrictionParam()`` with no
+  argument resolves the provider to the DoFn instance, so the provider methods
+  read the transform-level spec (poll function, coders, termination) off
+  ``self``. Provider methods run on a separately deserialized copy and before
+  ``setup()``, so the spec is immutable state set in ``__init__``.
+  """
+  def __init__(
+      self,
+      poll_fn: Callable[[Any], PollResult],
+      termination: TerminationCondition,
+      poll_interval: Duration,
+      output_coder: Coder,
+      key_fn: Callable[[Any], Any],
+      key_coder: Coder,
+      now_fn: Optional[Callable[[], float]] = None):
+    self._poll_fn = poll_fn
+    self._termination = termination
+    self._poll_interval = poll_interval
+    self._output_coder = output_coder
+    self._key_fn = key_fn
+    self._key_coder = key_coder
+    self._now = now_fn or time.time
+    self._restriction_coder = _GrowthStateCoder(output_coder, termination)
+
+  def initial_restriction(self, element) -> _PollingGrowthState:
+    now = Timestamp.of(self._now())
+    return _PollingGrowthState(
+        collections.OrderedDict(),
+        None,
+        self._termination.for_new_input(now, element))
+
+  def create_tracker(self, restriction) -> _GrowthRestrictionTracker:
+    return _GrowthRestrictionTracker(restriction, self._key_fn, 
self._key_coder)
+
+  def restriction_coder(self) -> Coder:
+    return self._restriction_coder
+
+  def restriction_size(self, element, restriction) -> int:
+    return 1
+
+  @core.DoFn.unbounded_per_element()
+  def process(
+      self,
+      element,
+      timestamp=core.DoFn.TimestampParam,
+      tracker=core.DoFn.RestrictionParam(),
+      watermark_estimator=core.DoFn.WatermarkEstimatorParam(
+          ManualWatermarkEstimator.default_provider())):
+    assert isinstance(tracker, sdf_utils.RestrictionTrackerView)
+    # Java seeds the manual estimator with the element timestamp; the Python
+    # default provider starts at None, which a runner reads as MIN_TIMESTAMP
+    # and would pin the stage's output watermark until the first output.
+    if watermark_estimator.current_watermark() is None:
+      watermark_estimator.set_watermark(timestamp)
+    restriction = tracker.current_restriction()
+    if isinstance(restriction, _NonPollingGrowthState):
+      # Replay the outputs already emitted this round, then stop. No poll.
+      if not tracker.try_claim((restriction.pending, None)):
+        return
+      for output in restriction.pending.outputs:
+        yield TimestampedValue((element, output.value), output.timestamp)
+      return
+    # Poll before claiming so a slow poll never holds the tracker lock, which
+    # would block runner progress checks and checkpoints.
+    result = self._poll_fn(element)
+    # Read the clock after the poll so a slow poll counts against termination.
+    now = Timestamp.of(self._now())
+    new_results = _never_seen_before(
+        restriction, result, self._key_fn, self._key_coder)
+    termination_state = restriction.termination_state
+    if new_results.outputs:
+      termination_state = self._termination.on_seen_new_output(
+          now, termination_state)
+    termination_state = self._termination.on_poll_complete(termination_state)
+    if not tracker.try_claim((new_results, termination_state)):
+      # A checkpoint already stopped this invocation; emit nothing.
+      return
+    for output in new_results.outputs:
+      yield TimestampedValue((element, output.value), output.timestamp)
+    if new_results.watermark is not None:
+      watermark = new_results.watermark
+    elif new_results.outputs:
+      # Outputs are timestamp-sorted, so the first one is the earliest.
+      watermark = new_results.outputs[0].timestamp
+    else:
+      watermark = None
+    if self._termination.can_stop_polling(now, termination_state):
+      return
+    if watermark is not None and watermark >= MAX_TIMESTAMP:
+      # No more output is possible (PollResult.complete), so polling stops.
+      return
+    if watermark is not None:
+      _set_watermark_if_greater(watermark_estimator, watermark)
+    tracker.defer_remainder(self._poll_interval)
+
+
+def _set_watermark_if_greater(watermark_estimator, new_watermark) -> None:
+  # set_watermark raises on regression, so only ever advance the watermark.
+  current = watermark_estimator.current_watermark()
+  if current is None or new_watermark > current:
+    watermark_estimator.set_watermark(new_watermark)
+
+
+# 
------------------------------------------------------------------------------
+# Public PTransform.
+# 
------------------------------------------------------------------------------
+
+
+def _return_type(fn) -> Any:
+  """The return type annotation of ``fn`` or its ``__call__``, else ``Any``."""
+  target = fn if inspect.isroutine(fn) else getattr(type(fn), '__call__', None)
+  if target is None:
+    return Any
+  try:
+    hints = typing.get_type_hints(target)
+  except (NameError, TypeError):
+    return Any
+  return hints.get('return', Any)
+
+
+def _poll_output_type(poll_fn) -> Any:
+  """The ``V`` of a ``PollResult[V]`` return annotation on ``poll_fn``.
+
+  This mirrors the Java SDK, which infers the output coder from the
+  ``PollFn``'s ``OutputT`` type parameter. Returns ``Any`` when ``poll_fn``
+  carries no such annotation.
+  """
+  hint = _return_type(poll_fn)
+  if typing.get_origin(hint) is PollResult:
+    args = typing.get_args(hint)
+    if len(args) == 1:
+      return args[0]
+  return Any
+
+
+class Watch(PTransform):
+  """Watches a growing set of outputs per input via a periodic poll function.
+
+  The output is an unbounded ``PCollection`` of ``(input, output)`` pairs.
+
+  Args:
+    poll_fn: callable ``input -> PollResult``, invoked once per poll round.
+    poll_interval: delay between two poll rounds for one input, as a
+      :class:`Duration` or in seconds.
+    termination: per-input stop policy; defaults to :func:`never`.
+    output_coder: coder for the poll outputs, used to keep them in the
+      restriction state. Inferred when omitted: from a :class:`PollFn`'s
+      :meth:`~PollFn.default_output_coder`, else from the registered coder for
+      the ``V`` of a ``PollResult[V]`` return annotation on ``poll_fn``.
+    output_key_fn: derives the dedup key from an output; an output is emitted
+      only when its key was never seen before. Defaults to the output itself.
+    output_key_coder: coder whose encoding of the key is hashed for dedup;
+      inferred like ``output_coder`` when omitted. It is converted with
+      ``as_deterministic_coder`` so equal keys always hash equally; a coder
+      with no deterministic form is rejected.
+    now_fn: clock used for termination decisions; tests can inject one.
+  """
+  def __init__(
+      self,
+      poll_fn: Callable[[Any], PollResult],
+      poll_interval,
+      termination: Optional[TerminationCondition] = None,
+      output_coder: Optional[Coder] = None,
+      output_key_fn: Optional[Callable[[Any], Any]] = None,
+      output_key_coder: Optional[Coder] = None,
+      now_fn: Optional[Callable[[], float]] = None):
+    super().__init__()
+    if poll_interval is None:
+      raise ValueError('Watch requires a poll_interval')
+    self._poll_fn = poll_fn
+    self._poll_interval = _as_duration(poll_interval)
+    self._termination = termination or never()
+    self._output_coder = output_coder
+    self._output_key_fn = output_key_fn
+    self._output_key_coder = output_key_coder
+    self._now = now_fn
+
+  def expand(self, pcoll):
+    output_coder = self._output_coder
+    if output_coder is None and isinstance(self._poll_fn, PollFn):
+      output_coder = self._poll_fn.default_output_coder()
+    if output_coder is None:
+      output_coder = 
coders.registry.get_coder(_poll_output_type(self._poll_fn))
+    if self._output_key_fn is None:
+      # The output is its own dedup key, so the key coder is the output coder.
+      key_fn = _identity
+      key_coder = self._output_key_coder or output_coder
+    else:
+      key_fn = self._output_key_fn
+      key_coder = self._output_key_coder or coders.registry.get_coder(
+          _return_type(self._output_key_fn))
+    # Dedup hashes the encoded key, so equal keys must encode equally; use the
+    # coder's deterministic form and reject coders that have none.
+    key_coder = key_coder.as_deterministic_coder(
+        self.label,
+        'Watch dedups by hashing the encoded output key, so the key coder '
+        'must be deterministic. %s has no deterministic form; pass a '
+        'deterministic output_key_coder (or output_coder).' %
+        type(key_coder).__name__)
+    # Type the (input, output) pairs from the input type and the resolved
+    # coder's type, so downstream transforms are typed and coder inference does
+    # not fall back to pickling.
+    input_type = pcoll.element_type or Any
+    try:
+      value_type = output_coder.to_type_hint()
+    except NotImplementedError:
+      value_type = Any
+    return pcoll | core.ParDo(
+        _WatchGrowthDoFn(
+            self._poll_fn,
+            self._termination,
+            self._poll_interval,
+            output_coder,
+            key_fn,
+            key_coder,
+            self._now)).with_output_types(Tuple[input_type, value_type])
+
+
+def _as_duration(value) -> Duration:
+  return value if isinstance(value, Duration) else Duration(value)
diff --git a/sdks/python/apache_beam/io/watch_test.py 
b/sdks/python/apache_beam/io/watch_test.py
new file mode 100644
index 00000000000..8c1f6571da6
--- /dev/null
+++ b/sdks/python/apache_beam/io/watch_test.py
@@ -0,0 +1,456 @@
+#
+# 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 the Watch transform."""
+
+import collections
+import unittest
+
+import apache_beam as beam
+from apache_beam.coders.coders import Coder
+from apache_beam.coders.coders import StrUtf8Coder
+from apache_beam.io.watch import PollFn
+from apache_beam.io.watch import PollResult
+from apache_beam.io.watch import Watch
+from apache_beam.io.watch import _GrowthRestrictionTracker
+from apache_beam.io.watch import _GrowthStateCoder
+from apache_beam.io.watch import _never_seen_before
+from apache_beam.io.watch import _NonPollingGrowthState
+from apache_beam.io.watch import _PollingGrowthState
+from apache_beam.io.watch import _WatchGrowthDoFn
+from apache_beam.io.watch import after_total_of
+from apache_beam.io.watch import never
+from apache_beam.io.watermark_estimators import ManualWatermarkEstimator
+from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.runners.sdf_utils import RestrictionTrackerView
+from apache_beam.runners.sdf_utils import ThreadsafeRestrictionTracker
+from apache_beam.runners.sdf_utils import ThreadsafeWatermarkEstimator
+from apache_beam.testing.test_pipeline import TestPipeline
+from apache_beam.testing.util import TestWindowedValue
+from apache_beam.testing.util import assert_that
+from apache_beam.testing.util import equal_to
+from apache_beam.transforms.window import FixedWindows
+from apache_beam.transforms.window import GlobalWindow
+from apache_beam.transforms.window import TimestampedValue
+from apache_beam.typehints import typehints
+from apache_beam.utils.timestamp import MAX_TIMESTAMP
+from apache_beam.utils.timestamp import Duration
+from apache_beam.utils.timestamp import Timestamp
+
+
+def _ts(value, timestamp):
+  return TimestampedValue(value, Timestamp(timestamp))
+
+
+def _identity(output):
+  return output
+
+
+def _new_results(restriction, result, key_fn=None):
+  return _never_seen_before(
+      restriction, result, key_fn or _identity, StrUtf8Coder())
+
+
+def _tracker(restriction):
+  return _GrowthRestrictionTracker(restriction, _identity, StrUtf8Coder())
+
+
+def _initial_polling(termination=None, now=Timestamp(0)):
+  termination = termination or never()
+  return _PollingGrowthState(
+      collections.OrderedDict(), None, termination.for_new_input(now, 'input'))
+
+
+class GrowthStateCoderTest(unittest.TestCase):
+  def test_polling_round_trip_preserves_resume_state(self):
+    termination = after_total_of(Duration(30))
+    coder = _GrowthStateCoder(StrUtf8Coder(), termination)
+    completed = collections.OrderedDict([
+        (b'a' * 16, Timestamp(1)),
+        (b'b' * 16, Timestamp(2)),
+        (b'c' * 16, Timestamp(3)),
+    ])
+    termination_state = termination.for_new_input(Timestamp(7), 'input')
+    state = _PollingGrowthState(completed, Timestamp(5), termination_state)
+    decoded = coder.decode(coder.encode(state))
+    self.assertEqual(list(completed.items()), list(decoded.completed.items()))
+    self.assertEqual(Timestamp(5), decoded.poll_watermark)
+    self.assertEqual(termination_state, decoded.termination_state)
+
+  def test_non_polling_round_trip_preserves_pending_outputs(self):
+    coder = _GrowthStateCoder(StrUtf8Coder(), never())
+    pending = PollResult((_ts('a', 1), _ts('b', 2)), MAX_TIMESTAMP)
+    state = _NonPollingGrowthState(pending)
+    decoded = coder.decode(coder.encode(state))
+    self.assertEqual(MAX_TIMESTAMP, decoded.pending.watermark)
+    self.assertEqual([('a', Timestamp(1)), ('b', Timestamp(2))],
+                     [(o.value, o.timestamp) for o in decoded.pending.outputs])
+
+
+class NeverSeenBeforeTest(unittest.TestCase):
+  def test_dedups_and_sorts_by_timestamp(self):
+    result = PollResult.incomplete([_ts('b', 2), _ts('a', 1), _ts('a', 1)])
+    new_results = _new_results(_initial_polling(), result)
+    self.assertEqual(['a', 'b'], [o.value for o in new_results.outputs])
+
+  def test_dedups_against_completed_keys(self):
+    state = _initial_polling()
+    first = _new_results(
+        state, PollResult.incomplete([_ts('a', 1), _ts('b', 2)]))
+    tracker = _tracker(state)
+    self.assertTrue(tracker.try_claim((first, 0)))
+    _, residual = tracker.try_split(0)
+    second = _new_results(
+        residual, PollResult.incomplete([_ts('a', 1), _ts('c', 3)]))
+    self.assertEqual(['c'], [o.value for o in second.outputs])
+
+  def test_output_key_dedups_by_derived_key(self):
+    result = PollResult.incomplete([_ts('a1', 1), _ts('a2', 2), _ts('b1', 3)])
+    # The key is the first character, so 'a1' and 'a2' collapse to one output.
+    new_results = _new_results(
+        _initial_polling(), result, key_fn=lambda output: output[0])
+    self.assertEqual(['a1', 'b1'], [o.value for o in new_results.outputs])
+
+  def test_preserves_explicit_watermark(self):
+    result = PollResult.incomplete([_ts('c', 3)]).with_watermark(5)
+    new_results = _new_results(_initial_polling(), result)
+    self.assertEqual(Timestamp(5), new_results.watermark)
+
+
+class GrowthTrackerTest(unittest.TestCase):
+  def test_claim_then_split_builds_replay_primary_and_merged_residual(self):
+    state = _initial_polling()
+    new_results = _new_results(
+        state, PollResult.incomplete([_ts('a', 1), _ts('b', 2)]))
+    tracker = _tracker(state)
+    self.assertFalse(tracker.is_bounded())
+    self.assertTrue(tracker.try_claim((new_results, 0)))
+    primary, residual = tracker.try_split(0)
+    self.assertIsInstance(primary, _NonPollingGrowthState)
+    self.assertEqual(new_results, primary.pending)
+    self.assertIsInstance(residual, _PollingGrowthState)
+    self.assertEqual(2, len(residual.completed))
+    self.assertEqual(0, residual.termination_state)
+    self.assertTrue(tracker.check_done())
+
+  def test_split_merges_explicit_watermark_into_residual(self):
+    state = _initial_polling()
+    result = PollResult.incomplete([_ts('c', 3)]).with_watermark(5)
+    tracker = _tracker(state)
+    self.assertTrue(tracker.try_claim((_new_results(state, result), 0)))
+    _, residual = tracker.try_split(0)
+    self.assertEqual(Timestamp(5), residual.poll_watermark)
+
+  def test_second_claim_is_rejected(self):
+    state = _initial_polling()
+    new_results = _new_results(state, PollResult.incomplete([_ts('a', 1)]))
+    tracker = _tracker(state)
+    self.assertTrue(tracker.try_claim((new_results, 0)))
+    self.assertFalse(tracker.try_claim((new_results, 0)))
+
+  def test_claim_rejects_already_completed_keys(self):
+    # The tracker re-validates a claim, so a poll round that was not deduped
+    # against the restriction is rejected instead of emitting duplicates.
+    state = _initial_polling()
+    first = _new_results(state, PollResult.incomplete([_ts('a', 1)]))
+    tracker = _tracker(state)
+    self.assertTrue(tracker.try_claim((first, 0)))
+    _, residual = tracker.try_split(0)
+    stale = PollResult.incomplete([_ts('a', 1)])
+    self.assertFalse(_tracker(residual).try_claim((stale, 0)))
+
+  def test_split_before_claim_moves_all_work_to_residual(self):
+    state = _initial_polling()
+    tracker = _tracker(state)
+    primary, residual = tracker.try_split(0)
+    self.assertIs(state, residual)
+    self.assertIsInstance(primary, _NonPollingGrowthState)
+    self.assertEqual((), primary.pending.outputs)
+    new_results = _new_results(state, PollResult.incomplete([_ts('a', 1)]))
+    self.assertFalse(tracker.try_claim((new_results, 0)))
+    self.assertTrue(tracker.check_done())
+
+  def test_non_polling_replays_exactly_the_pending_outputs(self):
+    pending = PollResult((_ts('a', 1), _ts('b', 2)), MAX_TIMESTAMP)
+    tracker = _tracker(_NonPollingGrowthState(pending))
+    self.assertTrue(tracker.is_bounded())
+    # A replay must claim the pending poll result exactly.
+    partial = PollResult((_ts('a', 1), ), None)
+    self.assertFalse(tracker.try_claim((partial, None)))
+    self.assertTrue(tracker.try_claim((pending, None)))
+    # A checkpoint after the replay leaves no residual work.
+    _, residual = tracker.try_split(0)
+    self.assertEqual((), residual.pending.outputs)
+    self.assertTrue(tracker.check_done())
+
+  def test_check_done_raises_without_claim_or_split(self):
+    tracker = _tracker(_initial_polling())
+    with self.assertRaises(ValueError):
+      tracker.check_done()
+
+  def test_wrapper_chain_defers_merged_residual(self):
+    state = _initial_polling()
+    new_results = _new_results(
+        state, PollResult.incomplete([_ts('a', 1), _ts('b', 2)]))
+    threadsafe = ThreadsafeRestrictionTracker(_tracker(state))
+    view = RestrictionTrackerView(threadsafe)
+    self.assertTrue(view.try_claim((new_results, 0)))
+    view.defer_remainder(Duration(5))
+    residual, _ = threadsafe.deferred_status()
+    self.assertIsInstance(residual, _PollingGrowthState)
+    self.assertEqual(2, len(residual.completed))
+
+
+class TerminationConditionTest(unittest.TestCase):
+  def test_never_does_not_stop(self):
+    termination = never()
+    state = termination.for_new_input(Timestamp(0), 'input')
+    self.assertFalse(termination.can_stop_polling(MAX_TIMESTAMP, state))
+
+  def test_after_total_of_stops_once_duration_elapsed(self):
+    termination = after_total_of(10)
+    state = termination.for_new_input(Timestamp(0), 'input')
+    self.assertFalse(termination.can_stop_polling(Timestamp(10), state))
+    self.assertTrue(termination.can_stop_polling(Timestamp(11), state))
+
+
+# Module-level so the poll function pickles by reference; the call counter is
+# shared within the single in-memory DirectRunner process.
+_POLL_CALLS = collections.defaultdict(int)
+
+
+def _growing_poll(prefix):
+  # Unannotated on purpose: dedup must hold on the inferred fallback coder.
+  _POLL_CALLS[prefix] += 1
+  count = _POLL_CALLS[prefix]
+  outputs = [_ts('%s%d' % (prefix, i), i + 1) for i in range(count)]
+  if count >= 3:
+    return PollResult.complete(outputs)
+  return PollResult.incomplete(outputs)
+
+
+def _complete_poll(prefix) -> PollResult[str]:
+  return PollResult.complete([_ts(prefix + 'a', 1), _ts(prefix + 'b', 2)])
+
+
+def _first_char(output):
+  return output[0]
+
+
+def _empty_poll(unused_element):
+  return PollResult.incomplete([])
+
+
+def _keyed_poll(prefix):
+  # 'a1' and 'a2' share the dedup key 'a', so only 'a1' is emitted.
+  return PollResult.complete([_ts('a1', 1), _ts('a2', 2), _ts('b1', 3)])
+
+
+class _StrCoderPollFn(PollFn):
+  def __call__(self, element):
+    return PollResult.complete([_ts(element + 'a', 1)])
+
+  def default_output_coder(self):
+    return StrUtf8Coder()
+
+
+class _NoDeterministicFormCoder(Coder):
+  def encode(self, value):
+    return b''
+
+  def decode(self, encoded):
+    return None
+
+  def is_deterministic(self):
+    return False
+
+
+def _windowed_group(kv, window=beam.DoFn.WindowParam):
+  return ((window.start, window.end), sorted(kv[1]))
+
+
+class WatchDoFnProcessTest(unittest.TestCase):
+  def _process(
+      self, poll_fn, element, timestamp, restriction=None, watermark=None):
+    dofn = _WatchGrowthDoFn(
+        poll_fn,
+        never(),
+        Duration(1),
+        StrUtf8Coder(),
+        _identity,
+        StrUtf8Coder())
+    if restriction is None:
+      restriction = dofn.initial_restriction(element)
+    threadsafe = ThreadsafeRestrictionTracker(dofn.create_tracker(restriction))
+    estimator = ThreadsafeWatermarkEstimator(
+        ManualWatermarkEstimator(watermark))
+    outputs = list(
+        dofn.process(
+            element,
+            timestamp=timestamp,
+            tracker=RestrictionTrackerView(threadsafe),
+            watermark_estimator=estimator))
+    return outputs, threadsafe, estimator
+
+  def test_empty_round_holds_watermark_at_input_timestamp(self):
+    outputs, threadsafe, estimator = self._process(
+        _empty_poll, 'in', Timestamp(7))
+    self.assertEqual([], outputs)
+    # The estimator is seeded from the input timestamp, so the deferred
+    # residual holds the watermark there instead of at MIN_TIMESTAMP.
+    self.assertEqual(Timestamp(7), estimator.current_watermark())
+    residual, _ = threadsafe.deferred_status()
+    self.assertIsInstance(residual, _PollingGrowthState)
+
+  def test_complete_round_stops_without_residual(self):
+    outputs, threadsafe, _ = self._process(_complete_poll, 'k:', Timestamp(0))
+    self.assertEqual([('k:', 'k:a'), ('k:', 'k:b')],
+                     [value.value for value in outputs])
+    self.assertIsNone(threadsafe.deferred_status())
+    self.assertTrue(threadsafe.check_done())
+
+  def test_replay_round_leaves_the_watermark_alone(self):
+    pending = PollResult((_ts('k:a', 1), _ts('k:b', 2)), MAX_TIMESTAMP)
+    outputs, threadsafe, estimator = self._process(
+        _empty_poll,
+        'k:',
+        Timestamp(7),
+        restriction=_NonPollingGrowthState(pending))
+    self.assertEqual([('k:', 'k:a'), ('k:', 'k:b')],
+                     [value.value for value in outputs])
+    # The replay branch holds the watermark at the seed, so it never runs ahead
+    # of the replayed outputs and never releases to MAX_TIMESTAMP itself.
+    self.assertEqual(Timestamp(7), estimator.current_watermark())
+    self.assertIsNone(threadsafe.deferred_status())
+    self.assertTrue(threadsafe.check_done())
+
+  def test_terminal_round_after_deferring_leaves_no_residual(self):
+    _POLL_CALLS.clear()
+    # Round one defers and parks the watermark on the new output's time.
+    _, threadsafe, estimator = self._process(_growing_poll, 'd:', Timestamp(0))
+    residual, _ = threadsafe.deferred_status()
+    self.assertIsInstance(residual, _PollingGrowthState)
+    self.assertEqual(Timestamp(1), estimator.current_watermark())
+    # Round two resumes from that residual, carrying the watermark forward.
+    _, threadsafe, estimator = self._process(
+        _growing_poll,
+        'd:',
+        Timestamp(0),
+        restriction=residual,
+        watermark=estimator.current_watermark())
+    residual, _ = threadsafe.deferred_status()
+    self.assertEqual(Timestamp(2), estimator.current_watermark())
+    # Round three completes. The watermark stays where round two left it and
+    # the round reports no residual, so nothing carries that hold forward.
+    outputs, threadsafe, estimator = self._process(
+        _growing_poll,
+        'd:',
+        Timestamp(0),
+        restriction=residual,
+        watermark=estimator.current_watermark())
+    self.assertEqual([('d:', 'd:2')], [value.value for value in outputs])
+    self.assertEqual(Timestamp(2), estimator.current_watermark())
+    self.assertIsNone(threadsafe.deferred_status())
+    self.assertTrue(threadsafe.check_done())
+
+
+class WatchEndToEndTest(unittest.TestCase):
+  def _in_memory_pipeline(self):
+    return TestPipeline(
+        options=PipelineOptions(direct_running_mode='in_memory'))
+
+  def test_complete_outputs_values_and_timestamps(self):
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['k:'])
+          | Watch(_complete_poll, poll_interval=Duration(1)))
+      assert_that(
+          output,
+          equal_to([
+              TestWindowedValue(('k:', 'k:a'), Timestamp(1), [GlobalWindow()]),
+              TestWindowedValue(('k:', 'k:b'), Timestamp(2), [GlobalWindow()]),
+          ]),
+          reify_windows=True)
+
+  def test_complete_advances_watermark_for_windowed_pipeline(self):
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['k:'])
+          | Watch(_complete_poll, poll_interval=Duration(1)))
+      grouped = (
+          output
+          | beam.WindowInto(FixedWindows(10))
+          | beam.Map(lambda kv: ('all', kv[1]))
+          | beam.GroupByKey()
+          | beam.Map(_windowed_group))
+      assert_that(
+          grouped,
+          equal_to([
+              ((Timestamp(0), Timestamp(10)), ['k:a', 'k:b']),
+          ]))
+
+  def test_multi_round_dedups_stops_and_is_per_input(self):
+    _POLL_CALLS.clear()
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['x:', 'y:'])
+          | Watch(_growing_poll, poll_interval=Duration(0.05)))
+      assert_that(
+          output,
+          equal_to([('x:', 'x:0'), ('x:', 'x:1'), ('x:', 'x:2'), ('y:', 'y:0'),
+                    ('y:', 'y:1'), ('y:', 'y:2')]))
+    self.assertEqual(3, _POLL_CALLS['x:'])
+    self.assertEqual(3, _POLL_CALLS['y:'])
+
+  def test_output_key_dedups_across_pipeline(self):
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['k'])
+          | Watch(
+              _keyed_poll, poll_interval=Duration(1),
+              output_key_fn=_first_char))
+      assert_that(output, equal_to([('k', 'a1'), ('k', 'b1')]))
+
+  def test_rejects_key_coder_without_deterministic_form(self):
+    with self.assertRaises(ValueError):
+      with self._in_memory_pipeline() as p:
+        _ = (
+            p | beam.Create(['k:'])
+            | Watch(
+                _complete_poll,
+                poll_interval=Duration(1),
+                output_key_coder=_NoDeterministicFormCoder()))
+
+  def test_infers_output_coder_from_return_annotation(self):
+    # _complete_poll is annotated ``-> PollResult[str]``, so the output coder
+    # and with it the (input, output) element type are inferred without hints.
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['k:'])
+          | Watch(_complete_poll, poll_interval=Duration(1)))
+      self.assertEqual(typehints.Tuple[str, str], output.element_type)
+
+  def test_uses_poll_fn_default_output_coder(self):
+    with self._in_memory_pipeline() as p:
+      output = (
+          p | beam.Create(['k:'])
+          | Watch(_StrCoderPollFn(), poll_interval=Duration(1)))
+      self.assertEqual(typehints.Tuple[str, str], output.element_type)
+
+
+if __name__ == '__main__':
+  unittest.main()


Reply via email to