Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 177 additions & 78 deletions sdks/python/apache_beam/io/fileio.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,28 +93,37 @@
import random
import uuid
from collections import namedtuple
from functools import partial
from typing import Any
from typing import BinaryIO # pylint: disable=unused-import
from typing import Callable
from typing import Iterable
from typing import Optional
from typing import Union

import apache_beam as beam
from apache_beam.coders.coders import FloatCoder
from apache_beam.coders.coders import StrUtf8Coder
from apache_beam.coders.coders import TupleCoder
from apache_beam.coders.coders import VarIntCoder
from apache_beam.io import filesystem
from apache_beam.io import filesystems
from apache_beam.io.filesystem import BeamIOError
from apache_beam.io.filesystem import CompressionTypes
from apache_beam.io.watch import PollFn
from apache_beam.io.watch import PollResult
from apache_beam.io.watch import TerminationCondition
from apache_beam.io.watch import Watch
from apache_beam.io.watch import never
from apache_beam.options.pipeline_options import GoogleCloudOptions
from apache_beam.options.value_provider import StaticValueProvider
from apache_beam.options.value_provider import ValueProvider
from apache_beam.transforms.periodicsequence import PeriodicImpulse
from apache_beam.transforms.userstate import CombiningValueStateSpec
from apache_beam.transforms.window import BoundedWindow
from apache_beam.transforms.window import FixedWindows
from apache_beam.transforms.window import GlobalWindow
from apache_beam.transforms.window import IntervalWindow
from apache_beam.utils.timestamp import MAX_TIMESTAMP
from apache_beam.utils.timestamp import Duration
from apache_beam.utils.timestamp import Timestamp

