| # |
| # 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() |