9393import random
9494import uuid
9595from collections import namedtuple
96- from functools import partial
9796from typing import Any
9897from typing import BinaryIO # pylint: disable=unused-import
9998from typing import Callable
10099from typing import Iterable
101100from typing import Union
102101
103102import apache_beam as beam
103+ from apache_beam .coders .coders import StrUtf8Coder
104+ from apache_beam .coders .coders import VarIntCoder
104105from apache_beam .io import filesystem
105106from apache_beam .io import filesystems
106107from apache_beam .io .filesystem import BeamIOError
107108from 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
108114from apache_beam .options .pipeline_options import GoogleCloudOptions
109115from apache_beam .options .value_provider import StaticValueProvider
110116from apache_beam .options .value_provider import ValueProvider
111117from apache_beam .transforms .periodicsequence import PeriodicImpulse
112- from apache_beam .transforms .userstate import CombiningValueStateSpec
113118from apache_beam .transforms .window import BoundedWindow
114119from apache_beam .transforms .window import FixedWindows
115120from apache_beam .transforms .window import GlobalWindow
116121from apache_beam .transforms .window import IntervalWindow
122+ from apache_beam .transforms .window import TimestampedValue
117123from apache_beam .utils .timestamp import MAX_TIMESTAMP
124+ from apache_beam .utils .timestamp import Duration
118125from apache_beam .utils .timestamp import Timestamp
119126
120127__all__ = [
@@ -251,6 +258,79 @@ def process(
251258 yield ReadableFile (metadata , self ._compression )
252259
253260
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+
254334class MatchContinuously (beam .PTransform ):
255335 """Checks for new files for a given pattern every interval.
256336
@@ -261,10 +341,11 @@ class MatchContinuously(beam.PTransform):
261341 guarantees.
262342
263343 Matching continuously scales poorly, as it is stateful, and requires storing
264- file ids in memory. In addition, because it is memory-only, if a pipeline is
265- restarted, already processed files will be reprocessed. Consider an alternate
266- technique, such as Pub/Sub Notifications
267- (https://cloud.google.com/storage/docs/pubsub-notifications)
344+ file ids for every file the pattern has matched. With ``has_deduplication``
345+ enabled those ids are kept in the splittable DoFn restriction, so a runner
346+ with checkpointing enabled restores them after a restart and does not
347+ reprocess files. Consider an alternate technique, such as Pub/Sub
348+ Notifications (https://cloud.google.com/storage/docs/pubsub-notifications)
268349 when using GCS if possible.
269350 """
270351 def __init__ (
@@ -306,37 +387,70 @@ def __init__(
306387 'if possible' )
307388
308389 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
323390 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.
391+ match_files = self ._match_deduplicated (pbegin )
392+ else :
393+ match_files = self ._match_all_each_poll (pbegin )
394+
395+ # Apply windowing last because dedup relies on the global window.
335396 if self .apply_windowing :
336397 match_files = match_files | beam .WindowInto (FixedWindows (self .interval ))
337398
338399 return match_files
339400
401+ def _match_deduplicated (self ,
402+ pbegin ) -> beam .PCollection [filesystem .FileMetadata ]:
403+ # The Watch transform polls the pattern and emits each file once per path.
404+ # When matching updated files, the poll function timestamps each output with
405+ # the file's last-modified time and Watch includes that timestamp in dedup.
406+ # stop_timestamp bounds the watch to the polls that fall in [start, stop).
407+ if self .stop_ts == MAX_TIMESTAMP :
408+ termination = never ()
409+ else :
410+ start_ts = Timestamp .of (self .start_ts )
411+ stop_ts = Timestamp .of (self .stop_ts )
412+ if stop_ts < start_ts :
413+ raise ValueError (
414+ 'MatchContinuously stop_timestamp %s precedes start_timestamp %s' %
415+ (stop_ts , start_ts ))
416+ interval_micros = Duration .of (self .interval ).micros
417+ if interval_micros <= 0 :
418+ raise ValueError ('MatchContinuously interval must be positive.' )
419+ span_micros = (stop_ts - start_ts ).micros
420+ # Ceiling division reproduces PeriodicImpulse's tick count; the window
421+ # upper bound is exclusive.
422+ max_polls = - (- span_micros // interval_micros )
423+ termination = _WatchWindowTermination (start_ts .micros , max_polls )
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 ([self .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+ return (
446+ pbegin
447+ | PeriodicImpulse (
448+ start_timestamp = self .start_ts ,
449+ stop_timestamp = self .stop_ts ,
450+ fire_interval = self .interval )
451+ | 'GetFilePattern' >> beam .Map (lambda x : self .file_pattern )
452+ | MatchAll (self .empty_match_treatment ))
453+
340454
341455class ReadMatches (beam .PTransform ):
342456 """Converts each result of MatchFiles() or MatchAll() to a ReadableFile.
@@ -892,50 +1006,3 @@ def finish_bundle(self):
8921006 timestamp = key [1 ].start ,
8931007 windows = [key [1 ]] # TODO(pabloem) HOW DO WE GET THE PANE
8941008 ))
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