Skip to content

Commit bb35430

Browse files
committed
[Python] Refactor MatchContinuously to use Watch
1 parent c2fbb47 commit bb35430

4 files changed

Lines changed: 268 additions & 98 deletions

File tree

sdks/python/apache_beam/io/fileio.py

Lines changed: 142 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,35 @@
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
101100
from typing import Union
102101

103102
import apache_beam as beam
103+
from apache_beam.coders.coders import StrUtf8Coder
104+
from apache_beam.coders.coders import VarIntCoder
104105
from apache_beam.io import filesystem
105106
from apache_beam.io import filesystems
106107
from apache_beam.io.filesystem import BeamIOError
107108
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
108114
from apache_beam.options.pipeline_options import GoogleCloudOptions
109115
from apache_beam.options.value_provider import StaticValueProvider
110116
from apache_beam.options.value_provider import ValueProvider
111117
from apache_beam.transforms.periodicsequence import PeriodicImpulse
112-
from apache_beam.transforms.userstate import CombiningValueStateSpec
113118
from apache_beam.transforms.window import BoundedWindow
114119
from apache_beam.transforms.window import FixedWindows
115120
from apache_beam.transforms.window import GlobalWindow
116121
from apache_beam.transforms.window import IntervalWindow
122+
from apache_beam.transforms.window import TimestampedValue
117123
from apache_beam.utils.timestamp import MAX_TIMESTAMP
124+
from apache_beam.utils.timestamp import Duration
118125
from 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+
254334
class MatchContinuously(beam.PTransform):
255335
"""Checks for new files for a given pattern every interval.
256336
@@ -306,37 +386,72 @@ def __init__(
306386
'if possible')
307387

308388
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
323389
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.
335395
if self.apply_windowing:
336396
match_files = match_files | beam.WindowInto(FixedWindows(self.interval))
337397

338398
return match_files
339399

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+
340455

341456
class ReadMatches(beam.PTransform):
342457
"""Converts each result of MatchFiles() or MatchAll() to a ReadableFile.
@@ -892,50 +1007,3 @@ def finish_bundle(self):
8921007
timestamp=key[1].start,
8931008
windows=[key[1]] # TODO(pabloem) HOW DO WE GET THE PANE
8941009
))
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)

sdks/python/apache_beam/io/fileio_test.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@
2828
import uuid
2929
import warnings
3030

31-
import pytest
32-
from hamcrest.library.text import stringmatches
33-
3431
import apache_beam as beam
32+
import pytest
3533
from apache_beam.io import fileio
3634
from apache_beam.io.filebasedsink_test import _TestCaseWithTempDirCleanUp
35+
from apache_beam.io.filesystem import BeamIOError
3736
from apache_beam.io.filesystem import CompressionTypes
3837
from apache_beam.io.filesystems import FileSystems
3938
from apache_beam.options.pipeline_options import PipelineOptions
@@ -49,6 +48,7 @@
4948
from apache_beam.transforms.window import GlobalWindow
5049
from apache_beam.transforms.window import IntervalWindow
5150
from apache_beam.utils.timestamp import Timestamp
51+
from hamcrest.library.text import stringmatches
5252

5353
warnings.filterwarnings(
5454
'ignore', category=FutureWarning, module='apache_beam.io.fileio_test')
@@ -420,6 +420,65 @@ def _create_extra_file(element):
420420

421421
assert_that(match_continiously, equal_to(files))
422422

423+
def test_poll_fn_gates_on_start_timestamp(self):
424+
tempdir = '%s%s' % (self._new_tempdir(), os.sep)
425+
self._create_temp_file(dir=tempdir)
426+
pattern = FileSystems.join(tempdir, '*')
427+
428+
future_start = fileio._MatchContinuouslyPollFn(
429+
fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() + 3600)
430+
self.assertEqual((), future_start(pattern).outputs)
431+
432+
past_start = fileio._MatchContinuouslyPollFn(
433+
fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600)
434+
self.assertEqual(1, len(past_start(pattern).outputs))
435+
436+
def test_poll_fn_disallows_empty_match(self):
437+
tempdir = '%s%s' % (self._new_tempdir(), os.sep)
438+
poll_fn = fileio._MatchContinuouslyPollFn(
439+
fileio.EmptyMatchTreatment.DISALLOW, Timestamp.now() - 3600)
440+
with self.assertRaises(BeamIOError):
441+
poll_fn(FileSystems.join(tempdir, 'no-such-file'))
442+
443+
def test_poll_fn_can_timestamp_outputs_with_file_mtime(self):
444+
tempdir = '%s%s' % (self._new_tempdir(), os.sep)
445+
self._create_temp_file(dir=tempdir)
446+
poll_fn = fileio._MatchContinuouslyPollFn(
447+
fileio.EmptyMatchTreatment.ALLOW,
448+
Timestamp.now() - 3600,
449+
use_metadata_timestamp=True)
450+
result = poll_fn(FileSystems.join(tempdir, '*'))
451+
self.assertEqual(1, len(result.outputs))
452+
output = result.outputs[0]
453+
self.assertEqual(
454+
Timestamp.of(output.value.last_updated_in_seconds), output.timestamp)
455+
456+
def test_watch_window_termination_ignores_pre_start_polls(self):
457+
# Polls before start_timestamp are deferred waits and must not consume the
458+
# budget, otherwise a future start_timestamp silently drops all output.
459+
start_micros = Timestamp.of(1000).micros
460+
term = fileio._WatchWindowTermination(start_micros, max_polls=2)
461+
before = Timestamp.of(999)
462+
after = Timestamp.of(1000)
463+
state = term.for_new_input(before, 'pattern')
464+
state = term.on_poll_complete(before, state)
465+
state = term.on_poll_complete(before, state)
466+
self.assertFalse(term.can_stop_polling(before, state))
467+
state = term.on_poll_complete(after, state)
468+
self.assertFalse(term.can_stop_polling(after, state))
469+
state = term.on_poll_complete(after, state)
470+
self.assertTrue(term.can_stop_polling(after, state))
471+
472+
def test_poll_fn_advances_watermark_on_empty_match(self):
473+
# An empty (but allowed) match still carries a watermark so downstream
474+
# event-time windows keep progressing when no new files appear.
475+
tempdir = '%s%s' % (self._new_tempdir(), os.sep)
476+
poll_fn = fileio._MatchContinuouslyPollFn(
477+
fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600)
478+
result = poll_fn(FileSystems.join(tempdir, '*'))
479+
self.assertEqual((), result.outputs)
480+
self.assertIsNotNone(result.watermark)
481+
423482

424483
class WriteFilesTest(_TestCaseWithTempDirCleanUp):
425484

0 commit comments

Comments
 (0)