| # |
| # 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. |
| |
| By default, the Watch transform internally stores the hash of all items |
| seen. If the incremental items returned by the poll function guarantee |
| monotonic timestamp growth (new items on the next poll have timestamps |
| larger than the largest of the previous poll), consider setting |
| ``timestamp_cursor=True`` for better performance, as it replaces the hash |
| dedup with an O(1) event-time cursor; see :class:`Watch`. |
| |
| 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 enum |
| import hashlib |
| import inspect |
| import logging |
| import time |
| import typing |
| from collections.abc import Iterable |
| from typing import Any |
| from typing import Callable |
| from typing import Generic |
| from typing import Optional |
| 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.typehints import native_type_compatibility |
| 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', |
| ] |
| |
| _LOGGER = logging.getLogger(__name__) |
| |
| _HASH_DIGEST_SIZE = 16 # 128-bit digest width. |
| |
| OutputT = TypeVar('OutputT') |
| |
| # ------------------------------------------------------------------------------ |
| # Public API. |
| # ------------------------------------------------------------------------------ |
| |
| |
| @dataclasses.dataclass(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, ...]: |
| # One default timestamp per call, so raw outputs share an event time. |
| 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. The inferred watermark |
| is safe only for non-decreasing event-time enumerations; out-of-order |
| sources should call :meth:`with_watermark`. |
| """ |
| 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. The watermark is |
| released to ``MAX_TIMESTAMP`` so downstream event-time windows close. |
| """ |
| return PollResult( |
| PollResult._normalize(outputs, timestamp), watermark=MAX_TIMESTAMP) |
| |
| def with_watermark(self, watermark) -> 'PollResult': |
| """Sets an explicit watermark, a promise that no future output for this |
| input will have an event time below ``watermark``.""" |
| 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.""" |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class _PollingGrowthState(_GrowthState): |
| """Keep-polling state: dedup state, 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. In timestamp-cursor |
| mode ``completed`` is empty and ``cursor`` is the greatest emitted event |
| time. |
| """ |
| completed: 'collections.OrderedDict[bytes, Timestamp]' |
| poll_watermark: Optional[Timestamp] |
| termination_state: Any |
| cursor: Optional[Timestamp] = None |
| |
| |
| @dataclasses.dataclass(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 _StateTag(enum.IntEnum): |
| """Envelope tag selecting the encoded restriction variant.""" |
| POLLING = 0 |
| NON_POLLING = 1 |
| CURSOR_POLLING = 2 |
| |
| |
| 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. A cursor state encodes only its termination state and cursor; the |
| watermark is restored from the estimator state the runner persists. Hash |
| states keep the pre-cursor byte format. 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._cursor_polling_coder = TupleCoder([ |
| termination.state_coder(), |
| TimestampCoder(), |
| ]) |
| self._non_polling_coder = TupleCoder([ |
| nullable_ts, |
| coders.ListCoder(_TimestampedValueCoder(output_coder)), |
| ]) |
| |
| def encode(self, state: _GrowthState) -> bytes: |
| if isinstance(state, _PollingGrowthState): |
| if state.cursor is None: |
| payload = self._polling_coder.encode(( |
| state.termination_state, |
| state.poll_watermark, |
| list(state.completed.items()))) |
| return self._envelope_coder.encode((_StateTag.POLLING, payload)) |
| payload = self._cursor_polling_coder.encode( |
| (state.termination_state, state.cursor)) |
| return self._envelope_coder.encode((_StateTag.CURSOR_POLLING, payload)) |
| payload = self._non_polling_coder.encode( |
| (state.pending.watermark, list(state.pending.outputs))) |
| return self._envelope_coder.encode((_StateTag.NON_POLLING, payload)) |
| |
| def decode(self, encoded: bytes) -> _GrowthState: |
| tag, payload = self._envelope_coder.decode(encoded) |
| if tag == _StateTag.POLLING: |
| termination_state, poll_watermark, items = self._polling_coder.decode( |
| payload) |
| return _PollingGrowthState( |
| collections.OrderedDict(items), poll_watermark, termination_state) |
| if tag == _StateTag.NON_POLLING: |
| watermark, outputs = self._non_polling_coder.decode(payload) |
| return _NonPollingGrowthState(PollResult(tuple(outputs), watermark)) |
| if tag == _StateTag.CURSOR_POLLING: |
| termination_state, cursor = self._cursor_polling_coder.decode(payload) |
| return _PollingGrowthState( |
| collections.OrderedDict(), None, termination_state, cursor) |
| 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)) |
| |
| |
| def _cursor_of(restriction: _PollingGrowthState) -> Optional[Timestamp]: |
| """The dedup cursor: the stored one, or for a restriction switched over |
| from hash dedup, the greatest event time its hash map recorded.""" |
| if restriction.cursor is not None: |
| return restriction.cursor |
| if restriction.completed: |
| return max(restriction.completed.values()) |
| return None |
| |
| |
| def _past_cursor( |
| restriction: _PollingGrowthState, result: PollResult) -> PollResult: |
| """Filters a poll result down to outputs strictly past the cursor, sorted |
| by timestamp so the earliest infers the watermark and the latest advances |
| the cursor.""" |
| cursor = _cursor_of(restriction) |
| new_outputs = [ |
| output for output in result.outputs |
| if cursor is None or output.timestamp > cursor |
| ] |
| 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, |
| timestamp_cursor: bool = False): |
| self._restriction = restriction |
| self._key_fn = key_fn |
| self._key_coder = key_coder |
| self._timestamp_cursor = timestamp_cursor |
| 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 = None |
| if self._timestamp_cursor: |
| # Cursor mode validates by timestamps and never hashes. |
| if isinstance(self._restriction, _PollingGrowthState): |
| cursor = _cursor_of(self._restriction) |
| if cursor is not None and any(output.timestamp <= cursor |
| for output in result.outputs): |
| return False |
| else: |
| # Values may lack stable equality without a deterministic coder, so a |
| # replay is identified by its timestamps. |
| expected = sorted( |
| output.timestamp for output in self._restriction.pending.outputs) |
| if expected != sorted(output.timestamp for output in result.outputs): |
| return False |
| else: |
| 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 round folded into the dedup state. |
| # A state holds hashes or a cursor, never both, so each mode drops the |
| # other mode's leftovers after a switch. |
| if self._timestamp_cursor: |
| completed = self._restriction.completed |
| if completed: |
| completed = collections.OrderedDict() |
| if self._claimed_result.outputs: |
| cursor = self._claimed_result.outputs[-1].timestamp |
| else: |
| cursor = _cursor_of(self._restriction) |
| elif self._claimed_hashes: |
| completed = collections.OrderedDict(self._restriction.completed) |
| completed.update(self._claimed_hashes) |
| cursor = None |
| else: |
| # An idle round reuses the parent map so empty polls stay O(1). |
| completed = self._restriction.completed |
| cursor = None |
| residual = _PollingGrowthState( |
| completed, |
| _max_watermark( |
| self._restriction.poll_watermark, self._claimed_result.watermark), |
| self._claimed_termination_state, |
| cursor) |
| 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, |
| timestamp_cursor: bool = False, |
| 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._timestamp_cursor = timestamp_cursor |
| self._now = now_fn or time.time |
| self._restriction_coder = _GrowthStateCoder(output_coder, termination) |
| # Count of late emissions seen on this worker, for throttled warnings. |
| self._late_count = 0 |
| |
| 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, self._timestamp_cursor) |
| |
| 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 |
| if (self._timestamp_cursor and restriction.cursor is not None and |
| restriction.cursor >= MAX_TIMESTAMP): |
| # Nothing can be past a cursor at MAX; claim an empty round and stop. |
| tracker.try_claim((PollResult(()), restriction.termination_state)) |
| 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()) |
| if self._timestamp_cursor: |
| new_results = _past_cursor(restriction, result) |
| else: |
| 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 |
| # Emit before advancing the watermark so a round's own watermark cannot |
| # make its outputs late. Late outputs are warned about only once the |
| # watermark has advanced past the element-timestamp seed. |
| current_watermark = watermark_estimator.current_watermark() |
| warn_on_late = ( |
| current_watermark is not None and current_watermark > timestamp) |
| for output in new_results.outputs: |
| if warn_on_late and output.timestamp < current_watermark: |
| self._warn_late(element, output.timestamp, current_watermark) |
| 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._timestamp_cursor: |
| new_cursor = ( |
| new_results.outputs[-1].timestamp |
| if new_results.outputs else restriction.cursor) |
| if new_cursor is not None and new_cursor >= MAX_TIMESTAMP: |
| # A cursor at MAX is terminal; polling on would only drop outputs. |
| return |
| 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 _warn_late(self, element, output_timestamp, watermark) -> None: |
| # Log at powers of two to keep an ongoing problem visible without spam. |
| self._late_count += 1 |
| if self._late_count & (self._late_count - 1) == 0: |
| _LOGGER.warning( |
| 'Watch emitted output for input %r at %s, behind the watermark %s; ' |
| 'downstream event-time windowing may drop it as late. Use ' |
| 'PollResult.with_watermark for out-of-order sources. ' |
| '(%d late emissions on this worker)', |
| element, |
| output_timestamp, |
| watermark, |
| self._late_count) |
| |
| |
| 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 |
| |
| |
| def _coder_for_hint(hint) -> Coder: |
| # typing and native generic hints such as tuple[str, float] must be |
| # converted to Beam typehints, or the registry falls back to pickling. |
| return coders.registry.get_coder( |
| native_type_compatibility.convert_to_beam_type(hint)) |
| |
| |
| 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. |
| timestamp_cursor: dedup by event time instead of by key. Each round emits |
| only outputs strictly past the greatest event time already emitted, so |
| the per-input state is a single timestamp. Requires every new output to |
| carry an event time strictly greater than all previously emitted ones; |
| re-listed old outputs at or below the cursor are dropped as already |
| seen. For sources whose new outputs can arrive at or below the cursor, |
| keep the default hash dedup. Incompatible with ``output_key_fn`` and |
| ``output_key_coder``. |
| 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, |
| timestamp_cursor: bool = False, |
| now_fn: Optional[Callable[[], float]] = None): |
| super().__init__() |
| if poll_interval is None: |
| raise ValueError('Watch requires a poll_interval') |
| if timestamp_cursor and (output_key_fn is not None or |
| output_key_coder is not None): |
| raise ValueError( |
| 'timestamp_cursor dedups by event time, not by key; do not pass ' |
| 'output_key_fn or output_key_coder with timestamp_cursor=True.') |
| 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._timestamp_cursor = timestamp_cursor |
| 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 = _coder_for_hint(_poll_output_type(self._poll_fn)) |
| if self._timestamp_cursor: |
| # Cursor dedup never hashes, so no deterministic key coder is needed. |
| key_fn = _identity |
| key_coder = output_coder |
| else: |
| 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 _coder_for_hint( |
| _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._timestamp_cursor, |
| self._now)).with_output_types(tuple[input_type, value_type]) |
| |
| |
| def _as_duration(value) -> Duration: |
| return value if isinstance(value, Duration) else Duration(value) |