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 1a43d8de57d [Python] Refactor MatchContinuously onto the Watch
transform (#39461)
1a43d8de57d is described below
commit 1a43d8de57d93a414786e764430c669e1ce3f656
Author: Elia Liu <[email protected]>
AuthorDate: Tue Aug 18 03:44:20 2026 +1000
[Python] Refactor MatchContinuously onto the Watch transform (#39461)
* [Python] Refactor MatchContinuously onto the Watch transform
Route MatchContinuously through Watch when deduplication is enabled. The
polling loop and the set of already-matched file ids now live in the
splittable DoFn restriction, replacing the per-key state DoFns.
* Add a timestamp_cursor option to MatchContinuously
Opt-in timestamp_cursor=True backs deduplication with the Watch
transform's cursor mode, so the restriction holds one timestamp rather
than an id per matched file. The poll stamps each match with its
last-modified time, which is what the cursor dedups on, and the
watermark stays at the poll time.
---
sdks/python/apache_beam/io/fileio.py | 305 ++++++++++++++++++--------
sdks/python/apache_beam/io/fileio_test.py | 345 ++++++++++++++++++++++++++++++
sdks/python/apache_beam/io/watch.py | 265 ++++++++++++-----------
sdks/python/apache_beam/io/watch_test.py | 222 ++++++++++++-------
4 files changed, 846 insertions(+), 291 deletions(-)
diff --git a/sdks/python/apache_beam/io/fileio.py
b/sdks/python/apache_beam/io/fileio.py
index a333b7c8977..fcce83fa59e 100644
--- a/sdks/python/apache_beam/io/fileio.py
+++ b/sdks/python/apache_beam/io/fileio.py
@@ -93,28 +93,35 @@ import logging
import random
import uuid
from collections import namedtuple
-from functools import partial
from typing import Any
from typing import BinaryIO # pylint: disable=unused-import
from typing import Callable
from typing import Iterable
+from typing import Optional
from typing import Union
import apache_beam as beam
+from apache_beam.coders.coders import VarIntCoder
from apache_beam.io import filesystem
from apache_beam.io import filesystems
from apache_beam.io.filesystem import BeamIOError
from apache_beam.io.filesystem import CompressionTypes
+from apache_beam.io.watch import PollFn
+from apache_beam.io.watch import PollResult
+from apache_beam.io.watch import TerminationCondition
+from apache_beam.io.watch import Watch
+from apache_beam.io.watch import never
from apache_beam.options.pipeline_options import GoogleCloudOptions
from apache_beam.options.value_provider import StaticValueProvider
from apache_beam.options.value_provider import ValueProvider
from apache_beam.transforms.periodicsequence import PeriodicImpulse
-from apache_beam.transforms.userstate import CombiningValueStateSpec
from apache_beam.transforms.window import BoundedWindow
from apache_beam.transforms.window import FixedWindows
from apache_beam.transforms.window import GlobalWindow
from apache_beam.transforms.window import IntervalWindow
+from apache_beam.transforms.window import TimestampedValue
from apache_beam.utils.timestamp import MAX_TIMESTAMP
+from apache_beam.utils.timestamp import Duration
from apache_beam.utils.timestamp import Timestamp
__all__ = [
@@ -251,6 +258,115 @@ class _ReadMatchesFn(beam.DoFn):
yield ReadableFile(metadata, self._compression)
+class _PollClock(object):
+ """Shares one clock reading per poll round, so the start gate and the poll
+ budget judge the ``start_timestamp`` boundary consistently."""
+ def __init__(self):
+ self.last_poll_micros: Optional[int] = None
+
+
+class _WatchWindowTermination(TerminationCondition):
+ """Stops after the polls that fall in the ``[start, stop)`` window.
+
+ ``max_polls`` is the ``PeriodicImpulse`` tick count
+ ``ceil((stop - start) / interval)``; polls before ``start`` are waiting
+ rounds and do not consume the budget.
+ """
+ def __init__(self, clock: _PollClock, start_micros: int, max_polls: int):
+ self._clock = clock
+ self._start_micros = start_micros
+ self._max_polls = max_polls
+
+ def for_new_input(self, now, element):
+ return 0
+
+ def on_poll_complete(self, state):
+ poll_micros = self._clock.last_poll_micros
+ if poll_micros is not None and poll_micros >= self._start_micros:
+ return state + 1
+ return state
+
+ def can_stop_polling(self, now, state):
+ return state >= self._max_polls
+
+ def state_coder(self):
+ return VarIntCoder()
+
+
+def _ensure_mtime(metadata: filesystem.FileMetadata) -> float:
+ # A missing (zero) timestamp is rejected because every file would then carry
+ # the same one, and updates could never be told apart.
+ if not metadata.last_updated_in_seconds:
+ raise BeamIOError(
+ 'MatchContinuously deduplicates by last-modified time, but %s reports '
+ 'none.' % metadata.path)
+ return metadata.last_updated_in_seconds
+
+
+def _file_path_key(metadata: filesystem.FileMetadata) -> str:
+ return metadata.path
+
+
+def _file_path_and_mtime_key(
+ metadata: filesystem.FileMetadata) -> tuple[str, float]:
+ # Keying on the last-modified time makes a changed file look new again.
+ return metadata.path, _ensure_mtime(metadata)
+
+
+class _MatchContinuouslyPollFn(PollFn):
+ """Polls a file pattern, honoring empty-match rules.
+
+ A poll before ``start_timestamp`` emits nothing. Matches carry the poll time
+ as their event time, or their last-modified time under ``mtime_timestamps``,
+ where the watermark trails the newest last-modified time for as long as
+ polls keep turning up newer ones.
+ """
+ def __init__(
+ self,
+ empty_match_treatment,
+ start_timestamp,
+ clock=None,
+ mtime_timestamps=False):
+ self._empty_match_treatment = empty_match_treatment
+ self._start_micros = Timestamp.of(start_timestamp).micros
+ self._clock = clock if clock is not None else _PollClock()
+ self._mtime_timestamps = mtime_timestamps
+ # Greatest last-modified time handed out so far, to tell a poll that found
+ # something newer from one that only re-listed what was already there.
+ self._newest_mtime = None # type: Optional[Timestamp]
+
+ def __call__(self, file_pattern: str) -> PollResult[filesystem.FileMetadata]:
+ now = Timestamp.now()
+ self._clock.last_poll_micros = now.micros
+ if now.micros < self._start_micros:
+ return PollResult.incomplete(())
+ match_result = filesystems.FileSystems.match([file_pattern])[0]
+ if (not match_result.metadata_list and
+ not EmptyMatchTreatment.allow_empty_match(file_pattern,
+
self._empty_match_treatment)):
+ raise BeamIOError(
+ 'Empty match for pattern %s. Disallowed.' % file_pattern)
+ if not self._mtime_timestamps:
+ return PollResult.incomplete(
+ match_result.metadata_list, timestamp=now).with_watermark(now)
+ outputs = [
+ TimestampedValue(metadata, Timestamp.of(_ensure_mtime(metadata)))
+ for metadata in match_result.metadata_list
+ ]
+ # A poll that turned up a newer last-modified time has just read the
+ # filesystem clock, so the watermark stops there, capped at the poll time.
+ # A poll that found nothing newer takes the poll time, so a quiet
+ # directory does not stall event-time windows.
+ newest = max((output.timestamp for output in outputs), default=None)
+ if newest is not None and (self._newest_mtime is None or
+ newest > self._newest_mtime):
+ self._newest_mtime = newest
+ watermark = min(newest, now)
+ else:
+ watermark = now
+ return PollResult.incomplete(outputs).with_watermark(watermark)
+
+
class MatchContinuously(beam.PTransform):
"""Checks for new files for a given pattern every interval.
@@ -260,12 +376,16 @@ class MatchContinuously(beam.PTransform):
MatchContinuously is experimental. No backwards-compatibility
guarantees.
- Matching continuously scales poorly, as it is stateful, and requires storing
- file ids in memory. In addition, because it is memory-only, if a pipeline is
- restarted, already processed files will be reprocessed. Consider an alternate
- technique, such as Pub/Sub Notifications
- (https://cloud.google.com/storage/docs/pubsub-notifications)
- when using GCS if possible.
+ Deduplication state is checkpointed, so a runner with checkpointing enabled
+ restores it after a restart and does not reprocess files. That state grows
+ with the number of files matched, unless ``timestamp_cursor`` bounds it. For
+ a growing directory on GCS, consider an alternate technique such as Pub/Sub
+ Notifications (https://cloud.google.com/storage/docs/pubsub-notifications).
+
+ A match carries the poll time as its event time, and the watermark follows
+ the poll time. Under ``timestamp_cursor`` a match carries its last-modified
+ time instead, and the watermark holds at the newest one matched, capped at
+ the poll time, until a poll turns up nothing newer and releases it.
"""
def __init__(
self,
@@ -276,7 +396,8 @@ class MatchContinuously(beam.PTransform):
stop_timestamp=MAX_TIMESTAMP,
match_updated_files=False,
apply_windowing=False,
- empty_match_treatment=EmptyMatchTreatment.ALLOW):
+ empty_match_treatment=EmptyMatchTreatment.ALLOW,
+ timestamp_cursor=False):
"""Initializes a MatchContinuously transform.
Args:
@@ -289,6 +410,12 @@ class MatchContinuously(beam.PTransform):
file with timestamp changes.
apply_windowing: Whether each element should be assigned to
individual window. If false, all elements will reside in global window.
+ timestamp_cursor: (When match_updated_files and has_deduplication are set
+ to True) bound the deduplication state by last-modified time. By
+ default, all file modification history is tracked. If set to true, file
+ modification history prior to the max(mtime of last poll result) are
+ dropped, for better performance. A file that appears with an older
+ last-modified time is then taken as already seen and skipped.
"""
self.file_pattern = file_pattern
@@ -299,44 +426,97 @@ class MatchContinuously(beam.PTransform):
self.match_upd = match_updated_files
self.apply_windowing = apply_windowing
self.empty_match_treatment = empty_match_treatment
- _LOGGER.warning(
- 'Matching Continuously is stateful, and can scale poorly. '
- 'Consider using Pub/Sub Notifications '
- '(https://cloud.google.com/storage/docs/pubsub-notifications) '
- 'if possible')
+ self.timestamp_cursor = timestamp_cursor
+ if timestamp_cursor:
+ if not has_deduplication:
+ raise ValueError(
+ 'MatchContinuously(timestamp_cursor=True) deduplicates, so it '
+ 'requires has_deduplication=True.')
+ if not match_updated_files:
+ _LOGGER.warning(
+ 'MatchContinuously(timestamp_cursor=True) implies '
+ 'match_updated_files=True.')
+ self.match_upd = True
+ else:
+ _LOGGER.warning(
+ 'Matching Continuously is stateful, and can scale poorly. '
+ 'Consider using Pub/Sub Notifications '
+ '(https://cloud.google.com/storage/docs/pubsub-notifications) '
+ 'if possible')
def expand(self, pbegin) -> beam.PCollection[filesystem.FileMetadata]:
- # invoke periodic impulse
- impulse = pbegin | PeriodicImpulse(
- start_timestamp=self.start_ts,
- stop_timestamp=self.stop_ts,
- fire_interval=self.interval)
-
- # match file pattern periodically
- file_pattern = self.file_pattern
- match_files = (
- impulse
- | 'GetFilePattern' >> beam.Map(lambda x: file_pattern)
- | MatchAll(self.empty_match_treatment))
-
- # apply deduplication strategy if required
+ if Duration.of(self.interval).micros <= 0:
+ raise ValueError('MatchContinuously interval must be positive.')
if self.has_deduplication:
- # Making a Key Value so each file has its own state.
- match_files = match_files | 'ToKV' >> beam.Map(lambda x: (x.path, x))
- if self.match_upd:
- match_files = match_files | 'RemoveOldAlreadyRead' >> beam.ParDo(
- _RemoveOldDuplicates())
- else:
- match_files = match_files | 'RemoveAlreadyRead' >> beam.ParDo(
- _RemoveDuplicates())
-
- # apply windowing if required. Apply at last because deduplication relies
on
- # the global window.
+ match_files = self._match_deduplicated(pbegin)
+ else:
+ match_files = self._match_all_each_poll(pbegin)
+
+ # Apply windowing last because dedup relies on the global window.
if self.apply_windowing:
match_files = match_files | beam.WindowInto(FixedWindows(self.interval))
return match_files
+ def _match_deduplicated(self,
+ pbegin) -> beam.PCollection[filesystem.FileMetadata]:
+ # Watch emits each file once per dedup key: the path, joined by the mtime
+ # when matching updated files. stop_timestamp bounds the polls to
+ # [start, stop).
+ clock = _PollClock()
+ if self.stop_ts == MAX_TIMESTAMP:
+ termination = never()
+ else:
+ start_ts = Timestamp.of(self.start_ts)
+ stop_ts = Timestamp.of(self.stop_ts)
+ if stop_ts < start_ts:
+ raise ValueError(
+ 'MatchContinuously stop_timestamp %s precedes start_timestamp %s' %
+ (stop_ts, start_ts))
+ interval_micros = Duration.of(self.interval).micros
+ span_micros = (stop_ts - start_ts).micros
+ # Ceiling division reproduces PeriodicImpulse's tick count; the window
+ # upper bound is exclusive.
+ max_polls = -(-span_micros // interval_micros)
+ if max_polls == 0:
+ # An empty [start, stop) window never ticks; the impulse path keeps
+ # the output empty without Watch's unconditional first poll.
+ return self._match_all_each_poll(pbegin)
+ termination = _WatchWindowTermination(clock, start_ts.micros, max_polls)
+ poll_fn = _MatchContinuouslyPollFn(
+ self.empty_match_treatment,
+ self.start_ts,
+ clock,
+ mtime_timestamps=self.timestamp_cursor)
+ # The key coder is inferred from the key function's return annotation.
+ watch = Watch(
+ poll_fn,
+ poll_interval=self.interval,
+ termination=termination,
+ output_key_fn=(
+ _file_path_and_mtime_key if self.match_upd else _file_path_key),
+ timestamp_cursor=self.timestamp_cursor)
+ # Watch emits (pattern, file) pairs; keep the FileMetadata output type so
+ # downstream transforms stay typed instead of falling back to Any.
+ return (
+ pbegin
+ | 'Impulse' >> beam.Create([self.file_pattern])
+ | 'Watch' >> watch
+ | 'DropPattern' >> beam.Map(lambda kv: kv[1]).with_output_types(
+ filesystem.FileMetadata))
+
+ def _match_all_each_poll(self,
+ pbegin) ->
beam.PCollection[filesystem.FileMetadata]:
+ # No deduplication: re-emit every match on each poll.
+ return (
+ pbegin
+ | PeriodicImpulse(
+ start_timestamp=self.start_ts,
+ stop_timestamp=self.stop_ts,
+ fire_interval=self.interval)
+ | 'GetFilePattern' >> beam.Map(lambda x: self.file_pattern)
+ | MatchAll(self.empty_match_treatment))
+
class ReadMatches(beam.PTransform):
"""Converts each result of MatchFiles() or MatchAll() to a ReadableFile.
@@ -892,50 +1072,3 @@ class _WriteUnshardedRecordsFn(beam.DoFn):
timestamp=key[1].start,
windows=[key[1]] # TODO(pabloem) HOW DO WE GET THE PANE
))
-
-
-class _RemoveDuplicates(beam.DoFn):
- """Internal DoFn that filters out filenames already seen (even though the
file
- has updated)."""
- COUNT_STATE = CombiningValueStateSpec('count', combine_fn=sum)
-
- def process(
- self,
- element: tuple[str, filesystem.FileMetadata],
- count_state=beam.DoFn.StateParam(COUNT_STATE)
- ) -> Iterable[filesystem.FileMetadata]:
-
- path = element[0]
- file_metadata = element[1]
- counter = count_state.read()
-
- if counter == 0:
- count_state.add(1)
- _LOGGER.debug('Generated entry for file %s', path)
- yield file_metadata
- else:
- _LOGGER.debug('File %s was already read, seen %d times', path, counter)
-
-
-class _RemoveOldDuplicates(beam.DoFn):
- """Internal DoFn that filters out filenames already seen and timestamp
- unchanged."""
- TIME_STATE = CombiningValueStateSpec(
- 'count', combine_fn=partial(max, default=0.0))
-
- def process(
- self,
- element: tuple[str, filesystem.FileMetadata],
- time_state=beam.DoFn.StateParam(TIME_STATE)
- ) -> Iterable[filesystem.FileMetadata]:
- path = element[0]
- file_metadata = element[1]
- new_ts = file_metadata.last_updated_in_seconds
- old_ts = time_state.read()
-
- if old_ts < new_ts:
- time_state.add(new_ts)
- _LOGGER.debug('Generated entry for file %s', path)
- yield file_metadata
- else:
- _LOGGER.debug('File %s was already read', path)
diff --git a/sdks/python/apache_beam/io/fileio_test.py
b/sdks/python/apache_beam/io/fileio_test.py
index ce535265ef2..1c650653804 100644
--- a/sdks/python/apache_beam/io/fileio_test.py
+++ b/sdks/python/apache_beam/io/fileio_test.py
@@ -34,7 +34,9 @@ from hamcrest.library.text import stringmatches
import apache_beam as beam
from apache_beam.io import fileio
from apache_beam.io.filebasedsink_test import _TestCaseWithTempDirCleanUp
+from apache_beam.io.filesystem import BeamIOError
from apache_beam.io.filesystem import CompressionTypes
+from apache_beam.io.filesystem import FileMetadata
from apache_beam.io.filesystems import FileSystems
from apache_beam.options.pipeline_options import PipelineOptions
from apache_beam.options.pipeline_options import StandardOptions
@@ -420,6 +422,349 @@ class MatchContinuouslyTest(_TestCaseWithTempDirCleanUp):
assert_that(match_continiously, equal_to(files))
+ def test_poll_fn_gates_on_start_timestamp(self):
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ self._create_temp_file(dir=tempdir)
+ pattern = FileSystems.join(tempdir, '*')
+
+ future_start = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() + 3600)
+ self.assertEqual((), future_start(pattern).outputs)
+
+ past_start = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600)
+ self.assertEqual(1, len(past_start(pattern).outputs))
+
+ def test_poll_fn_disallows_empty_match(self):
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.DISALLOW, Timestamp.now() - 3600)
+ with self.assertRaises(BeamIOError):
+ poll_fn(FileSystems.join(tempdir, 'no-such-file'))
+
+ def test_poll_fn_stamps_outputs_with_poll_time(self):
+ # Matches always carry the poll time as event time; matching updated
+ # files must not change that.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ self._create_temp_file(dir=tempdir)
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600)
+ before = Timestamp.now()
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ after = Timestamp.now()
+ self.assertEqual(1, len(result.outputs))
+ output = result.outputs[0]
+ self.assertLessEqual(before, output.timestamp)
+ self.assertLessEqual(output.timestamp, after)
+ self.assertEqual(result.watermark, output.timestamp)
+
+ def test_match_updated_files_keys_on_path_and_mtime(self):
+ # An updated file dedups as new because its key changes.
+ metadata = FileMetadata('/tmp/a', 1, 1234.5)
+ self.assertEqual(('/tmp/a', 1234.5),
+ fileio._file_path_and_mtime_key(metadata))
+
+ def test_match_updated_files_rejects_missing_mtime(self):
+ # A zero last-modified time is rejected: without mtimes, updates could
+ # never be detected.
+ with self.assertRaises(BeamIOError):
+ fileio._file_path_and_mtime_key(FileMetadata('/tmp/a', 1))
+
+ def test_start_equals_stop_matches_nothing(self):
+ # PeriodicImpulse's [start, stop) tick window is empty when start == stop;
+ # the deduplicated path must skip Watch's unconditional first poll by
+ # falling back to the impulse path, which also keeps the output unbounded.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ self._create_temp_file(dir=tempdir)
+ start = Timestamp.now()
+ with TestPipeline() as p:
+ match_continiously = (
+ p
+ | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'),
+ interval=0.2,
+ start_timestamp=start,
+ stop_timestamp=start))
+ assert_that(match_continiously, equal_to([]))
+
+ def test_rejects_nonpositive_interval(self):
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ with self.assertRaisesRegex(ValueError, 'interval must be positive'):
+ with TestPipeline() as p:
+ _ = p | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'), interval=0)
+
+ def test_watch_window_termination_ignores_pre_start_polls(self):
+ # Polls before start_timestamp are deferred waits and must not consume the
+ # budget, otherwise a future start_timestamp silently drops all output. The
+ # boundary is judged by the poll's own clock reading, so a round straddling
+ # the start cannot consume the budget without having matched.
+ start_micros = Timestamp.of(1000).micros
+ clock = fileio._PollClock()
+ term = fileio._WatchWindowTermination(clock, start_micros, max_polls=2)
+ now = Timestamp.of(999)
+ state = term.for_new_input(now, 'pattern')
+ clock.last_poll_micros = Timestamp.of(999).micros
+ state = term.on_poll_complete(state)
+ state = term.on_poll_complete(state)
+ self.assertFalse(term.can_stop_polling(now, state))
+ clock.last_poll_micros = Timestamp.of(1000).micros
+ state = term.on_poll_complete(state)
+ self.assertFalse(term.can_stop_polling(now, state))
+ state = term.on_poll_complete(state)
+ self.assertTrue(term.can_stop_polling(now, state))
+
+ def test_poll_fn_records_its_clock_reading_for_the_termination(self):
+ # The gate and the poll budget share one reading per round; see _PollClock.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ clock = fileio._PollClock()
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() + 3600, clock)
+ self.assertIsNone(clock.last_poll_micros)
+ poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertIsNotNone(clock.last_poll_micros)
+
+ def test_poll_fn_advances_watermark_on_empty_match(self):
+ # An empty (but allowed) match still carries a watermark so downstream
+ # event-time windows keep progressing when no new files appear.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600)
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertEqual((), result.outputs)
+ self.assertIsNotNone(result.watermark)
+
+ def test_poll_fn_stamps_outputs_with_mtime_for_the_cursor(self):
+ # The cursor dedups on the event time, so a match carries its own mtime.
+ # Sub-millisecond digits are kept, or a cursor taken from them would come
+ # back below the outputs it was taken from and match them all over again.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ path = self._create_temp_file(dir=tempdir)
+ os.utime(path, (1234.567891, 1234.567891))
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertEqual(1, len(result.outputs))
+ self.assertEqual(
+ Timestamp.of(os.path.getmtime(path)), result.outputs[0].timestamp)
+
+ def test_poll_fn_holds_the_mtime_watermark_at_the_newest_match(self):
+ # The filesystem clock can run behind the local one, so a watermark at the
+ # poll time would make the files it has yet to hand out late.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5))
+ os.utime(self._create_temp_file(dir=tempdir), (2345.5, 2345.5))
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertEqual(Timestamp.of(2345.5), result.watermark)
+
+ def test_poll_fn_releases_the_mtime_watermark_once_nothing_is_newer(self):
+ # Holding at the newest match forever would stall a directory that is
+ # merely quiet rather than empty: every poll re-lists the same old files,
+ # and the watermark would sit at their last-modified time while the poll
+ # time ran away from it, so event-time windows would never close.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5))
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ pattern = FileSystems.join(tempdir, '*')
+ self.assertEqual(Timestamp.of(1234.5), poll_fn(pattern).watermark)
+ before = Timestamp.now()
+ quiet = poll_fn(pattern)
+ self.assertEqual(1, len(quiet.outputs))
+ self.assertTrue(before <= quiet.watermark <= Timestamp.now())
+
+ def test_poll_fn_holds_the_mtime_watermark_again_for_a_newer_match(self):
+ # A poll that does turn up a newer file has read the filesystem clock
+ # again, so the hold comes back rather than being spent once.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5))
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ pattern = FileSystems.join(tempdir, '*')
+ poll_fn(pattern)
+ poll_fn(pattern)
+ os.utime(self._create_temp_file(dir=tempdir), (2345.5, 2345.5))
+ self.assertEqual(Timestamp.of(2345.5), poll_fn(pattern).watermark)
+
+ def test_poll_fn_caps_the_mtime_watermark_at_the_poll_time(self):
+ # A filesystem clock running ahead must not carry the watermark with it,
+ # which pins the watermark to the poll time.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ path = self._create_temp_file(dir=tempdir)
+ ahead = Timestamp.now() + 3600
+ os.utime(path, (float(ahead), float(ahead)))
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ before = Timestamp.now()
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertTrue(before <= result.watermark <= Timestamp.now())
+
+ def test_poll_fn_advances_the_mtime_watermark_on_empty_match(self):
+ # No match is no reading of the filesystem clock, so the watermark takes
+ # the poll time and event-time windows keep progressing.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ poll_fn = fileio._MatchContinuouslyPollFn(
+ fileio.EmptyMatchTreatment.ALLOW,
+ Timestamp.now() - 3600,
+ mtime_timestamps=True)
+ before = Timestamp.now()
+ result = poll_fn(FileSystems.join(tempdir, '*'))
+ self.assertEqual((), result.outputs)
+ self.assertTrue(before <= result.watermark <= Timestamp.now())
+
+ def test_timestamp_cursor_rejects_missing_mtime(self):
+ # Without mtimes every match would carry the same event time, so the
+ # cursor would drop everything after the first poll.
+ with self.assertRaises(BeamIOError):
+ fileio._ensure_mtime(FileMetadata('/tmp/a', 1))
+
+ def test_timestamp_cursor_requires_deduplication(self):
+ with self.assertRaisesRegex(ValueError, 'has_deduplication=True'):
+ fileio.MatchContinuously(
+ file_pattern='/tmp/*', has_deduplication=False,
timestamp_cursor=True)
+
+ def test_timestamp_cursor_implies_matching_updated_files(self):
+ # timestamp_cursor forces match_updated_files=True, so an update is a new
+ # key whatever the caller passed.
+ match = fileio.MatchContinuously(
+ file_pattern='/tmp/*', timestamp_cursor=True)
+ self.assertTrue(match.match_upd)
+
+ def test_timestamp_cursor_emits_files_modified_past_the_cursor(self):
+ files = []
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+
+ # Create a file to be matched before pipeline
+ files.append(self._create_temp_file(dir=tempdir))
+ # Add file name that will be created mid-pipeline
+ files.append(FileSystems.join(tempdir, 'extra'))
+
+ interval = 0.2
+ start = Timestamp.now()
+ stop = start + interval + 0.1
+
+ def _create_extra_file(element):
+ writer = FileSystems.create(FileSystems.join(tempdir, 'extra'))
+ writer.close()
+ return element.path
+
+ with TestPipeline() as p:
+ match_continiously = (
+ p
+ | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'),
+ interval=interval,
+ start_timestamp=start,
+ stop_timestamp=stop,
+ timestamp_cursor=True)
+ | beam.Map(_create_extra_file))
+
+ assert_that(match_continiously, equal_to(files))
+
+ def test_timestamp_cursor_skips_files_modified_before_the_cursor(self):
+ # A file that lands with an mtime older than one already emitted sits
+ # behind the cursor, so it never appears.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ first = self._create_temp_file(dir=tempdir)
+
+ interval = 0.2
+ start = Timestamp.now()
+ stop = start + interval + 0.1
+
+ def _create_backdated_file(element):
+ path = FileSystems.join(tempdir, 'backdated')
+ writer = FileSystems.create(path)
+ writer.close()
+ os.utime(path, (1234.5, 1234.5))
+ return element.path
+
+ with TestPipeline() as p:
+ match_continiously = (
+ p
+ | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'),
+ interval=interval,
+ start_timestamp=start,
+ stop_timestamp=stop,
+ timestamp_cursor=True)
+ | beam.Map(_create_backdated_file))
+
+ assert_that(match_continiously, equal_to([first]))
+
+ def test_timestamp_cursor_emits_a_file_sharing_the_newest_mtime(self):
+ # A file landing with the same last-modified time as the newest one
+ # already emitted is still new. Filesystems that report to the
+ # millisecond, GCS among them, hand out such ties routinely.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ first = self._create_temp_file(dir=tempdir)
+ os.utime(first, (1234.5, 1234.5))
+ twin = FileSystems.join(tempdir, 'twin')
+
+ interval = 0.2
+ start = Timestamp.now()
+ stop = start + interval + 0.1
+
+ def _create_twin(element):
+ writer = FileSystems.create(twin)
+ writer.close()
+ os.utime(twin, (1234.5, 1234.5))
+ return element.path
+
+ with TestPipeline() as p:
+ match_continiously = (
+ p
+ | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'),
+ interval=interval,
+ start_timestamp=start,
+ stop_timestamp=stop,
+ timestamp_cursor=True)
+ | beam.Map(_create_twin))
+
+ assert_that(match_continiously, equal_to([first, twin]))
+
+ def test_timestamp_cursor_emits_an_updated_file(self):
+ # The cursor keys on the path and the last-modified time, so an update is
+ # a new key and is matched again. Keying on the path alone would leave the
+ # update out while the key is retained and let it through once the cursor
+ # retired the key, which made the outcome depend on cursor timing.
+ tempdir = '%s%s' % (self._new_tempdir(), os.sep)
+ path = self._create_temp_file(dir=tempdir)
+ os.utime(path, (1234.5, 1234.5))
+
+ interval = 0.2
+ start = Timestamp.now()
+ stop = start + interval + 0.1
+
+ def _touch(element):
+ os.utime(path, (2345.5, 2345.5))
+ return element.path
+
+ with TestPipeline() as p:
+ match_continiously = (
+ p
+ | fileio.MatchContinuously(
+ file_pattern=FileSystems.join(tempdir, '*'),
+ interval=interval,
+ start_timestamp=start,
+ stop_timestamp=stop,
+ timestamp_cursor=True)
+ | beam.Map(_touch))
+
+ assert_that(match_continiously, equal_to([path, path]))
+
class WriteFilesTest(_TestCaseWithTempDirCleanUp):
diff --git a/sdks/python/apache_beam/io/watch.py
b/sdks/python/apache_beam/io/watch.py
index 40b7e451ce6..70adaa44417 100644
--- a/sdks/python/apache_beam/io/watch.py
+++ b/sdks/python/apache_beam/io/watch.py
@@ -35,11 +35,9 @@ 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`.
+seen. If the items returned by the poll function arrive in roughly
+non-decreasing event time, consider setting ``timestamp_cursor=True`` for
+better performance; see :class:`Watch`.
Example::
@@ -274,9 +272,9 @@ class _PollingGrowthState(_GrowthState):
"""Keep-polling state: dedup state, watermark, termination state.
``completed`` maps a 16-byte output-key hash to the event time it was first
- seen; it is insertion-ordered and treated as immutable. In timestamp-cursor
- mode ``completed`` is empty and ``cursor`` is the greatest emitted event
- time.
+ seen; it is insertion-ordered and treated as immutable. ``cursor`` is the
+ greatest emitted event time, set only in timestamp-cursor mode, where it
+ retires the keys it has moved past and bounds ``completed``.
"""
completed: 'collections.OrderedDict[bytes, Timestamp]'
poll_watermark: Optional[Timestamp]
@@ -337,22 +335,25 @@ class _GrowthStateCoder(Coder):
A ``(tag, payload)`` envelope selects the variant; the payload is a
variant-specific :class:`TupleCoder`. ``completed`` is encoded as an ordered
list of ``(hash, timestamp)`` pairs so insertion order survives a round
- trip. A cursor state encodes only its termination state and cursor; the
- watermark is restored from the estimator state the runner persists. Hash
- states keep the pre-cursor byte format. This format is internal to the
- Python SDK.
+ trip. A cursor state adds the cursor to that payload; the keys it carries
+ are only those the cursor has yet to retire. States without a cursor 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())
+ completed_coder = coders.ListCoder(
+ TupleCoder([coders.BytesCoder(), TimestampCoder()]))
self._envelope_coder = TupleCoder(
[coders.VarIntCoder(), coders.BytesCoder()])
self._polling_coder = TupleCoder([
termination.state_coder(),
nullable_ts,
- coders.ListCoder(TupleCoder([coders.BytesCoder(), TimestampCoder()])),
+ completed_coder,
])
self._cursor_polling_coder = TupleCoder([
termination.state_coder(),
+ nullable_ts,
+ completed_coder,
TimestampCoder(),
])
self._non_polling_coder = TupleCoder([
@@ -368,8 +369,11 @@ class _GrowthStateCoder(Coder):
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))
+ payload = self._cursor_polling_coder.encode((
+ state.termination_state,
+ state.poll_watermark,
+ list(state.completed.items()),
+ state.cursor))
return self._envelope_coder.encode((_StateTag.CURSOR_POLLING, payload))
payload = self._non_polling_coder.encode(
(state.pending.watermark, list(state.pending.outputs)))
@@ -386,9 +390,13 @@ class _GrowthStateCoder(Coder):
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)
+ termination_state, poll_watermark, items, cursor = (
+ self._cursor_polling_coder.decode(payload))
return _PollingGrowthState(
- collections.OrderedDict(), None, termination_state, cursor)
+ collections.OrderedDict(items),
+ poll_watermark,
+ termination_state,
+ cursor)
raise ValueError('unknown Watch growth state tag: %r' % (tag, ))
def is_deterministic(self) -> bool:
@@ -418,20 +426,39 @@ def _max_watermark(left: Optional[Timestamp],
return max(left, right)
+def _retention_floor(
+ restriction: _PollingGrowthState,
+ allowed_lateness: Duration) -> Optional[Timestamp]:
+ """The event time below which a key is retired from ``completed``.
+
+ ``None`` leaves every key retained, which is hash dedup with unbounded
+ state. Otherwise an output at or above the floor is still deduped by key,
+ and one below it is taken as already seen.
+ """
+ if restriction.cursor is None:
+ return None
+ return restriction.cursor - allowed_lateness
+
+
def _never_seen_before(
restriction: _PollingGrowthState,
result: PollResult,
key_fn: Callable[[Any], Any],
- key_coder: Coder) -> PollResult:
+ key_coder: Coder,
+ floor: Optional[Timestamp] = None) -> PollResult:
"""Filters a poll result down to outputs whose key was never seen before.
Dedup hashes ``key_fn(output.value)`` against the restriction's completed
- set, also dropping in-round duplicates. Outputs are sorted by timestamp so
- the earliest one can serve as the inferred watermark.
+ set, also dropping in-round duplicates. An output below ``floor`` is dropped
+ without consulting the set, since the key that would prove it seen has been
+ retired. Outputs are sorted by timestamp so the earliest one can serve as
+ the inferred watermark.
"""
new_outputs = []
seen_this_round = set()
for output in result.outputs:
+ if floor is not None and output.timestamp < floor:
+ continue
key_hash = _hash_output(key_coder, key_fn(output.value))
if key_hash in restriction.completed or key_hash in seen_this_round:
continue
@@ -441,28 +468,18 @@ 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))
+def _retained(
+ completed: 'collections.OrderedDict[bytes, Timestamp]',
+ floor: Optional[Timestamp]) -> 'collections.OrderedDict[bytes, Timestamp]':
+ """Drops the keys the floor has retired, which bounds the state."""
+ if floor is None:
+ return completed
+ retained = collections.OrderedDict(
+ (key_hash, timestamp) for key_hash, timestamp in completed.items()
+ if timestamp >= floor)
+ # Reuse the parent map when the floor retired nothing, so a round that adds
+ # no key leaves the state object untouched.
+ return completed if len(retained) == len(completed) else retained
class _GrowthRestrictionTracker(iobase.RestrictionTracker):
@@ -479,11 +496,13 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
restriction: _GrowthState,
key_fn: Callable[[Any], Any],
key_coder: Coder,
- timestamp_cursor: bool = False):
+ timestamp_cursor: bool = False,
+ allowed_lateness: Duration = Duration(0)):
self._restriction = restriction
self._key_fn = key_fn
self._key_coder = key_coder
self._timestamp_cursor = timestamp_cursor
+ self._allowed_lateness = allowed_lateness
self._claimed_result = None # type: Optional[PollResult]
self._claimed_termination_state = None # type: Any
self._claimed_hashes = None # type: Optional[collections.OrderedDict]
@@ -492,6 +511,12 @@ class _GrowthRestrictionTracker(iobase.RestrictionTracker):
def _hash(self, value: Any) -> bytes:
return _hash_output(self._key_coder, self._key_fn(value))
+ def _floor(self) -> Optional[Timestamp]:
+ if not self._timestamp_cursor or not isinstance(self._restriction,
+ _PollingGrowthState):
+ return None
+ return _retention_floor(self._restriction, self._allowed_lateness)
+
def current_restriction(self) -> _GrowthState:
return self._restriction
@@ -505,35 +530,23 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
if self._should_stop:
return False
result, termination_state = position
- claimed_hashes = None
- if self._timestamp_cursor:
- # Cursor mode validates by timestamps and never hashes.
- if isinstance(self._restriction, _PollingGrowthState):
- cursor = _cursor_of(self._restriction)
- if cursor is not None and any(output.timestamp <= cursor
- for output in result.outputs):
- return False
- else:
- # Values may lack stable equality without a deterministic coder, so a
- # replay is identified by its timestamps.
- expected = sorted(
- output.timestamp for output in self._restriction.pending.outputs)
- if expected != sorted(output.timestamp for output in result.outputs):
- return False
+ 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
+ floor = self._floor()
+ if floor is not None and any(output.timestamp < floor
+ for output in result.outputs):
+ return False
else:
- claimed_hashes = collections.OrderedDict()
- for output in result.outputs:
- claimed_hashes[self._hash(output.value)] = output.timestamp
- if isinstance(self._restriction, _PollingGrowthState):
- if any(key_hash in self._restriction.completed
- for key_hash in claimed_hashes):
- return False
- else:
- expected = set(
- self._hash(output.value)
- for output in self._restriction.pending.outputs)
- if expected != set(claimed_hashes):
- return False
+ 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
@@ -554,24 +567,21 @@ class
_GrowthRestrictionTracker(iobase.RestrictionTracker):
else:
# The primary becomes a replay of the claimed round; the residual
# resumes polling with the claimed round folded into the dedup state.
- # A state holds hashes or a cursor, never both, so each mode drops the
- # other mode's leftovers after a switch.
- if self._timestamp_cursor:
- completed = self._restriction.completed
- if completed:
- completed = collections.OrderedDict()
- if self._claimed_result.outputs:
- cursor = self._claimed_result.outputs[-1].timestamp
- else:
- cursor = _cursor_of(self._restriction)
- elif self._claimed_hashes:
+ if 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
+ cursor = None
+ if self._timestamp_cursor:
+ # The cursor only ever advances, and retires the keys it moves past.
+ cursor = _max_watermark(
+ self._restriction.cursor,
+ max((output.timestamp for output in self._claimed_result.outputs),
+ default=None))
+ if cursor is not None:
+ completed = _retained(completed, cursor - self._allowed_lateness)
residual = _PollingGrowthState(
completed,
_max_watermark(
@@ -623,6 +633,7 @@ class _WatchGrowthDoFn(core.DoFn, core.RestrictionProvider):
key_fn: Callable[[Any], Any],
key_coder: Coder,
timestamp_cursor: bool = False,
+ allowed_lateness: Duration = Duration(0),
now_fn: Optional[Callable[[], float]] = None):
self._poll_fn = poll_fn
self._termination = termination
@@ -631,6 +642,7 @@ class _WatchGrowthDoFn(core.DoFn, core.RestrictionProvider):
self._key_fn = key_fn
self._key_coder = key_coder
self._timestamp_cursor = timestamp_cursor
+ self._allowed_lateness = allowed_lateness
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.
@@ -645,7 +657,11 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
def create_tracker(self, restriction) -> _GrowthRestrictionTracker:
return _GrowthRestrictionTracker(
- restriction, self._key_fn, self._key_coder, self._timestamp_cursor)
+ restriction,
+ self._key_fn,
+ self._key_coder,
+ self._timestamp_cursor,
+ self._allowed_lateness)
def restriction_coder(self) -> Coder:
return self._restriction_coder
@@ -685,11 +701,11 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
result = self._poll_fn(element)
# Read the clock after the poll so a slow poll counts against termination.
now = Timestamp.of(self._now())
+ floor = None
if self._timestamp_cursor:
- new_results = _past_cursor(restriction, result)
- else:
- new_results = _never_seen_before(
- restriction, result, self._key_fn, self._key_coder)
+ floor = _retention_floor(restriction, self._allowed_lateness)
+ new_results = _never_seen_before(
+ restriction, result, self._key_fn, self._key_coder, floor)
termination_state = restriction.termination_state
if new_results.outputs:
termination_state = self._termination.on_seen_new_output(
@@ -716,9 +732,10 @@ class _WatchGrowthDoFn(core.DoFn,
core.RestrictionProvider):
else:
watermark = None
if self._timestamp_cursor:
- new_cursor = (
- new_results.outputs[-1].timestamp
- if new_results.outputs else restriction.cursor)
+ # Outputs are timestamp-sorted, so the last one is the greatest.
+ new_cursor = _max_watermark(
+ restriction.cursor,
+ new_results.outputs[-1].timestamp if new_results.outputs else None)
if new_cursor is not None and new_cursor >= MAX_TIMESTAMP:
# A cursor at MAX is terminal; polling on would only drop outputs.
return
@@ -812,14 +829,17 @@ 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``.
+ timestamp_cursor: bound the dedup state by event time, for better
+ performance. An output more than ``allowed_lateness`` behind the greatest
+ event time emitted so far is taken as already seen and dropped, so this
+ suits sources whose outputs arrive in roughly non-decreasing event time;
+ keep the default for sources that can hand out much older outputs at any
+ time.
+ allowed_lateness: how far behind the greatest emitted event time an output
+ is still deduplicated by key, as a :class:`Duration` or in seconds.
+ Widen it for a source whose outputs arrive out of order, at the cost of a
+ larger state. Ignored unless ``timestamp_cursor`` is set; defaults to
+ zero.
now_fn: clock used for termination decisions; tests can inject one.
"""
def __init__(
@@ -831,15 +851,16 @@ class Watch(PTransform):
output_key_fn: Optional[Callable[[Any], Any]] = None,
output_key_coder: Optional[Coder] = None,
timestamp_cursor: bool = False,
+ allowed_lateness=0,
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):
+ allowed_lateness = _as_duration(allowed_lateness)
+ if allowed_lateness < Duration(0):
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.')
+ 'Watch allowed_lateness must not be negative, got %s' %
+ allowed_lateness)
self._poll_fn = poll_fn
self._poll_interval = _as_duration(poll_interval)
self._termination = termination or never()
@@ -847,6 +868,7 @@ class Watch(PTransform):
self._output_key_fn = output_key_fn
self._output_key_coder = output_key_coder
self._timestamp_cursor = timestamp_cursor
+ self._allowed_lateness = allowed_lateness
self._now = now_fn
def expand(self, pcoll):
@@ -855,28 +877,22 @@ 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._timestamp_cursor:
- # Cursor dedup never hashes, so no deterministic key coder is needed.
+ 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 = output_coder
+ key_coder = self._output_key_coder or output_coder
else:
- if self._output_key_fn is None:
- # The output is its own dedup key, so the key coder is the output
- # coder.
- key_fn = _identity
- key_coder = self._output_key_coder or output_coder
- else:
- key_fn = self._output_key_fn
- key_coder = self._output_key_coder or _coder_for_hint(
- _return_type(self._output_key_fn))
- # Dedup hashes the encoded key, so equal keys must encode equally; use
- # the coder's deterministic form and reject coders that have none.
- key_coder = key_coder.as_deterministic_coder(
- self.label,
- 'Watch dedups by hashing the encoded output key, so the key coder '
- 'must be deterministic. %s has no deterministic form; pass a '
- 'deterministic output_key_coder (or output_coder).' %
- type(key_coder).__name__)
+ 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.
@@ -894,6 +910,7 @@ class Watch(PTransform):
key_fn,
key_coder,
self._timestamp_cursor,
+ self._allowed_lateness,
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 a07f98bfa8d..a44428af592 100644
--- a/sdks/python/apache_beam/io/watch_test.py
+++ b/sdks/python/apache_beam/io/watch_test.py
@@ -37,8 +37,8 @@ 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 _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
@@ -56,6 +56,7 @@ 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
@@ -77,9 +78,22 @@ def _tracker(restriction):
return _GrowthRestrictionTracker(restriction, _identity, StrUtf8Coder())
-def _cursor_tracker(restriction):
+def _cursor_tracker(restriction, allowed_lateness=Duration(0)):
return _GrowthRestrictionTracker(
- restriction, _identity, StrUtf8Coder(), timestamp_cursor=True)
+ 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)):
@@ -133,17 +147,18 @@ class GrowthStateCoderTest(unittest.TestCase):
self.assertEqual(termination_state, decoded.termination_state)
self.assertIsNone(decoded.cursor)
- def test_polling_round_trip_preserves_cursor(self):
+ 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(
- collections.OrderedDict(),
+ 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(0, len(decoded.completed))
- self.assertIsNone(decoded.poll_watermark) # not part of the payload
+ 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
@@ -307,64 +322,121 @@ class GrowthTrackerTest(unittest.TestCase):
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):
+ """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 = _past_cursor(state, result)
+ 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(0, len(residual.completed)) # no hash set
- self.assertEqual(Timestamp(3), residual.cursor) # high-water mark
+ 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_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.
+ 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)
- 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(
+ 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)]))
- self.assertEqual(['c'], [o.value for o in second.outputs]) # only 20 > 10
+ # '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 state never accumulates a hash set.
+ # 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 = _past_cursor(state, result)
+ 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(0, len(state.completed)) # O(1) throughout
+ 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_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.
+ def test_round_below_the_cursor_leaves_it_unchanged(self):
state = _initial_polling()
tracker = _cursor_tracker(state)
- first = _past_cursor(state, PollResult.incomplete([_ts('a', 10)]))
+ first = _cursor_results(state, PollResult.incomplete([_ts('a', 10)]))
self.assertTrue(tracker.try_claim((first, 0)))
_, residual1 = tracker.try_split(0)
- stale = _past_cursor(
+ stale = _cursor_results(
residual1, PollResult.incomplete([_ts('a', 10), _ts('old', 4)]))
self.assertEqual((), stale.outputs)
resumed = _cursor_tracker(residual1)
@@ -373,60 +445,42 @@ class TimestampCursorTest(unittest.TestCase):
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.
+ 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 = _past_cursor(state, PollResult.incomplete([_ts('a', 10)]))
+ first = _cursor_results(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])
+ 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((new_results, 0)))
+ self.assertTrue(tracker.try_claim((relist, 0)))
_, residual = tracker.try_split(0)
- self.assertEqual(0, len(residual.completed))
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 drops the cursor, so a state never
- # holds hashes and a cursor at the same time.
+ # 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)
@@ -444,7 +498,7 @@ class TimestampCursorTest(unittest.TestCase):
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)))
+ self.assertTrue(tracker.try_claim((_cursor_results(state, result), 0)))
_, residual = tracker.try_split(0)
return coder.encode(residual)
@@ -785,20 +839,26 @@ class WatchEndToEndTest(unittest.TestCase):
_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.
+ # 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_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_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: