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 82c6ee4e976 [Python] Bound Watch state with a timestamp cursor (#39090)
82c6ee4e976 is described below
commit 82c6ee4e97639c26492f217c8b9bca6f7534ce80
Author: Elia Liu <[email protected]>
AuthorDate: Tue Aug 4 06:17:14 2026 +1000
[Python] Bound Watch state with a timestamp cursor (#39090)
* [Python] Bound Watch state with a timestamp cursor
Opt-in timestamp_cursor=True dedups by a high-water-mark timestamp
instead of by key identity: Watch keeps only the greatest event time it
has emitted for an input and emits the polled outputs strictly past it,
so the per-input state and per-checkpoint encoding are O(1) regardless
of how many outputs the input produces. For sources whose outputs carry
strictly increasing event-time timestamps; the default exact hash dedup
remains for arbitrary-relisting or out-of-order sources.
---
sdks/python/apache_beam/io/watch.py | 264 +++++++++++++++++----
sdks/python/apache_beam/io/watch_test.py | 387 ++++++++++++++++++++++++++++++-
2 files changed, 599 insertions(+), 52 deletions(-)
diff --git a/sdks/python/apache_beam/io/watch.py
b/sdks/python/apache_beam/io/watch.py
index f2eadaf4ace..40b7e451ce6 100644
--- a/sdks/python/apache_beam/io/watch.py
+++ b/sdks/python/apache_beam/io/watch.py
@@ -34,6 +34,13 @@ hashes each output's key: the output itself by default, or
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
@@ -55,8 +62,10 @@ 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
@@ -91,6 +100,8 @@ __all__ = [
'after_total_of',
]
+_LOGGER = logging.getLogger(__name__)
+
_HASH_DIGEST_SIZE = 16 # 128-bit digest width.
OutputT = TypeVar('OutputT')
@@ -120,6 +131,7 @@ class PollResult(Generic[OutputT]):
@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:
@@ -137,7 +149,9 @@ class PollResult(Generic[OutputT]):
"""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.
+ 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)
@@ -146,12 +160,15 @@ class PollResult(Generic[OutputT]):
"""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.
+ 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))
@@ -254,15 +271,17 @@ class _GrowthState:
@dataclasses.dataclass(frozen=True)
class _PollingGrowthState(_GrowthState):
- """Keep-polling state: emitted-output hashes, watermark, termination state.
+ """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; a new mapping is
- built for each residual.
+ 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)
@@ -305,13 +324,23 @@ class _TimestampedValueCoder(Coder):
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.
- This format is internal to the Python SDK.
+ 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())
@@ -322,6 +351,10 @@ class _GrowthStateCoder(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)),
@@ -329,25 +362,33 @@ class _GrowthStateCoder(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))
+ 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((1, payload))
+ return self._envelope_coder.encode((_StateTag.NON_POLLING, payload))
def decode(self, encoded: bytes) -> _GrowthState:
tag, payload = self._envelope_coder.decode(encoded)
- if tag == 0:
+ 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 == 1:
+ 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:
@@ -400,6 +441,30 @@ def _never_seen_before(
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.
@@ -413,10 +478,12 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
self,
restriction: _GrowthState,
key_fn: Callable[[Any], Any],
- key_coder: Coder):
+ 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]
@@ -438,19 +505,35 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
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
+ 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:
- expected = set(
- self._hash(output.value)
- for output in self._restriction.pending.outputs)
- if expected != set(claimed_hashes):
- return False
+ 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
@@ -470,14 +553,31 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
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)
+ # 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(
- merged,
+ completed,
_max_watermark(
self._restriction.poll_watermark,
self._claimed_result.watermark),
- self._claimed_termination_state)
+ self._claimed_termination_state,
+ cursor)
self._restriction = _NonPollingGrowthState(self._claimed_result)
self._should_stop = True
return self._restriction, residual
@@ -522,6 +622,7 @@ class _WatchGrowthDoFn(core.DoFn, core.RestrictionProvider):
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
@@ -529,8 +630,11 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
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())
@@ -540,7 +644,8 @@ class _WatchGrowthDoFn(core.DoFn, core.RestrictionProvider):
self._termination.for_new_input(now, element))
def create_tracker(self, restriction) -> _GrowthRestrictionTracker:
- return _GrowthRestrictionTracker(restriction, self._key_fn,
self._key_coder)
+ return _GrowthRestrictionTracker(
+ restriction, self._key_fn, self._key_coder, self._timestamp_cursor)
def restriction_coder(self) -> Coder:
return self._restriction_coder
@@ -570,13 +675,21 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
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())
- new_results = _never_seen_before(
- restriction, result, self._key_fn, self._key_coder)
+ 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(
@@ -585,7 +698,15 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
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
@@ -594,6 +715,13 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
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:
@@ -603,6 +731,20 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
_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.
@@ -670,6 +812,14 @@ class Watch(PTransform):
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__(
@@ -680,16 +830,23 @@ class Watch(PTransform):
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):
@@ -698,22 +855,28 @@ class Watch(PTransform):
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._output_key_fn is None:
- # The output is its own dedup key, so the key coder is the output coder.
+ if self._timestamp_cursor:
+ # Cursor dedup never hashes, so no deterministic key coder is needed.
key_fn = _identity
- key_coder = self._output_key_coder or output_coder
+ key_coder = 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__)
+ 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.
@@ -730,6 +893,7 @@ class Watch(PTransform):
output_coder,
key_fn,
key_coder,
+ self._timestamp_cursor,
self._now)).with_output_types(tuple[input_type, value_type])
diff --git a/sdks/python/apache_beam/io/watch_test.py
b/sdks/python/apache_beam/io/watch_test.py
index 472177ceaee..a07f98bfa8d 100644
--- a/sdks/python/apache_beam/io/watch_test.py
+++ b/sdks/python/apache_beam/io/watch_test.py
@@ -22,8 +22,14 @@ import typing
import unittest
import apache_beam as beam
+from apache_beam.coders.coders import BytesCoder
from apache_beam.coders.coders import Coder
+from apache_beam.coders.coders import ListCoder
+from apache_beam.coders.coders import NullableCoder
from apache_beam.coders.coders import StrUtf8Coder
+from apache_beam.coders.coders import TimestampCoder
+from apache_beam.coders.coders import TupleCoder
+from apache_beam.coders.coders import VarIntCoder
from apache_beam.io.watch import PollFn
from apache_beam.io.watch import PollResult
from apache_beam.io.watch import Watch
@@ -31,6 +37,7 @@ 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 _past_cursor
from apache_beam.io.watch import _PollingGrowthState
from apache_beam.io.watch import _WatchGrowthDoFn
from apache_beam.io.watch import after_total_of
@@ -70,12 +77,45 @@ def _tracker(restriction):
return _GrowthRestrictionTracker(restriction, _identity, StrUtf8Coder())
+def _cursor_tracker(restriction):
+ return _GrowthRestrictionTracker(
+ restriction, _identity, StrUtf8Coder(), timestamp_cursor=True)
+
+
def _initial_polling(termination=None, now=Timestamp(0)):
termination = termination or never()
return _PollingGrowthState(
collections.OrderedDict(), None, termination.for_new_input(now, 'input'))
+class PollResultTest(unittest.TestCase):
+ def test_normalize_stamps_one_processing_time_when_timestamp_none(self):
+ before = Timestamp.now()
+ result = PollResult.incomplete(['a', 'b'])
+ after = Timestamp.now()
+ # Raw outputs share a single processing-time stamp (no per-output jitter).
+ stamps = {o.timestamp for o in result.outputs}
+ self.assertEqual(1, len(stamps))
+ ts = stamps.pop()
+ self.assertTrue(before <= ts <= after)
+
+ def test_normalize_preserves_timestamped_and_applies_explicit_default(self):
+ result = PollResult.incomplete([_ts('a', 1), 'b'], timestamp=7)
+ by_value = {o.value: o.timestamp for o in result.outputs}
+ self.assertEqual(Timestamp(1), by_value['a']) # TimestampedValue preserved
+ self.assertEqual(Timestamp(7), by_value['b']) # raw stamped with default
+
+ def test_complete_releases_watermark_to_max(self):
+ self.assertEqual(
+ MAX_TIMESTAMP, PollResult.complete([_ts('a', 1)]).watermark)
+ self.assertTrue(PollResult.complete([]).is_complete)
+
+ def test_with_watermark_overrides(self):
+ self.assertEqual(
+ Timestamp(0),
+ PollResult.incomplete([_ts('a', 9)]).with_watermark(0).watermark)
+
+
class GrowthStateCoderTest(unittest.TestCase):
def test_polling_round_trip_preserves_resume_state(self):
termination = after_total_of(Duration(30))
@@ -91,6 +131,42 @@ class GrowthStateCoderTest(unittest.TestCase):
self.assertEqual(list(completed.items()), list(decoded.completed.items()))
self.assertEqual(Timestamp(5), decoded.poll_watermark)
self.assertEqual(termination_state, decoded.termination_state)
+ self.assertIsNone(decoded.cursor)
+
+ def test_polling_round_trip_preserves_cursor(self):
+ coder = _GrowthStateCoder(StrUtf8Coder(), never())
+ state = _PollingGrowthState(
+ collections.OrderedDict(),
+ Timestamp(5),
+ never().for_new_input(Timestamp(0), 'input'),
+ Timestamp(42))
+ decoded = coder.decode(coder.encode(state))
+ self.assertEqual(Timestamp(42), decoded.cursor)
+ self.assertEqual(0, len(decoded.completed))
+ self.assertIsNone(decoded.poll_watermark) # not part of the payload
+
+ def test_cursorless_state_keeps_the_pre_cursor_byte_format(self):
+ # A polling state without a cursor must encode exactly as before the
+ # cursor existed, so in-flight hash-mode restrictions decode across an
+ # upgrade in either direction.
+ termination = never()
+ coder = _GrowthStateCoder(StrUtf8Coder(), termination)
+ completed = collections.OrderedDict([(b'a' * 16, Timestamp(1))])
+ termination_state = termination.for_new_input(Timestamp(0), 'input')
+ state = _PollingGrowthState(completed, Timestamp(5), termination_state)
+ legacy_polling_coder = TupleCoder([
+ termination.state_coder(),
+ NullableCoder(TimestampCoder()),
+ ListCoder(TupleCoder([BytesCoder(), TimestampCoder()])),
+ ])
+ legacy_payload = legacy_polling_coder.encode(
+ (termination_state, Timestamp(5), list(completed.items())))
+ legacy_encoded = TupleCoder([VarIntCoder(), BytesCoder()]).encode(
+ (0, legacy_payload))
+ self.assertEqual(legacy_encoded, coder.encode(state))
+ decoded = coder.decode(legacy_encoded)
+ self.assertEqual(list(completed.items()), list(decoded.completed.items()))
+ self.assertIsNone(decoded.cursor)
def test_non_polling_round_trip_preserves_pending_outputs(self):
coder = _GrowthStateCoder(StrUtf8Coder(), never())
@@ -215,6 +291,167 @@ class GrowthTrackerTest(unittest.TestCase):
self.assertIsInstance(residual, _PollingGrowthState)
self.assertEqual(2, len(residual.completed))
+ def test_idle_round_reuses_completed_map_object(self):
+ # A round that discovers nothing must reuse the parent dedup map rather
+ # than copying it O(N), so a steady-state empty poll stays cheap.
+ state = _initial_polling()
+ first = _new_results(state, PollResult.incomplete([_ts('a', 1)]))
+ tracker = _tracker(state)
+ self.assertTrue(tracker.try_claim((first, 0)))
+ _, residual1 = tracker.try_split(0)
+ resumed = _tracker(residual1)
+ empty = _new_results(residual1, PollResult.incomplete([]))
+ self.assertTrue(resumed.try_claim((empty, 0)))
+ _, residual2 = resumed.try_split(0)
+ self.assertIs(residual1.completed, residual2.completed)
+
+
+class TimestampCursorTest(unittest.TestCase):
+ """Cursor-mode dedup: high-water-mark timestamp instead of a hash set."""
+ def test_keeps_state_o1_and_tracks_high_water_mark(self):
+ state = _initial_polling()
+ result = PollResult.incomplete([_ts('a', 1), _ts('b', 2), _ts('c', 3)])
+ new_results = _past_cursor(state, result)
+ self.assertEqual(['a', 'b', 'c'], [o.value for o in new_results.outputs])
+ tracker = _cursor_tracker(state)
+ self.assertTrue(tracker.try_claim((new_results, 0)))
+ _, residual = tracker.try_split(0)
+ self.assertIsInstance(residual, _PollingGrowthState)
+ self.assertEqual(0, len(residual.completed)) # no hash set
+ self.assertEqual(Timestamp(3), residual.cursor) # high-water mark
+
+ def test_emits_only_outputs_after_the_cursor(self):
+ # A later round emits only outputs strictly past the cursor; a re-listed
+ # output (== cursor) and an earlier output (< cursor) are both dropped.
+ state = _initial_polling()
+ tracker = _cursor_tracker(state)
+ first = _past_cursor(state, PollResult.incomplete([_ts('a', 10)]))
+ self.assertTrue(tracker.try_claim((first, 0)))
+ _, residual = tracker.try_split(0)
+ self.assertEqual(Timestamp(10), residual.cursor)
+ second = _past_cursor(
+ residual,
+ PollResult.incomplete([_ts('early', 5), _ts('a', 10), _ts('c', 20)]))
+ self.assertEqual(['c'], [o.value for o in second.outputs]) # only 20 > 10
+ resumed = _cursor_tracker(residual)
+ self.assertTrue(resumed.try_claim((second, 0)))
+ _, residual = resumed.try_split(0)
+ self.assertEqual(Timestamp(20), residual.cursor)
+
+ def test_relist_emits_each_output_exactly_once(self):
+ # A full re-list of a growing collection at strictly increasing event
+ # times emits each output once; the state never accumulates a hash set.
+ state = _initial_polling()
+ emitted = collections.Counter()
+ for round_index in range(10):
+ result = PollResult.incomplete(
+ [_ts('f%d' % i, i + 1) for i in range(round_index + 1)])
+ new_results = _past_cursor(state, result)
+ tracker = _cursor_tracker(state)
+ self.assertTrue(tracker.try_claim((new_results, 0)))
+ for output in new_results.outputs:
+ emitted[output.value] += 1
+ _, state = tracker.try_split(0)
+ self.assertEqual(0, len(state.completed)) # O(1) throughout
+ self.assertEqual([1] * 10, [emitted['f%d' % i] for i in range(10)])
+ self.assertEqual(Timestamp(10), state.cursor)
+
+ def test_round_below_high_water_mark_keeps_cursor_and_reuses_state(self):
+ # A round whose outputs are all at or below the cursor emits nothing and
+ # leaves the cursor unchanged; the (empty) completed map is reused as-is.
+ state = _initial_polling()
+ tracker = _cursor_tracker(state)
+ first = _past_cursor(state, PollResult.incomplete([_ts('a', 10)]))
+ self.assertTrue(tracker.try_claim((first, 0)))
+ _, residual1 = tracker.try_split(0)
+ stale = _past_cursor(
+ residual1, PollResult.incomplete([_ts('a', 10), _ts('old', 4)]))
+ self.assertEqual((), stale.outputs)
+ resumed = _cursor_tracker(residual1)
+ self.assertTrue(resumed.try_claim((stale, 0)))
+ _, residual2 = resumed.try_split(0)
+ self.assertEqual(Timestamp(10), residual2.cursor) # unchanged
+ self.assertIs(residual1.completed, residual2.completed)
+
+ def test_claim_rejects_outputs_at_or_below_the_cursor(self):
+ # The tracker re-validates a claim, so a round that was not filtered
+ # against the cursor is rejected instead of emitting already-seen outputs.
+ state = _initial_polling()
+ tracker = _cursor_tracker(state)
+ first = _past_cursor(state, PollResult.incomplete([_ts('a', 10)]))
+ self.assertTrue(tracker.try_claim((first, 0)))
+ _, residual = tracker.try_split(0)
+ stale = PollResult.incomplete([_ts('a', 10)])
+ self.assertFalse(_cursor_tracker(residual).try_claim((stale, 0)))
+
+ def test_replay_validates_by_timestamps(self):
+ # Cursor mode never hashes, so a replay is validated by its timestamps.
+ pending = PollResult((_ts('a', 1), _ts('b', 2)), MAX_TIMESTAMP)
+ tracker = _cursor_tracker(_NonPollingGrowthState(pending))
+ partial = PollResult((_ts('a', 1), ), None)
+ self.assertFalse(tracker.try_claim((partial, None)))
+ self.assertTrue(tracker.try_claim((pending, None)))
+
+ def test_switching_hash_state_to_cursor_drops_the_hash_map(self):
+ # A restriction carried over from hash dedup still holds completed hashes;
+ # cursor mode ignores them, so the first cursor round must drop them and
+ # make the state O(1) rather than carry dead hashes forever.
+ legacy = _PollingGrowthState(
+ collections.OrderedDict([(b'a' * 16, Timestamp(1))]),
+ None,
+ never().for_new_input(Timestamp(0), 'input'))
+ result = _past_cursor(legacy, PollResult.incomplete([_ts('a', 100)]))
+ tracker = _cursor_tracker(legacy)
+ self.assertTrue(tracker.try_claim((result, 0)))
+ _, residual = tracker.try_split(0)
+ self.assertEqual(0, len(residual.completed))
+ self.assertEqual(Timestamp(100), residual.cursor)
+
+ def test_switching_hash_state_to_cursor_seeds_the_cursor(self):
+ # Outputs at or below the hash map's greatest recorded event time are
+ # already seen and must not re-emit after the switch.
+ legacy = _PollingGrowthState(
+ collections.OrderedDict([(b'a' * 16, Timestamp(5)),
+ (b'b' * 16, Timestamp(10))]),
+ None,
+ never().for_new_input(Timestamp(0), 'input'))
+ relist = PollResult.incomplete([_ts('a', 5), _ts('b', 10), _ts('c', 20)])
+ new_results = _past_cursor(legacy, relist)
+ self.assertEqual(['c'], [o.value for o in new_results.outputs])
+ tracker = _cursor_tracker(legacy)
+ self.assertTrue(tracker.try_claim((new_results, 0)))
+ _, residual = tracker.try_split(0)
+ self.assertEqual(0, len(residual.completed))
+ self.assertEqual(Timestamp(20), residual.cursor)
+
+ def test_hash_round_drops_a_stale_cursor(self):
+ # The reverse switch: a hash round drops the cursor, so a state never
+ # holds hashes and a cursor at the same time.
+ state = _PollingGrowthState(
+ collections.OrderedDict(), None, 0, cursor=Timestamp(10))
+ tracker = _tracker(state)
+ result = _new_results(state, PollResult.incomplete([_ts('a', 20)]))
+ self.assertTrue(tracker.try_claim((result, 0)))
+ _, residual = tracker.try_split(0)
+ self.assertIsNone(residual.cursor)
+ self.assertEqual(1, len(residual.completed))
+
+ def test_cursor_state_encoding_size_is_independent_of_outputs(self):
+ coder = _GrowthStateCoder(StrUtf8Coder(), never())
+
+ def encoded_residual_after_claiming(count):
+ state = _initial_polling()
+ result = PollResult.incomplete(
+ [_ts('output%d' % i, i + 1) for i in range(count)])
+ tracker = _cursor_tracker(state)
+ self.assertTrue(tracker.try_claim((_past_cursor(state, result), 0)))
+ _, residual = tracker.try_split(0)
+ return coder.encode(residual)
+
+ self.assertEqual(
+ len(encoded_residual_after_claiming(1)),
+ len(encoded_residual_after_claiming(100)))
+
class TerminationConditionTest(unittest.TestCase):
def test_never_does_not_stop(self):
@@ -256,6 +493,20 @@ def _empty_poll(unused_element):
return PollResult.incomplete([])
+def _out_of_order_poll(prefix):
+ # Round 1 emits late_after@10 (advances the watermark to 10); round 2 emits
+ # early@5, which is behind the watermark and therefore late.
+ _POLL_CALLS[prefix] += 1
+ if _POLL_CALLS[prefix] == 1:
+ return PollResult.incomplete([_ts(prefix + 'late_after', 10)])
+ return PollResult.complete([_ts(prefix + 'early', 5)])
+
+
+def _max_timestamp_poll(unused_element):
+ return PollResult.incomplete(
+ [_ts('a', 10), TimestampedValue('b', MAX_TIMESTAMP)])
+
+
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)])
@@ -286,14 +537,21 @@ def _windowed_group(kv, window=beam.DoFn.WindowParam):
class WatchDoFnProcessTest(unittest.TestCase):
def _process(
- self, poll_fn, element, timestamp, restriction=None, watermark=None):
+ self,
+ poll_fn,
+ element,
+ timestamp,
+ restriction=None,
+ watermark=None,
+ timestamp_cursor=False):
dofn = _WatchGrowthDoFn(
poll_fn,
never(),
Duration(1),
StrUtf8Coder(),
_identity,
- StrUtf8Coder())
+ StrUtf8Coder(),
+ timestamp_cursor)
if restriction is None:
restriction = dofn.initial_restriction(element)
threadsafe = ThreadsafeRestrictionTracker(dofn.create_tracker(restriction))
@@ -368,6 +626,107 @@ class WatchDoFnProcessTest(unittest.TestCase):
self.assertIsNone(threadsafe.deferred_status())
self.assertTrue(threadsafe.check_done())
+ def test_cursor_at_max_timestamp_stops_polling(self):
+ # A cursor reaching MAX is terminal: nothing can be strictly past it, so
+ # the round stops instead of polling forever and dropping every output.
+ outputs, threadsafe, _ = self._process(
+ _max_timestamp_poll, 'k:', Timestamp(0), timestamp_cursor=True)
+ self.assertEqual([('k:', 'a'), ('k:', 'b')],
+ [value.value for value in outputs])
+ self.assertIsNone(threadsafe.deferred_status())
+ self.assertTrue(threadsafe.check_done())
+
+ def test_resumed_cursor_at_max_stops_without_polling(self):
+ # A restriction resumed with the cursor already at MAX (persisted by a
+ # checkpoint after a MAX-timestamped round) must stop without invoking the
+ # poll function at all.
+ polls = []
+
+ def poll(unused_element):
+ polls.append(1)
+ return PollResult.incomplete([])
+
+ resumed = _PollingGrowthState(
+ collections.OrderedDict(),
+ None,
+ never().for_new_input(Timestamp(0), 'input'),
+ MAX_TIMESTAMP)
+ outputs, threadsafe, _ = self._process(
+ poll, 'k:', Timestamp(0), restriction=resumed, timestamp_cursor=True)
+ self.assertEqual([], outputs)
+ self.assertEqual([], polls) # the poll function never ran
+ self.assertIsNone(threadsafe.deferred_status())
+ self.assertTrue(threadsafe.check_done())
+
+ def test_out_of_order_new_output_emits_late_and_warns(self):
+ # Round 1 surfaces late_after@10 and parks the watermark there; round 2
+ # surfaces a brand-new early@5. The output is emitted at its true (earlier)
+ # time, so it is late for downstream windowing, and Watch warns about it.
+ _POLL_CALLS.clear()
+ _, threadsafe, estimator = self._process(
+ _out_of_order_poll, 'k:', Timestamp(0))
+ self.assertEqual(Timestamp(10), estimator.current_watermark())
+ residual, _ = threadsafe.deferred_status()
+ with self.assertLogs('apache_beam.io.watch', level='WARNING') as logs:
+ outputs, _, _ = self._process(
+ _out_of_order_poll,
+ 'k:',
+ Timestamp(0),
+ restriction=residual,
+ watermark=estimator.current_watermark())
+ self.assertEqual([('k:', 'k:early')], [value.value for value in outputs])
+ self.assertEqual([Timestamp(5)], [value.timestamp for value in outputs])
+ self.assertTrue(
+ any('behind the watermark' in line for line in logs.output),
+ 'expected a late-emission warning, got: %s' % logs.output)
+
+ def test_first_round_early_output_does_not_warn(self):
+ # While the estimator holds the input element's timestamp seed, an output
+ # behind it must not trigger the out-of-order warning: the seed is not a
+ # poll-order signal.
+ def poll(unused_element):
+ return PollResult.incomplete([_ts('a', 5)])
+
+ with self.assertNoLogs('apache_beam.io.watch', level='WARNING'):
+ outputs, _, _ = self._process(poll, 'k:', Timestamp(10))
+ self.assertEqual([Timestamp(5)], [value.timestamp for value in outputs])
+
+ def test_early_output_after_empty_poll_does_not_warn(self):
+ # An empty first poll defers with the watermark still at the element seed;
+ # the next round's first real output must not be treated as out-of-order
+ # either; the watermark has not advanced past the seed.
+ polls = []
+
+ def poll(unused_element):
+ polls.append(len(polls))
+ if len(polls) == 1:
+ return PollResult.incomplete([])
+ return PollResult.incomplete([_ts('a', 5)])
+
+ _, threadsafe, estimator = self._process(poll, 'k:', Timestamp(10))
+ self.assertEqual(Timestamp(10), estimator.current_watermark())
+ residual, _ = threadsafe.deferred_status()
+ with self.assertNoLogs('apache_beam.io.watch', level='WARNING'):
+ outputs, _, _ = self._process(
+ poll,
+ 'k:',
+ Timestamp(10),
+ restriction=residual,
+ watermark=estimator.current_watermark())
+ self.assertEqual([Timestamp(5)], [value.timestamp for value in outputs])
+
+ def test_explicit_watermark_holds_below_output_time(self):
+ # An explicit watermark below the output's own event time is honored, so
+ # a later, earlier-timestamped output stays on time (the out-of-order-safe
+ # path).
+ def poll(unused_element):
+ return PollResult.incomplete([_ts('a', 10)]).with_watermark(0)
+
+ _, threadsafe, estimator = self._process(poll, 'k:', Timestamp(0))
+ self.assertEqual(Timestamp(0), estimator.current_watermark())
+ residual, _ = threadsafe.deferred_status()
+ self.assertEqual(Timestamp(0), residual.poll_watermark)
+
class WatchEndToEndTest(unittest.TestCase):
def _in_memory_pipeline(self):
@@ -417,6 +776,30 @@ class WatchEndToEndTest(unittest.TestCase):
self.assertEqual(3, _POLL_CALLS['x:'])
self.assertEqual(3, _POLL_CALLS['y:'])
+ def test_timestamp_cursor_dedups_growing_source(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),
+ timestamp_cursor=True))
+ # Each output is emitted exactly once via the high-water-mark cursor,
+ # with no hash set kept, across poll rounds and checkpoints.
+ assert_that(
+ output,
+ equal_to([('x:', 'x:0'), ('x:', 'x:1'), ('x:', 'x:2'), ('y:', 'y:0'),
+ ('y:', 'y:1'), ('y:', 'y:2')]))
+
+ def test_timestamp_cursor_rejects_key_spec(self):
+ with self.assertRaises(ValueError):
+ Watch(
+ _complete_poll,
+ poll_interval=Duration(1),
+ output_key_fn=_first_char,
+ timestamp_cursor=True)
+
def test_output_key_dedups_across_pipeline(self):
with self._in_memory_pipeline() as p:
output = (