blob: a44428af5923f16aa705154b742d3778f89a49da [file]
#
# 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 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
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 _retention_floor
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 MIN_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 _cursor_tracker(restriction, allowed_lateness=Duration(0)):
return _GrowthRestrictionTracker(
restriction,
_identity,
StrUtf8Coder(),
timestamp_cursor=True,
allowed_lateness=allowed_lateness)
def _cursor_results(restriction, result, allowed_lateness=Duration(0)):
return _never_seen_before(
restriction,
result,
_identity,
StrUtf8Coder(),
_retention_floor(restriction, allowed_lateness))
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))
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)
self.assertIsNone(decoded.cursor)
def test_polling_round_trip_preserves_cursor_and_retained_keys(self):
coder = _GrowthStateCoder(StrUtf8Coder(), never())
completed = collections.OrderedDict([(b'a' * 16, Timestamp(42))])
state = _PollingGrowthState(
completed,
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(list(completed.items()), list(decoded.completed.items()))
self.assertEqual(Timestamp(5), decoded.poll_watermark)
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())
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))
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: hash dedup whose keys the cursor retires."""
def test_bounds_the_key_set_to_the_newest_event_time(self):
state = _initial_polling()
result = PollResult.incomplete([_ts('a', 1), _ts('b', 2), _ts('c', 3)])
new_results = _cursor_results(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(Timestamp(3), residual.cursor)
# The two older keys are retired; only the one at the cursor is kept.
self.assertEqual(1, len(residual.completed))
def test_outputs_sharing_an_event_time_are_each_emitted_once(self):
# A bare cursor cannot tell two outputs at one event time apart, so it
# either drops the second or repeats both on the next re-list. The keys
# the cursor still retains are what distinguishes them.
state = _initial_polling()
first = _cursor_results(
state, PollResult.incomplete([_ts('a', 10), _ts('b', 10)]))
self.assertEqual(['a', 'b'], sorted(o.value for o in first.outputs))
tracker = _cursor_tracker(state)
self.assertTrue(tracker.try_claim((first, 0)))
_, residual = tracker.try_split(0)
self.assertEqual(Timestamp(10), residual.cursor)
relist = _cursor_results(
residual,
PollResult.incomplete([_ts('a', 10), _ts('b', 10), _ts('c', 10)]))
self.assertEqual(['c'], [o.value for o in relist.outputs])
def test_drops_outputs_the_cursor_retired(self):
state = _initial_polling()
tracker = _cursor_tracker(state)
first = _cursor_results(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 = _cursor_results(
residual,
PollResult.incomplete([_ts('early', 5), _ts('a', 10), _ts('c', 20)]))
# 'early' is below the floor, 'a' is a retained key, only 'c' is new.
self.assertEqual(['c'], [o.value for o in second.outputs])
resumed = _cursor_tracker(residual)
self.assertTrue(resumed.try_claim((second, 0)))
_, residual = resumed.try_split(0)
self.assertEqual(Timestamp(20), residual.cursor)
def test_allowed_lateness_retains_keys_below_the_cursor(self):
# A wider window keeps deduping outputs that arrive behind the cursor
# instead of taking them as already seen.
lateness = Duration(10)
state = _initial_polling()
tracker = _cursor_tracker(state, lateness)
first = _cursor_results(
state, PollResult.incomplete([_ts('a', 20)]), lateness)
self.assertTrue(tracker.try_claim((first, 0)))
_, residual = tracker.try_split(0)
late = _cursor_results(
residual,
PollResult.incomplete([_ts('a', 20), _ts('late', 12), _ts('old', 5)]),
lateness)
self.assertEqual(['late'], [o.value for o in late.outputs])
def test_a_retired_key_returning_later_is_emitted_again(self):
# What bounding the state costs. A key is retired by the event time it was
# recorded with, so a key that comes back at a later event time, after the
# cursor has moved past the one it was recorded with, has nothing left to
# prove it was seen. This is the case for a file modified after the cursor
# passed it: it is emitted a second time, whatever the key function says
# about updates. Keep the default hash dedup where that matters.
state = _initial_polling()
tracker = _cursor_tracker(state)
first = _cursor_results(state, PollResult.incomplete([_ts('a', 10)]))
self.assertTrue(tracker.try_claim((first, 0)))
_, residual = tracker.try_split(0)
# 'b' moves the cursor past the event time 'a' was recorded with, which
# retires 'a'.
second = _cursor_results(
residual, PollResult.incomplete([_ts('a', 10), _ts('b', 20)]))
self.assertEqual(['b'], [o.value for o in second.outputs])
resumed = _cursor_tracker(residual)
self.assertTrue(resumed.try_claim((second, 0)))
_, residual = resumed.try_split(0)
self.assertEqual([Timestamp(20)], list(residual.completed.values()))
# 'a' now returns above the floor, so it reads as new.
third = _cursor_results(
residual, PollResult.incomplete([_ts('a', 30), _ts('b', 20)]))
self.assertEqual(['a'], [o.value for o in third.outputs])
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 key set stays bounded throughout.
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 = _cursor_results(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(1, len(state.completed))
self.assertEqual([1] * 10, [emitted['f%d' % i] for i in range(10)])
self.assertEqual(Timestamp(10), state.cursor)
def test_round_below_the_cursor_leaves_it_unchanged(self):
state = _initial_polling()
tracker = _cursor_tracker(state)
first = _cursor_results(state, PollResult.incomplete([_ts('a', 10)]))
self.assertTrue(tracker.try_claim((first, 0)))
_, residual1 = tracker.try_split(0)
stale = _cursor_results(
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_retained_keys_and_retired_outputs(self):
# The tracker re-validates a claim, so a round that was not filtered is
# rejected instead of emitting already-seen outputs.
state = _initial_polling()
tracker = _cursor_tracker(state)
first = _cursor_results(state, PollResult.incomplete([_ts('a', 10)]))
self.assertTrue(tracker.try_claim((first, 0)))
_, residual = tracker.try_split(0)
self.assertFalse(
_cursor_tracker(residual).try_claim(
(PollResult.incomplete([_ts('a', 10)]), 0)))
self.assertFalse(
_cursor_tracker(residual).try_claim(
(PollResult.incomplete([_ts('old', 4)]), 0)))
def test_switching_hash_state_to_cursor_keeps_the_keys(self):
# A restriction resumed in cursor mode still holds the hashes from its
# hash rounds, so nothing re-emits; the cursor retires them from there on.
state = _initial_polling()
hash_tracker = _tracker(state)
first = _new_results(state, PollResult.incomplete([_ts('a', 5)]))
self.assertTrue(hash_tracker.try_claim((first, 0)))
_, legacy = hash_tracker.try_split(0)
self.assertIsNone(legacy.cursor)
relist = _cursor_results(
legacy, PollResult.incomplete([_ts('a', 5), _ts('c', 20)]))
self.assertEqual(['c'], [o.value for o in relist.outputs])
tracker = _cursor_tracker(legacy)
self.assertTrue(tracker.try_claim((relist, 0)))
_, residual = tracker.try_split(0)
self.assertEqual(Timestamp(20), residual.cursor)
self.assertEqual(1, len(residual.completed))
def test_hash_round_drops_a_stale_cursor(self):
# The reverse switch: a hash round retains every key, so the cursor that
# would retire them is dropped.
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((_cursor_results(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):
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 _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)])
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,
timestamp_cursor=False):
dofn = _WatchGrowthDoFn(
poll_fn,
never(),
Duration(1),
StrUtf8Coder(),
_identity,
StrUtf8Coder(),
timestamp_cursor)
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())
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):
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_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, with the cursor retiring keys as
# it advances, 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_composes_with_an_output_key(self):
# The cursor bounds the state; the key still decides what counts as seen.
_POLL_CALLS.clear()
with self._in_memory_pipeline() as p:
output = (
p | beam.Create(['x:'])
| Watch(
_growing_poll,
poll_interval=Duration(0.05),
output_key_fn=_first_char,
timestamp_cursor=True))
# Every output shares a key, so only the first one is ever emitted.
assert_that(output, equal_to([('x:', 'x:0')]))
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_infers_coder_from_generic_annotations(self):
# tuple[str, float] and typing.Tuple[str, float] resolve to a tuple coder,
# not the pickling fallback.
def native_poll(element) -> PollResult[tuple[str, float]]:
return PollResult.complete([(element, 1.0)])
def typing_poll(
element) -> PollResult[typing.Tuple[str, float]]: # noqa: UP006
return PollResult.complete([(element, 1.0)])
for poll in (native_poll, typing_poll):
with self._in_memory_pipeline() as p:
output = (
p | beam.Create(['k:']) | Watch(poll, poll_interval=Duration(1)))
self.assertEqual(
typehints.Tuple[str, typehints.Tuple[str, float]],
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()