Skip to content

Commit 3760698

Browse files
committed
[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. Because the matched ids are part of the restriction, a runner with checkpointing enabled restores them after a restart and does not reprocess files. The docstring is updated accordingly. Verified on Flink 1.20: after killing the TaskManager mid stream the job restored from a checkpoint and every file was still emitted exactly once. has_deduplication=False keeps the previous PeriodicImpulse behaviour.
1 parent d6ab992 commit 3760698

2 files changed

Lines changed: 294 additions & 81 deletions

File tree

sdks/python/apache_beam/io/fileio.py

Lines changed: 177 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,37 @@
9393
import random
9494
import uuid
9595
from collections import namedtuple
96-
from functools import partial
9796
from typing import Any
9897
from typing import BinaryIO # pylint: disable=unused-import
9998
from typing import Callable
10099
from typing import Iterable
100+
from typing import Optional
101101
from typing import Union
102102

103103
import 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
104108
from apache_beam.io import filesystem
105109
from apache_beam.io import filesystems
106110
from apache_beam.io.filesystem import BeamIOError
107111
from 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
108117
from apache_beam.options.pipeline_options import GoogleCloudOptions
109118
from apache_beam.options.value_provider import StaticValueProvider
110119
from apache_beam.options.value_provider import ValueProvider
111120
from apache_beam.transforms.periodicsequence import PeriodicImpulse
112-
from apache_beam.transforms.userstate import CombiningValueStateSpec
113121
from apache_beam.transforms.window import BoundedWindow
114122
from apache_beam.transforms.window import FixedWindows
115123
from apache_beam.transforms.window import GlobalWindow
116124
from apache_beam.transforms.window import IntervalWindow
117125
from apache_beam.utils.timestamp import MAX_TIMESTAMP
126+
from apache_beam.utils.timestamp import Duration
118127
from 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+
254355
class 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

341487
class 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

Comments
 (0)