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
100+ from typing import Optional
101101from typing import Union
102102
103103import apache_beam as beam
104+ from apache_beam .coders .coders import FloatCoder
105+ from apache_beam .coders .coders import StrUtf8Coder
106+ from apache_beam .coders .coders import TupleCoder
107+ from apache_beam .coders .coders import VarIntCoder
104108from apache_beam .io import filesystem
105109from apache_beam .io import filesystems
106110from apache_beam .io .filesystem import BeamIOError
107111from apache_beam .io .filesystem import CompressionTypes
112+ from apache_beam .io .watch import PollFn
113+ from apache_beam .io .watch import PollResult
114+ from apache_beam .io .watch import TerminationCondition
115+ from apache_beam .io .watch import Watch
116+ from apache_beam .io .watch import never
108117from apache_beam .options .pipeline_options import GoogleCloudOptions
109118from apache_beam .options .value_provider import StaticValueProvider
110119from apache_beam .options .value_provider import ValueProvider
111120from apache_beam .transforms .periodicsequence import PeriodicImpulse
112- from apache_beam .transforms .userstate import CombiningValueStateSpec
113121from apache_beam .transforms .window import BoundedWindow
114122from apache_beam .transforms .window import FixedWindows
115123from apache_beam .transforms .window import GlobalWindow
116124from apache_beam .transforms .window import IntervalWindow
117125from apache_beam .utils .timestamp import MAX_TIMESTAMP
126+ from apache_beam .utils .timestamp import Duration
118127from apache_beam .utils .timestamp import Timestamp
119128
120129__all__ = [
@@ -251,6 +260,98 @@ def process(
251260 yield ReadableFile (metadata , self ._compression )
252261
253262
263+ class _PollClock (object ):
264+ """The poll-time clock reading one ``MatchContinuously`` round shares.
265+
266+ The start gate (a poll before ``start_timestamp`` emits nothing) and the
267+ poll budget (only polls at or after ``start_timestamp`` consume it) must
268+ agree on a single reading. With independent clock reads, a round straddling
269+ the boundary could consume the budget without having matched anything. The
270+ poll function writes the reading and the termination condition consumes it
271+ within the same round; instances are not shared across bundle threads.
272+ """
273+ def __init__ (self ):
274+ self .last_poll_micros : Optional [int ] = None
275+
276+
277+ class _WatchWindowTermination (TerminationCondition ):
278+ """Stops after the polls that fall in the ``[start, stop)`` window.
279+
280+ ``max_polls`` is the ``PeriodicImpulse`` tick count
281+ ``ceil((stop - start) / interval)``, so the number of polls is independent of
282+ how fast the runner reschedules deferred work. Only polls at or after
283+ ``start`` count toward the budget, judged by the poll's own clock reading in
284+ ``clock``; earlier polls are deferred waits that must not consume it,
285+ matching ``PeriodicImpulse``, which never advances a tick while waiting for
286+ the start time.
287+ """
288+ def __init__ (self , clock : _PollClock , start_micros : int , max_polls : int ):
289+ self ._clock = clock
290+ self ._start_micros = start_micros
291+ self ._max_polls = max_polls
292+
293+ def for_new_input (self , now , element ):
294+ return 0
295+
296+ def on_poll_complete (self , state ):
297+ poll_micros = self ._clock .last_poll_micros
298+ if poll_micros is not None and poll_micros >= self ._start_micros :
299+ return state + 1
300+ return state
301+
302+ def can_stop_polling (self , now , state ):
303+ return state >= self ._max_polls
304+
305+ def state_coder (self ):
306+ return VarIntCoder ()
307+
308+
309+ def _file_path_key (metadata : filesystem .FileMetadata ) -> str :
310+ return metadata .path
311+
312+
313+ def _file_path_and_mtime_key (
314+ metadata : filesystem .FileMetadata ) -> tuple [str , float ]:
315+ # Keying on the last-modified time makes a file whose timestamp changed look
316+ # new again, mirroring the Java SDK's ExtractFilenameAndLastUpdateFn — which
317+ # also rejects a missing (zero) timestamp, since updates could never be seen.
318+ if not metadata .last_updated_in_seconds :
319+ raise BeamIOError (
320+ 'MatchContinuously(match_updated_files=True) requires file '
321+ 'last-modified times, but %s reports none.' % metadata .path )
322+ return metadata .path , metadata .last_updated_in_seconds
323+
324+
325+ class _MatchContinuouslyPollFn (PollFn ):
326+ """Polls a file pattern for ``MatchContinuously``, honoring empty-match rules.
327+
328+ Emits no outputs before ``start_timestamp`` so polling can start ahead of the
329+ first intended match. Each match is stamped with the poll time as its event
330+ time, and the watermark advances to the poll time so downstream event-time
331+ windows keep progressing even when a poll finds no new files. Each round's
332+ clock reading is recorded in ``clock`` so ``_WatchWindowTermination`` judges
333+ the start boundary by the same reading as the gate below.
334+ """
335+ def __init__ (self , empty_match_treatment , start_timestamp , clock = None ):
336+ self ._empty_match_treatment = empty_match_treatment
337+ self ._start_micros = Timestamp .of (start_timestamp ).micros
338+ self ._clock = clock if clock is not None else _PollClock ()
339+
340+ def __call__ (self , file_pattern : str ) -> PollResult :
341+ now = Timestamp .now ()
342+ self ._clock .last_poll_micros = now .micros
343+ if now .micros < self ._start_micros :
344+ return PollResult .incomplete (())
345+ match_result = filesystems .FileSystems .match ([file_pattern ])[0 ]
346+ if (not match_result .metadata_list and
347+ not EmptyMatchTreatment .allow_empty_match (file_pattern ,
348+ self ._empty_match_treatment )):
349+ raise BeamIOError (
350+ 'Empty match for pattern %s. Disallowed.' % file_pattern )
351+ return PollResult .incomplete (
352+ match_result .metadata_list , timestamp = now ).with_watermark (now )
353+
354+
254355class MatchContinuously (beam .PTransform ):
255356 """Checks for new files for a given pattern every interval.
256357
@@ -261,10 +362,11 @@ class MatchContinuously(beam.PTransform):
261362 guarantees.
262363
263364 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)
365+ file ids for every file the pattern has matched. With ``has_deduplication``
366+ enabled those ids are kept in the splittable DoFn restriction, so a runner
367+ with checkpointing enabled restores them after a restart and does not
368+ reprocess files. Consider an alternate technique, such as Pub/Sub
369+ Notifications (https://cloud.google.com/storage/docs/pubsub-notifications)
268370 when using GCS if possible.
269371 """
270372 def __init__ (
@@ -306,37 +408,81 @@ def __init__(
306408 'if possible' )
307409
308410 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
411+ if Duration .of (self .interval ).micros <= 0 :
412+ raise ValueError ('MatchContinuously interval must be positive.' )
323413 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.
414+ match_files = self ._match_deduplicated (pbegin )
415+ else :
416+ match_files = self ._match_all_each_poll (pbegin )
417+
418+ # Apply windowing last because dedup relies on the global window.
335419 if self .apply_windowing :
336420 match_files = match_files | beam .WindowInto (FixedWindows (self .interval ))
337421
338422 return match_files
339423
424+ def _match_deduplicated (self ,
425+ pbegin ) -> beam .PCollection [filesystem .FileMetadata ]:
426+ # The Watch transform polls the pattern and emits each file once per key:
427+ # the path, joined by the last-modified time when matching updated files.
428+ # stop_timestamp bounds the watch to the polls that fall in [start, stop).
429+ clock = _PollClock ()
430+ if self .stop_ts == MAX_TIMESTAMP :
431+ termination = never ()
432+ else :
433+ start_ts = Timestamp .of (self .start_ts )
434+ stop_ts = Timestamp .of (self .stop_ts )
435+ if stop_ts < start_ts :
436+ raise ValueError (
437+ 'MatchContinuously stop_timestamp %s precedes start_timestamp %s' %
438+ (stop_ts , start_ts ))
439+ interval_micros = Duration .of (self .interval ).micros
440+ span_micros = (stop_ts - start_ts ).micros
441+ # Ceiling division reproduces PeriodicImpulse's tick count; the window
442+ # upper bound is exclusive.
443+ max_polls = - (- span_micros // interval_micros )
444+ if max_polls == 0 :
445+ # An empty [start, stop) window never ticks in PeriodicImpulse. Fall
446+ # back to the impulse path — with zero ticks dedup is moot — so the
447+ # output stays empty and unbounded, rather than let Watch run its
448+ # unconditional first poll.
449+ return self ._match_all_each_poll (pbegin )
450+ termination = _WatchWindowTermination (clock , start_ts .micros , max_polls )
451+ if self .match_upd :
452+ output_key_fn = _file_path_and_mtime_key
453+ output_key_coder = TupleCoder ([StrUtf8Coder (), FloatCoder ()])
454+ else :
455+ output_key_fn = _file_path_key
456+ output_key_coder = StrUtf8Coder ()
457+ poll_fn = _MatchContinuouslyPollFn (
458+ self .empty_match_treatment , self .start_ts , clock )
459+ watch = Watch (
460+ poll_fn ,
461+ poll_interval = self .interval ,
462+ termination = termination ,
463+ output_key_fn = output_key_fn ,
464+ output_key_coder = output_key_coder )
465+ # Watch emits (pattern, file) pairs; keep the FileMetadata output type so
466+ # downstream transforms stay typed instead of falling back to Any.
467+ return (
468+ pbegin
469+ | 'Impulse' >> beam .Create ([self .file_pattern ])
470+ | 'Watch' >> watch
471+ | 'DropPattern' >> beam .Map (lambda kv : kv [1 ]).with_output_types (
472+ filesystem .FileMetadata ))
473+
474+ def _match_all_each_poll (self ,
475+ pbegin ) -> beam .PCollection [filesystem .FileMetadata ]:
476+ # No deduplication: re-emit every match on each poll.
477+ return (
478+ pbegin
479+ | PeriodicImpulse (
480+ start_timestamp = self .start_ts ,
481+ stop_timestamp = self .stop_ts ,
482+ fire_interval = self .interval )
483+ | 'GetFilePattern' >> beam .Map (lambda x : self .file_pattern )
484+ | MatchAll (self .empty_match_treatment ))
485+
340486
341487class ReadMatches (beam .PTransform ):
342488 """Converts each result of MatchFiles() or MatchAll() to a ReadableFile.
@@ -892,50 +1038,3 @@ def finish_bundle(self):
8921038 timestamp = key [1 ].start ,
8931039 windows = [key [1 ]] # TODO(pabloem) HOW DO WE GET THE PANE
8941040 ))
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