__all__ = [
Expand Down Expand Up @@ -251,6 +260,98 @@ def process(
yield ReadableFile(metadata, self._compression)


class _PollClock(object):
"""The poll-time clock reading one ``MatchContinuously`` round shares.

The start gate (a poll before ``start_timestamp`` emits nothing) and the
poll budget (only polls at or after ``start_timestamp`` consume it) must
agree on a single reading. With independent clock reads, a round straddling
the boundary could consume the budget without having matched anything. The
poll function writes the reading and the termination condition consumes it
within the same round; instances are not shared across bundle threads.
"""
def __init__(self):
self.last_poll_micros: Optional[int] = None


class _WatchWindowTermination(TerminationCondition):
"""Stops after the polls that fall in the ``[start, stop)`` window.

``max_polls`` is the ``PeriodicImpulse`` tick count
``ceil((stop - start) / interval)``, so the number of polls is independent of
how fast the runner reschedules deferred work. Only polls at or after
``start`` count toward the budget, judged by the poll's own clock reading in
``clock``; earlier polls are deferred waits that must not consume it,
matching ``PeriodicImpulse``, which never advances a tick while waiting for
the start time.
"""
def __init__(self, clock: _PollClock, start_micros: int, max_polls: int):
self._clock = clock
self._start_micros = start_micros
self._max_polls = max_polls

def for_new_input(self, now, element):
return 0

def on_poll_complete(self, state):
poll_micros = self._clock.last_poll_micros
if poll_micros is not None and poll_micros >= self._start_micros:
return state + 1
return state

def can_stop_polling(self, now, state):
return state >= self._max_polls

def state_coder(self):
return VarIntCoder()


def _file_path_key(metadata: filesystem.FileMetadata) -> str:
return metadata.path


def _file_path_and_mtime_key(
metadata: filesystem.FileMetadata) -> tuple[str, float]:
# Keying on the last-modified time makes a file whose timestamp changed look
# new again, mirroring the Java SDK's ExtractFilenameAndLastUpdateFn — which
# also rejects a missing (zero) timestamp, since updates could never be seen.
if not metadata.last_updated_in_seconds:
raise BeamIOError(
'MatchContinuously(match_updated_files=True) requires file '
'last-modified times, but %s reports none.' % metadata.path)
return metadata.path, metadata.last_updated_in_seconds


class _MatchContinuouslyPollFn(PollFn):
"""Polls a file pattern for ``MatchContinuously``, honoring empty-match rules.

Emits no outputs before ``start_timestamp`` so polling can start ahead of the
first intended match. Each match is stamped with the poll time as its event
time, and the watermark advances to the poll time so downstream event-time
windows keep progressing even when a poll finds no new files. Each round's
clock reading is recorded in ``clock`` so ``_WatchWindowTermination`` judges
the start boundary by the same reading as the gate below.
"""
def __init__(self, empty_match_treatment, start_timestamp, clock=None):
self._empty_match_treatment = empty_match_treatment
self._start_micros = Timestamp.of(start_timestamp).micros
self._clock = clock if clock is not None else _PollClock()

def __call__(self, file_pattern: str) -> PollResult:
now = Timestamp.now()
self._clock.last_poll_micros = now.micros
if now.micros < self._start_micros:
return PollResult.incomplete(())
match_result = filesystems.FileSystems.match([file_pattern])[0]
if (not match_result.metadata_list and
not EmptyMatchTreatment.allow_empty_match(file_pattern,
self._empty_match_treatment)):
raise BeamIOError(
'Empty match for pattern %s. Disallowed.' % file_pattern)
return PollResult.incomplete(
match_result.metadata_list, timestamp=now).with_watermark(now)


class MatchContinuously(beam.PTransform):
"""Checks for new files for a given pattern every interval.

Expand All @@ -261,10 +362,11 @@ class MatchContinuously(beam.PTransform):
guarantees.

Matching continuously scales poorly, as it is stateful, and requires storing
file ids in memory. In addition, because it is memory-only, if a pipeline is
restarted, already processed files will be reprocessed. Consider an alternate
technique, such as Pub/Sub Notifications
(https://cloud.google.com/storage/docs/pubsub-notifications)
file ids for every file the pattern has matched. With ``has_deduplication``
enabled those ids are kept in the splittable DoFn restriction, so a runner
with checkpointing enabled restores them after a restart and does not
reprocess files. Consider an alternate technique, such as Pub/Sub
Notifications (https://cloud.google.com/storage/docs/pubsub-notifications)
when using GCS if possible.
"""
def __init__(
Expand Down Expand Up @@ -306,37 +408,81 @@ def __init__(
'if possible')

def expand(self, pbegin) -> beam.PCollection[filesystem.FileMetadata]:
# invoke periodic impulse
impulse = pbegin | PeriodicImpulse(
start_timestamp=self.start_ts,
stop_timestamp=self.stop_ts,
fire_interval=self.interval)

# match file pattern periodically
file_pattern = self.file_pattern
match_files = (
impulse
| 'GetFilePattern' >> beam.Map(lambda x: file_pattern)
| MatchAll(self.empty_match_treatment))

# apply deduplication strategy if required
if Duration.of(self.interval).micros <= 0:
raise ValueError('MatchContinuously interval must be positive.')
if self.has_deduplication:
# Making a Key Value so each file has its own state.
match_files = match_files | 'ToKV' >> beam.Map(lambda x: (x.path, x))
if self.match_upd:
match_files = match_files | 'RemoveOldAlreadyRead' >> beam.ParDo(
_RemoveOldDuplicates())
else:
match_files = match_files | 'RemoveAlreadyRead' >> beam.ParDo(
_RemoveDuplicates())

# apply windowing if required. Apply at last because deduplication relies on
# the global window.
match_files = self._match_deduplicated(pbegin)
else:
match_files = self._match_all_each_poll(pbegin)

# Apply windowing last because dedup relies on the global window.
if self.apply_windowing:
match_files = match_files | beam.WindowInto(FixedWindows(self.interval))

return match_files

def _match_deduplicated(self,
pbegin) -> beam.PCollection[filesystem.FileMetadata]:
# The Watch transform polls the pattern and emits each file once per key:
# the path, joined by the last-modified time when matching updated files.
# stop_timestamp bounds the watch to the polls that fall in [start, stop).
clock = _PollClock()
if self.stop_ts == MAX_TIMESTAMP:
termination = never()
else:
start_ts = Timestamp.of(self.start_ts)
stop_ts = Timestamp.of(self.stop_ts)
if stop_ts < start_ts:
raise ValueError(
'MatchContinuously stop_timestamp %s precedes start_timestamp %s' %
(stop_ts, start_ts))
interval_micros = Duration.of(self.interval).micros
span_micros = (stop_ts - start_ts).micros
# Ceiling division reproduces PeriodicImpulse's tick count; the window
# upper bound is exclusive.
max_polls = -(-span_micros // interval_micros)
if max_polls == 0:
# An empty [start, stop) window never ticks in PeriodicImpulse. Fall
# back to the impulse path — with zero ticks dedup is moot — so the
# output stays empty and unbounded, rather than let Watch run its
# unconditional first poll.
return self._match_all_each_poll(pbegin)
termination = _WatchWindowTermination(clock, start_ts.micros, max_polls)
if self.match_upd:
output_key_fn = _file_path_and_mtime_key
output_key_coder = TupleCoder([StrUtf8Coder(), FloatCoder()])
else:
output_key_fn = _file_path_key
output_key_coder = StrUtf8Coder()
poll_fn = _MatchContinuouslyPollFn(
self.empty_match_treatment, self.start_ts, clock)
watch = Watch(
poll_fn,
poll_interval=self.interval,
termination=termination,
output_key_fn=output_key_fn,
output_key_coder=output_key_coder)
# Watch emits (pattern, file) pairs; keep the FileMetadata output type so
# downstream transforms stay typed instead of falling back to Any.
return (
pbegin
| 'Impulse' >> beam.Create([self.file_pattern])
| 'Watch' >> watch
| 'DropPattern' >> beam.Map(lambda kv: kv[1]).with_output_types(
filesystem.FileMetadata))

def _match_all_each_poll(self,
pbegin) -> beam.PCollection[filesystem.FileMetadata]:
# No deduplication: re-emit every match on each poll.
return (
pbegin
| PeriodicImpulse(
start_timestamp=self.start_ts,
stop_timestamp=self.stop_ts,
fire_interval=self.interval)
| 'GetFilePattern' >> beam.Map(lambda x: self.file_pattern)
| MatchAll(self.empty_match_treatment))


class ReadMatches(beam.PTransform):
"""Converts each result of MatchFiles() or MatchAll() to a ReadableFile.
Expand Down Expand Up @@ -892,50 +1038,3 @@ def finish_bundle(self):
timestamp=key[1].start,
windows=[key[1]] # TODO(pabloem) HOW DO WE GET THE PANE
))


class _RemoveDuplicates(beam.DoFn):
"""Internal DoFn that filters out filenames already seen (even though the file
has updated)."""
COUNT_STATE = CombiningValueStateSpec('count', combine_fn=sum)

def process(
self,
element: tuple[str, filesystem.FileMetadata],
count_state=beam.DoFn.StateParam(COUNT_STATE)
) -> Iterable[filesystem.FileMetadata]:

path = element[0]
file_metadata = element[1]
counter = count_state.read()

if counter == 0:
count_state.add(1)
_LOGGER.debug('Generated entry for file %s', path)
yield file_metadata
else:
_LOGGER.debug('File %s was already read, seen %d times', path, counter)


class _RemoveOldDuplicates(beam.DoFn):
"""Internal DoFn that filters out filenames already seen and timestamp
unchanged."""
TIME_STATE = CombiningValueStateSpec(
'count', combine_fn=partial(max, default=0.0))

def process(
self,
element: tuple[str, filesystem.FileMetadata],
time_state=beam.DoFn.StateParam(TIME_STATE)
) -> Iterable[filesystem.FileMetadata]:
path = element[0]
file_metadata = element[1]
new_ts = file_metadata.last_updated_in_seconds
old_ts = time_state.read()

if old_ts < new_ts:
time_state.add(new_ts)
_LOGGER.debug('Generated entry for file %s', path)
yield file_metadata
else:
_LOGGER.debug('File %s was already read', path)
Loading
Loading