|
93 | 93 | import random |
94 | 94 | import uuid |
95 | 95 | from collections import namedtuple |
96 | | -from functools import partial |
97 | 96 | from typing import Any |
98 | 97 | from typing import BinaryIO # pylint: disable=unused-import |
99 | 98 | from typing import Callable |
100 | 99 | from typing import Iterable |
101 | 100 | from typing import Union |
102 | 101 |
|
103 | 102 | import apache_beam as beam |
| 103 | +from apache_beam.coders.coders import StrUtf8Coder |
| 104 | +from apache_beam.coders.coders import VarIntCoder |
104 | 105 | from apache_beam.io import filesystem |
105 | 106 | from apache_beam.io import filesystems |
106 | 107 | from apache_beam.io.filesystem import BeamIOError |
107 | 108 | from apache_beam.io.filesystem import CompressionTypes |
| 109 | +from apache_beam.io.watch import PollFn |
| 110 | +from apache_beam.io.watch import PollResult |
| 111 | +from apache_beam.io.watch import TerminationCondition |
| 112 | +from apache_beam.io.watch import Watch |
| 113 | +from apache_beam.io.watch import never |
108 | 114 | from apache_beam.options.pipeline_options import GoogleCloudOptions |
109 | 115 | from apache_beam.options.value_provider import StaticValueProvider |
110 | 116 | from apache_beam.options.value_provider import ValueProvider |
111 | 117 | from apache_beam.transforms.periodicsequence import PeriodicImpulse |
112 | | -from apache_beam.transforms.userstate import CombiningValueStateSpec |
113 | 118 | from apache_beam.transforms.window import BoundedWindow |
114 | 119 | from apache_beam.transforms.window import FixedWindows |
115 | 120 | from apache_beam.transforms.window import GlobalWindow |
116 | 121 | from apache_beam.transforms.window import IntervalWindow |
| 122 | +from apache_beam.transforms.window import TimestampedValue |
117 | 123 | from apache_beam.utils.timestamp import MAX_TIMESTAMP |
| 124 | +from apache_beam.utils.timestamp import Duration |
118 | 125 | from apache_beam.utils.timestamp import Timestamp |
119 | 126 |
|
120 | 127 | __all__ = [ |
@@ -251,6 +258,79 @@ def process( |
251 | 258 | yield ReadableFile(metadata, self._compression) |
252 | 259 |
|
253 | 260 |
|
| 261 | +class _WatchWindowTermination(TerminationCondition): |
| 262 | + """Stops after the polls that fall in the ``[start, stop)`` window. |
| 263 | +
|
| 264 | + ``max_polls`` is the ``PeriodicImpulse`` tick count |
| 265 | + ``ceil((stop - start) / interval)``, so the number of polls is independent of |
| 266 | + how fast the runner reschedules deferred work. Only polls at or after |
| 267 | + ``start`` count toward the budget; earlier polls are deferred waits that must |
| 268 | + not consume it, matching ``PeriodicImpulse``, which never advances a tick |
| 269 | + while waiting for the start time. |
| 270 | + """ |
| 271 | + def __init__(self, start_micros: int, max_polls: int): |
| 272 | + self._start_micros = start_micros |
| 273 | + self._max_polls = max_polls |
| 274 | + |
| 275 | + def for_new_input(self, now, element): |
| 276 | + return 0 |
| 277 | + |
| 278 | + def on_poll_complete(self, now, state): |
| 279 | + if now.micros >= self._start_micros: |
| 280 | + return state + 1 |
| 281 | + return state |
| 282 | + |
| 283 | + def can_stop_polling(self, now, state): |
| 284 | + return state >= self._max_polls |
| 285 | + |
| 286 | + def state_coder(self): |
| 287 | + return VarIntCoder() |
| 288 | + |
| 289 | + |
| 290 | +def _file_path_key(metadata: filesystem.FileMetadata) -> str: |
| 291 | + return metadata.path |
| 292 | + |
| 293 | + |
| 294 | +class _MatchContinuouslyPollFn(PollFn): |
| 295 | + """Polls a file pattern for ``MatchContinuously``, honoring empty-match rules. |
| 296 | +
|
| 297 | + Emits no outputs before ``start_timestamp`` so polling can start ahead of the |
| 298 | + first intended match. Normally each match is stamped with the poll time as |
| 299 | + its event time. When matching updated files, each match is stamped with the |
| 300 | + file's last-modified time so ``Watch(use_timestamp=True)`` can emit the same |
| 301 | + path again when that timestamp changes. The watermark advances to the poll |
| 302 | + time so downstream event-time windows keep progressing even when a poll finds |
| 303 | + no new files. |
| 304 | + """ |
| 305 | + def __init__( |
| 306 | + self, |
| 307 | + empty_match_treatment, |
| 308 | + start_timestamp, |
| 309 | + use_metadata_timestamp: bool = False): |
| 310 | + self._empty_match_treatment = empty_match_treatment |
| 311 | + self._start_micros = Timestamp.of(start_timestamp).micros |
| 312 | + self._use_metadata_timestamp = use_metadata_timestamp |
| 313 | + |
| 314 | + def __call__(self, file_pattern: str) -> PollResult: |
| 315 | + now = Timestamp.now() |
| 316 | + if now.micros < self._start_micros: |
| 317 | + return PollResult.incomplete(()) |
| 318 | + match_result = filesystems.FileSystems.match([file_pattern])[0] |
| 319 | + if (not match_result.metadata_list and |
| 320 | + not EmptyMatchTreatment.allow_empty_match(file_pattern, |
| 321 | + self._empty_match_treatment)): |
| 322 | + raise BeamIOError( |
| 323 | + 'Empty match for pattern %s. Disallowed.' % file_pattern) |
| 324 | + if self._use_metadata_timestamp: |
| 325 | + outputs = [ |
| 326 | + TimestampedValue(metadata, metadata.last_updated_in_seconds) |
| 327 | + for metadata in match_result.metadata_list |
| 328 | + ] |
| 329 | + return PollResult.incomplete(outputs).with_watermark(now) |
| 330 | + return PollResult.incomplete( |
| 331 | + match_result.metadata_list, timestamp=now).with_watermark(now) |
| 332 | + |
| 333 | + |
254 | 334 | class MatchContinuously(beam.PTransform): |
255 | 335 | """Checks for new files for a given pattern every interval. |
256 | 336 |
|
@@ -306,37 +386,72 @@ def __init__( |
306 | 386 | 'if possible') |
307 | 387 |
|
308 | 388 | def expand(self, pbegin) -> beam.PCollection[filesystem.FileMetadata]: |
309 | | - # invoke periodic impulse |
310 | | - impulse = pbegin | PeriodicImpulse( |
311 | | - start_timestamp=self.start_ts, |
312 | | - stop_timestamp=self.stop_ts, |
313 | | - fire_interval=self.interval) |
314 | | - |
315 | | - # match file pattern periodically |
316 | | - file_pattern = self.file_pattern |
317 | | - match_files = ( |
318 | | - impulse |
319 | | - | 'GetFilePattern' >> beam.Map(lambda x: file_pattern) |
320 | | - | MatchAll(self.empty_match_treatment)) |
321 | | - |
322 | | - # apply deduplication strategy if required |
323 | 389 | if self.has_deduplication: |
324 | | - # Making a Key Value so each file has its own state. |
325 | | - match_files = match_files | 'ToKV' >> beam.Map(lambda x: (x.path, x)) |
326 | | - if self.match_upd: |
327 | | - match_files = match_files | 'RemoveOldAlreadyRead' >> beam.ParDo( |
328 | | - _RemoveOldDuplicates()) |
329 | | - else: |
330 | | - match_files = match_files | 'RemoveAlreadyRead' >> beam.ParDo( |
331 | | - _RemoveDuplicates()) |
332 | | - |
333 | | - # apply windowing if required. Apply at last because deduplication relies on |
334 | | - # the global window. |
| 390 | + match_files = self._match_deduplicated(pbegin) |
| 391 | + else: |
| 392 | + match_files = self._match_all_each_poll(pbegin) |
| 393 | + |
| 394 | + # Apply windowing last because dedup relies on the global window. |
335 | 395 | if self.apply_windowing: |
336 | 396 | match_files = match_files | beam.WindowInto(FixedWindows(self.interval)) |
337 | 397 |
|
338 | 398 | return match_files |
339 | 399 |
|
| 400 | + def _match_deduplicated(self, |
| 401 | + pbegin) -> beam.PCollection[filesystem.FileMetadata]: |
| 402 | + # The Watch transform polls the pattern and emits each file once per path. |
| 403 | + # When matching updated files, the poll function timestamps each output with |
| 404 | + # the file's last-modified time and Watch includes that timestamp in dedup. |
| 405 | + # stop_timestamp bounds the watch to the polls that fall in [start, stop). |
| 406 | + if self.stop_ts == MAX_TIMESTAMP: |
| 407 | + termination = never() |
| 408 | + else: |
| 409 | + start_ts = Timestamp.of(self.start_ts) |
| 410 | + stop_ts = Timestamp.of(self.stop_ts) |
| 411 | + if stop_ts < start_ts: |
| 412 | + raise ValueError( |
| 413 | + 'MatchContinuously stop_timestamp %s precedes start_timestamp %s' % |
| 414 | + (stop_ts, start_ts)) |
| 415 | + interval_micros = Duration.of(self.interval).micros |
| 416 | + if interval_micros <= 0: |
| 417 | + raise ValueError('MatchContinuously interval must be positive.') |
| 418 | + span_micros = (stop_ts - start_ts).micros |
| 419 | + # Ceiling division reproduces PeriodicImpulse's tick count; the window |
| 420 | + # upper bound is exclusive. |
| 421 | + max_polls = -(-span_micros // interval_micros) |
| 422 | + termination = _WatchWindowTermination(start_ts.micros, max_polls) |
| 423 | + file_pattern = self.file_pattern |
| 424 | + poll_fn = _MatchContinuouslyPollFn( |
| 425 | + self.empty_match_treatment, self.start_ts, self.match_upd) |
| 426 | + watch = Watch( |
| 427 | + poll_fn, |
| 428 | + poll_interval=self.interval, |
| 429 | + termination=termination, |
| 430 | + output_key_fn=_file_path_key, |
| 431 | + output_key_coder=StrUtf8Coder(), |
| 432 | + use_timestamp=self.match_upd) |
| 433 | + # Watch emits (pattern, file) pairs; keep the FileMetadata output type so |
| 434 | + # downstream transforms stay typed instead of falling back to Any. |
| 435 | + return ( |
| 436 | + pbegin |
| 437 | + | 'Impulse' >> beam.Create([file_pattern]) |
| 438 | + | 'Watch' >> watch |
| 439 | + | 'DropPattern' >> beam.Map(lambda kv: kv[1]).with_output_types( |
| 440 | + filesystem.FileMetadata)) |
| 441 | + |
| 442 | + def _match_all_each_poll(self, |
| 443 | + pbegin) -> beam.PCollection[filesystem.FileMetadata]: |
| 444 | + # No deduplication: re-emit every match on each poll. |
| 445 | + file_pattern = self.file_pattern |
| 446 | + return ( |
| 447 | + pbegin |
| 448 | + | PeriodicImpulse( |
| 449 | + start_timestamp=self.start_ts, |
| 450 | + stop_timestamp=self.stop_ts, |
| 451 | + fire_interval=self.interval) |
| 452 | + | 'GetFilePattern' >> beam.Map(lambda x: file_pattern) |
| 453 | + | MatchAll(self.empty_match_treatment)) |
| 454 | + |
340 | 455 |
|
341 | 456 | class ReadMatches(beam.PTransform): |
342 | 457 | """Converts each result of MatchFiles() or MatchAll() to a ReadableFile. |
@@ -892,50 +1007,3 @@ def finish_bundle(self): |
892 | 1007 | timestamp=key[1].start, |
893 | 1008 | windows=[key[1]] # TODO(pabloem) HOW DO WE GET THE PANE |
894 | 1009 | )) |
895 | | - |
896 | | - |
897 | | -class _RemoveDuplicates(beam.DoFn): |
898 | | - """Internal DoFn that filters out filenames already seen (even though the file |
899 | | - has updated).""" |
900 | | - COUNT_STATE = CombiningValueStateSpec('count', combine_fn=sum) |
901 | | - |
902 | | - def process( |
903 | | - self, |
904 | | - element: tuple[str, filesystem.FileMetadata], |
905 | | - count_state=beam.DoFn.StateParam(COUNT_STATE) |
906 | | - ) -> Iterable[filesystem.FileMetadata]: |
907 | | - |
908 | | - path = element[0] |
909 | | - file_metadata = element[1] |
910 | | - counter = count_state.read() |
911 | | - |
912 | | - if counter == 0: |
913 | | - count_state.add(1) |
914 | | - _LOGGER.debug('Generated entry for file %s', path) |
915 | | - yield file_metadata |
916 | | - else: |
917 | | - _LOGGER.debug('File %s was already read, seen %d times', path, counter) |
918 | | - |
919 | | - |
920 | | -class _RemoveOldDuplicates(beam.DoFn): |
921 | | - """Internal DoFn that filters out filenames already seen and timestamp |
922 | | - unchanged.""" |
923 | | - TIME_STATE = CombiningValueStateSpec( |
924 | | - 'count', combine_fn=partial(max, default=0.0)) |
925 | | - |
926 | | - def process( |
927 | | - self, |
928 | | - element: tuple[str, filesystem.FileMetadata], |
929 | | - time_state=beam.DoFn.StateParam(TIME_STATE) |
930 | | - ) -> Iterable[filesystem.FileMetadata]: |
931 | | - path = element[0] |
932 | | - file_metadata = element[1] |
933 | | - new_ts = file_metadata.last_updated_in_seconds |
934 | | - old_ts = time_state.read() |
935 | | - |
936 | | - if old_ts < new_ts: |
937 | | - time_state.add(new_ts) |
938 | | - _LOGGER.debug('Generated entry for file %s', path) |
939 | | - yield file_metadata |
940 | | - else: |
941 | | - _LOGGER.debug('File %s was already read', path) |
|
0 commit comments