| # |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| """Tests for transforms defined in apache_beam.io.fileio.""" |
| |
| # pytype: skip-file |
| |
| import csv |
| import io |
| import json |
| import logging |
| import os |
| import unittest |
| import uuid |
| import warnings |
| |
| import pytest |
| from hamcrest.library.text import stringmatches |
| |
| import apache_beam as beam |
| from apache_beam.io import fileio |
| from apache_beam.io.filebasedsink_test import _TestCaseWithTempDirCleanUp |
| from apache_beam.io.filesystem import BeamIOError |
| from apache_beam.io.filesystem import CompressionTypes |
| from apache_beam.io.filesystem import FileMetadata |
| from apache_beam.io.filesystems import FileSystems |
| from apache_beam.options.pipeline_options import PipelineOptions |
| from apache_beam.options.pipeline_options import StandardOptions |
| from apache_beam.testing.test_pipeline import TestPipeline |
| from apache_beam.testing.test_stream import TestStream |
| from apache_beam.testing.test_utils import compute_hash |
| from apache_beam.testing.util import assert_that |
| from apache_beam.testing.util import equal_to |
| from apache_beam.testing.util import matches_all |
| from apache_beam.transforms import trigger |
| 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 Timestamp |
| |
| warnings.filterwarnings( |
| 'ignore', category=FutureWarning, module='apache_beam.io.fileio_test') |
| |
| |
| def _get_file_reader(readable_file): |
| return io.TextIOWrapper(readable_file.open()) |
| |
| |
| class MatchTest(_TestCaseWithTempDirCleanUp): |
| def test_basic_two_files(self): |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a couple files to be matched |
| files.append(self._create_temp_file(dir=tempdir)) |
| files.append(self._create_temp_file(dir=tempdir)) |
| |
| with TestPipeline() as p: |
| files_pc = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(tempdir, '*')) |
| | beam.Map(lambda x: x.path)) |
| |
| assert_that(files_pc, equal_to(files)) |
| |
| def test_match_all_two_directories(self): |
| files = [] |
| directories = [] |
| |
| for _ in range(2): |
| # TODO: What about this having to append the ending slash? |
| d = '%s%s' % (self._new_tempdir(), os.sep) |
| directories.append(d) |
| |
| files.append(self._create_temp_file(dir=d)) |
| files.append(self._create_temp_file(dir=d)) |
| |
| with TestPipeline() as p: |
| files_pc = ( |
| p |
| | beam.Create([FileSystems.join(d, '*') for d in directories]) |
| | fileio.MatchAll() |
| | beam.Map(lambda x: x.path)) |
| |
| assert_that(files_pc, equal_to(files)) |
| |
| def test_match_files_one_directory_failure1(self): |
| directories = [ |
| '%s%s' % (self._new_tempdir(), os.sep), |
| '%s%s' % (self._new_tempdir(), os.sep) |
| ] |
| |
| files = [] |
| files.append(self._create_temp_file(dir=directories[0])) |
| files.append(self._create_temp_file(dir=directories[0])) |
| |
| with self.assertRaisesRegex(Exception, "Empty match for pattern"): |
| with TestPipeline() as p: |
| files_pc = ( |
| p |
| | beam.Create([FileSystems.join(d, '*') for d in directories]) |
| | fileio.MatchAll(fileio.EmptyMatchTreatment.DISALLOW) |
| | beam.Map(lambda x: x.path)) |
| |
| assert_that(files_pc, equal_to(files)) |
| |
| def test_match_files_one_directory_failure2(self): |
| directories = [ |
| '%s%s' % (self._new_tempdir(), os.sep), |
| '%s%s' % (self._new_tempdir(), os.sep) |
| ] |
| |
| files = [] |
| files.append(self._create_temp_file(dir=directories[0])) |
| files.append(self._create_temp_file(dir=directories[0])) |
| |
| with TestPipeline() as p: |
| files_pc = ( |
| p |
| | beam.Create([FileSystems.join(d, '*') for d in directories]) |
| | fileio.MatchAll(fileio.EmptyMatchTreatment.ALLOW_IF_WILDCARD) |
| | beam.Map(lambda x: x.path)) |
| |
| assert_that(files_pc, equal_to(files)) |
| |
| |
| class ReadTest(_TestCaseWithTempDirCleanUp): |
| def test_basic_file_name_provided(self): |
| content = 'TestingMyContent\nIn multiple lines\nhaha!' |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| self._create_temp_file(dir=dir, content=content) |
| |
| with TestPipeline() as p: |
| content_pc = ( |
| p |
| | beam.Create([FileSystems.join(dir, '*')]) |
| | fileio.MatchAll() |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda f: f.read().decode('utf-8').splitlines())) |
| |
| assert_that(content_pc, equal_to(content.splitlines())) |
| |
| def test_csv_file_source(self): |
| content = 'name,year,place\ngoogle,1999,CA\nspotify,2006,sweden' |
| rows = [r.split(',') for r in content.split('\n')] |
| |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| self._create_temp_file(dir=dir, content=content) |
| |
| with TestPipeline() as p: |
| content_pc = ( |
| p |
| | beam.Create([FileSystems.join(dir, '*')]) |
| | fileio.MatchAll() |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda rf: csv.reader(_get_file_reader(rf)))) |
| |
| assert_that(content_pc, equal_to(rows)) |
| |
| def test_infer_compressed_file(self): |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| file_contents = b'compressed_contents!' |
| import gzip |
| with gzip.GzipFile(os.path.join(dir, 'compressed.gz'), 'w') as f: |
| f.write(file_contents) |
| |
| file_contents2 = b'compressed_contents_bz2!' |
| import bz2 |
| with bz2.BZ2File(os.path.join(dir, 'compressed2.bz2'), 'w') as f: |
| f.write(file_contents2) |
| |
| with TestPipeline() as p: |
| content_pc = ( |
| p |
| | beam.Create([FileSystems.join(dir, '*')]) |
| | fileio.MatchAll() |
| | fileio.ReadMatches() |
| | beam.Map(lambda rf: rf.open().readline())) |
| |
| assert_that(content_pc, equal_to([file_contents, file_contents2])) |
| |
| def test_read_bz2_compressed_file_without_suffix(self): |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| file_contents = b'compressed_contents!' |
| import bz2 |
| with bz2.BZ2File(os.path.join(dir, 'compressed'), 'w') as f: |
| f.write(file_contents) |
| |
| with TestPipeline() as p: |
| content_pc = ( |
| p |
| | beam.Create([FileSystems.join(dir, '*')]) |
| | fileio.MatchAll() |
| | fileio.ReadMatches() |
| | beam.Map( |
| lambda rf: rf.open(compression_type=CompressionTypes.BZIP2).read( |
| len(file_contents)))) |
| |
| assert_that(content_pc, equal_to([file_contents])) |
| |
| def test_read_gzip_compressed_file_without_suffix(self): |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| file_contents = b'compressed_contents!' |
| import gzip |
| with gzip.GzipFile(os.path.join(dir, 'compressed'), 'w') as f: |
| f.write(file_contents) |
| |
| with TestPipeline() as p: |
| content_pc = ( |
| p |
| | beam.Create([FileSystems.join(dir, '*')]) |
| | fileio.MatchAll() |
| | fileio.ReadMatches() |
| | beam.Map( |
| lambda rf: rf.open(compression_type=CompressionTypes.GZIP).read( |
| len(file_contents)))) |
| |
| assert_that(content_pc, equal_to([file_contents])) |
| |
| def test_string_filenames_and_skip_directory(self): |
| content = 'thecontent\n' |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a couple files to be matched |
| files.append(self._create_temp_file(dir=tempdir, content=content)) |
| files.append(self._create_temp_file(dir=tempdir, content=content)) |
| |
| with TestPipeline() as p: |
| contents_pc = ( |
| p |
| | beam.Create(files + ['%s/' % tempdir]) |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda x: x.read().decode('utf-8').splitlines())) |
| |
| assert_that(contents_pc, equal_to(content.splitlines() * 2)) |
| |
| def test_fail_on_directories(self): |
| content = 'thecontent\n' |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a couple files to be matched |
| files.append(self._create_temp_file(dir=tempdir, content=content)) |
| files.append(self._create_temp_file(dir=tempdir, content=content)) |
| |
| with self.assertRaisesRegex(Exception, "Directories are not allowed"): |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | beam.Create(files + ['%s/' % tempdir]) |
| | fileio.ReadMatches(skip_directories=False) |
| | beam.Map(lambda x: x.read_utf8())) |
| |
| |
| class MatchIntegrationTest(unittest.TestCase): |
| |
| INPUT_FILE = 'gs://dataflow-samples/shakespeare/kinglear.txt' |
| KINGLEAR_CHECKSUM = 'f418b25f1507f5a901257026b035ac2857a7ab87' |
| INPUT_FILE_LARGE = ( |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-00000000000*.json') |
| |
| WIKI_FILES = [ |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000000.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000001.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000002.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000003.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000004.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000005.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000006.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000007.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000008.json', |
| 'gs://dataflow-samples/wikipedia_edits/wiki_data-000000000009.json', |
| ] |
| |
| def setUp(self): |
| self.test_pipeline = TestPipeline(is_integration_test=True) |
| |
| @pytest.mark.it_postcommit |
| def test_transform_on_gcs(self): |
| args = self.test_pipeline.get_full_options_as_args() |
| |
| with beam.Pipeline(argv=args) as p: |
| matches_pc = ( |
| p |
| | beam.Create([self.INPUT_FILE, self.INPUT_FILE_LARGE]) |
| | fileio.MatchAll() |
| | 'GetPath' >> beam.Map(lambda metadata: metadata.path)) |
| |
| assert_that( |
| matches_pc, |
| equal_to([self.INPUT_FILE] + self.WIKI_FILES), |
| label='Matched Files') |
| |
| checksum_pc = ( |
| p |
| | 'SingleFile' >> beam.Create([self.INPUT_FILE]) |
| | 'MatchOneAll' >> fileio.MatchAll() |
| | fileio.ReadMatches() |
| | 'ReadIn' >> beam.Map(lambda x: x.read_utf8().split('\n')) |
| | 'Checksums' >> beam.Map(compute_hash)) |
| |
| assert_that( |
| checksum_pc, |
| equal_to([self.KINGLEAR_CHECKSUM]), |
| label='Assert Checksums') |
| |
| |
| class MatchContinuouslyTest(_TestCaseWithTempDirCleanUp): |
| def test_with_deduplication(self): |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a file to be matched before pipeline |
| files.append(self._create_temp_file(dir=tempdir)) |
| # Add file name that will be created mid-pipeline |
| files.append(FileSystems.join(tempdir, 'extra')) |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| def _create_extra_file(element): |
| writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) |
| writer.close() |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop) |
| | beam.Map(_create_extra_file)) |
| |
| assert_that(match_continiously, equal_to(files)) |
| |
| def test_without_deduplication(self): |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a file to be matched before pipeline starts |
| file = self._create_temp_file(dir=tempdir) |
| # Add file twice, since it will be matched for every interval |
| files += [file, file] |
| # Add file name that will be created mid-pipeline |
| files.append(FileSystems.join(tempdir, 'extra')) |
| |
| def _create_extra_file(element): |
| writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) |
| writer.close() |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| has_deduplication=False, |
| start_timestamp=start, |
| stop_timestamp=stop) |
| | beam.Map(_create_extra_file)) |
| |
| assert_that(match_continiously, equal_to(files)) |
| |
| def test_match_updated_files(self): |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| def _create_extra_file(element): |
| writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) |
| writer.close() |
| return element.path |
| |
| # Create two files to be matched before pipeline |
| files.append(self._create_temp_file(dir=tempdir)) |
| writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) |
| writer.close() |
| |
| # Add file name that will be created mid-pipeline |
| files.append(FileSystems.join(tempdir, 'extra')) |
| files.append(FileSystems.join(tempdir, 'extra')) |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop, |
| match_updated_files=True) |
| | beam.Map(_create_extra_file)) |
| |
| assert_that(match_continiously, equal_to(files)) |
| |
| def test_poll_fn_gates_on_start_timestamp(self): |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| self._create_temp_file(dir=tempdir) |
| pattern = FileSystems.join(tempdir, '*') |
| |
| future_start = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() + 3600) |
| self.assertEqual((), future_start(pattern).outputs) |
| |
| past_start = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600) |
| self.assertEqual(1, len(past_start(pattern).outputs)) |
| |
| def test_poll_fn_disallows_empty_match(self): |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.DISALLOW, Timestamp.now() - 3600) |
| with self.assertRaises(BeamIOError): |
| poll_fn(FileSystems.join(tempdir, 'no-such-file')) |
| |
| def test_poll_fn_stamps_outputs_with_poll_time(self): |
| # Matches always carry the poll time as event time; matching updated |
| # files must not change that. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| self._create_temp_file(dir=tempdir) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600) |
| before = Timestamp.now() |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| after = Timestamp.now() |
| self.assertEqual(1, len(result.outputs)) |
| output = result.outputs[0] |
| self.assertLessEqual(before, output.timestamp) |
| self.assertLessEqual(output.timestamp, after) |
| self.assertEqual(result.watermark, output.timestamp) |
| |
| def test_match_updated_files_keys_on_path_and_mtime(self): |
| # An updated file dedups as new because its key changes. |
| metadata = FileMetadata('/tmp/a', 1, 1234.5) |
| self.assertEqual(('/tmp/a', 1234.5), |
| fileio._file_path_and_mtime_key(metadata)) |
| |
| def test_match_updated_files_rejects_missing_mtime(self): |
| # A zero last-modified time is rejected: without mtimes, updates could |
| # never be detected. |
| with self.assertRaises(BeamIOError): |
| fileio._file_path_and_mtime_key(FileMetadata('/tmp/a', 1)) |
| |
| def test_start_equals_stop_matches_nothing(self): |
| # PeriodicImpulse's [start, stop) tick window is empty when start == stop; |
| # the deduplicated path must skip Watch's unconditional first poll by |
| # falling back to the impulse path, which also keeps the output unbounded. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| self._create_temp_file(dir=tempdir) |
| start = Timestamp.now() |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=0.2, |
| start_timestamp=start, |
| stop_timestamp=start)) |
| assert_that(match_continiously, equal_to([])) |
| |
| def test_rejects_nonpositive_interval(self): |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| with self.assertRaisesRegex(ValueError, 'interval must be positive'): |
| with TestPipeline() as p: |
| _ = p | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), interval=0) |
| |
| def test_watch_window_termination_ignores_pre_start_polls(self): |
| # Polls before start_timestamp are deferred waits and must not consume the |
| # budget, otherwise a future start_timestamp silently drops all output. The |
| # boundary is judged by the poll's own clock reading, so a round straddling |
| # the start cannot consume the budget without having matched. |
| start_micros = Timestamp.of(1000).micros |
| clock = fileio._PollClock() |
| term = fileio._WatchWindowTermination(clock, start_micros, max_polls=2) |
| now = Timestamp.of(999) |
| state = term.for_new_input(now, 'pattern') |
| clock.last_poll_micros = Timestamp.of(999).micros |
| state = term.on_poll_complete(state) |
| state = term.on_poll_complete(state) |
| self.assertFalse(term.can_stop_polling(now, state)) |
| clock.last_poll_micros = Timestamp.of(1000).micros |
| state = term.on_poll_complete(state) |
| self.assertFalse(term.can_stop_polling(now, state)) |
| state = term.on_poll_complete(state) |
| self.assertTrue(term.can_stop_polling(now, state)) |
| |
| def test_poll_fn_records_its_clock_reading_for_the_termination(self): |
| # The gate and the poll budget share one reading per round; see _PollClock. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| clock = fileio._PollClock() |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() + 3600, clock) |
| self.assertIsNone(clock.last_poll_micros) |
| poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertIsNotNone(clock.last_poll_micros) |
| |
| def test_poll_fn_advances_watermark_on_empty_match(self): |
| # An empty (but allowed) match still carries a watermark so downstream |
| # event-time windows keep progressing when no new files appear. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, Timestamp.now() - 3600) |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertEqual((), result.outputs) |
| self.assertIsNotNone(result.watermark) |
| |
| def test_poll_fn_stamps_outputs_with_mtime_for_the_cursor(self): |
| # The cursor dedups on the event time, so a match carries its own mtime. |
| # Sub-millisecond digits are kept, or a cursor taken from them would come |
| # back below the outputs it was taken from and match them all over again. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| path = self._create_temp_file(dir=tempdir) |
| os.utime(path, (1234.567891, 1234.567891)) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertEqual(1, len(result.outputs)) |
| self.assertEqual( |
| Timestamp.of(os.path.getmtime(path)), result.outputs[0].timestamp) |
| |
| def test_poll_fn_holds_the_mtime_watermark_at_the_newest_match(self): |
| # The filesystem clock can run behind the local one, so a watermark at the |
| # poll time would make the files it has yet to hand out late. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5)) |
| os.utime(self._create_temp_file(dir=tempdir), (2345.5, 2345.5)) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertEqual(Timestamp.of(2345.5), result.watermark) |
| |
| def test_poll_fn_releases_the_mtime_watermark_once_nothing_is_newer(self): |
| # Holding at the newest match forever would stall a directory that is |
| # merely quiet rather than empty: every poll re-lists the same old files, |
| # and the watermark would sit at their last-modified time while the poll |
| # time ran away from it, so event-time windows would never close. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5)) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| pattern = FileSystems.join(tempdir, '*') |
| self.assertEqual(Timestamp.of(1234.5), poll_fn(pattern).watermark) |
| before = Timestamp.now() |
| quiet = poll_fn(pattern) |
| self.assertEqual(1, len(quiet.outputs)) |
| self.assertTrue(before <= quiet.watermark <= Timestamp.now()) |
| |
| def test_poll_fn_holds_the_mtime_watermark_again_for_a_newer_match(self): |
| # A poll that does turn up a newer file has read the filesystem clock |
| # again, so the hold comes back rather than being spent once. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| os.utime(self._create_temp_file(dir=tempdir), (1234.5, 1234.5)) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| pattern = FileSystems.join(tempdir, '*') |
| poll_fn(pattern) |
| poll_fn(pattern) |
| os.utime(self._create_temp_file(dir=tempdir), (2345.5, 2345.5)) |
| self.assertEqual(Timestamp.of(2345.5), poll_fn(pattern).watermark) |
| |
| def test_poll_fn_caps_the_mtime_watermark_at_the_poll_time(self): |
| # A filesystem clock running ahead must not carry the watermark with it, |
| # which pins the watermark to the poll time. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| path = self._create_temp_file(dir=tempdir) |
| ahead = Timestamp.now() + 3600 |
| os.utime(path, (float(ahead), float(ahead))) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| before = Timestamp.now() |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertTrue(before <= result.watermark <= Timestamp.now()) |
| |
| def test_poll_fn_advances_the_mtime_watermark_on_empty_match(self): |
| # No match is no reading of the filesystem clock, so the watermark takes |
| # the poll time and event-time windows keep progressing. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| poll_fn = fileio._MatchContinuouslyPollFn( |
| fileio.EmptyMatchTreatment.ALLOW, |
| Timestamp.now() - 3600, |
| mtime_timestamps=True) |
| before = Timestamp.now() |
| result = poll_fn(FileSystems.join(tempdir, '*')) |
| self.assertEqual((), result.outputs) |
| self.assertTrue(before <= result.watermark <= Timestamp.now()) |
| |
| def test_timestamp_cursor_rejects_missing_mtime(self): |
| # Without mtimes every match would carry the same event time, so the |
| # cursor would drop everything after the first poll. |
| with self.assertRaises(BeamIOError): |
| fileio._ensure_mtime(FileMetadata('/tmp/a', 1)) |
| |
| def test_timestamp_cursor_requires_deduplication(self): |
| with self.assertRaisesRegex(ValueError, 'has_deduplication=True'): |
| fileio.MatchContinuously( |
| file_pattern='/tmp/*', has_deduplication=False, timestamp_cursor=True) |
| |
| def test_timestamp_cursor_implies_matching_updated_files(self): |
| # timestamp_cursor forces match_updated_files=True, so an update is a new |
| # key whatever the caller passed. |
| match = fileio.MatchContinuously( |
| file_pattern='/tmp/*', timestamp_cursor=True) |
| self.assertTrue(match.match_upd) |
| |
| def test_timestamp_cursor_emits_files_modified_past_the_cursor(self): |
| files = [] |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Create a file to be matched before pipeline |
| files.append(self._create_temp_file(dir=tempdir)) |
| # Add file name that will be created mid-pipeline |
| files.append(FileSystems.join(tempdir, 'extra')) |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| def _create_extra_file(element): |
| writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) |
| writer.close() |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop, |
| timestamp_cursor=True) |
| | beam.Map(_create_extra_file)) |
| |
| assert_that(match_continiously, equal_to(files)) |
| |
| def test_timestamp_cursor_skips_files_modified_before_the_cursor(self): |
| # A file that lands with an mtime older than one already emitted sits |
| # behind the cursor, so it never appears. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| first = self._create_temp_file(dir=tempdir) |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| def _create_backdated_file(element): |
| path = FileSystems.join(tempdir, 'backdated') |
| writer = FileSystems.create(path) |
| writer.close() |
| os.utime(path, (1234.5, 1234.5)) |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop, |
| timestamp_cursor=True) |
| | beam.Map(_create_backdated_file)) |
| |
| assert_that(match_continiously, equal_to([first])) |
| |
| def test_timestamp_cursor_emits_a_file_sharing_the_newest_mtime(self): |
| # A file landing with the same last-modified time as the newest one |
| # already emitted is still new. Filesystems that report to the |
| # millisecond, GCS among them, hand out such ties routinely. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| first = self._create_temp_file(dir=tempdir) |
| os.utime(first, (1234.5, 1234.5)) |
| twin = FileSystems.join(tempdir, 'twin') |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| def _create_twin(element): |
| writer = FileSystems.create(twin) |
| writer.close() |
| os.utime(twin, (1234.5, 1234.5)) |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop, |
| timestamp_cursor=True) |
| | beam.Map(_create_twin)) |
| |
| assert_that(match_continiously, equal_to([first, twin])) |
| |
| def test_timestamp_cursor_emits_an_updated_file(self): |
| # The cursor keys on the path and the last-modified time, so an update is |
| # a new key and is matched again. Keying on the path alone would leave the |
| # update out while the key is retained and let it through once the cursor |
| # retired the key, which made the outcome depend on cursor timing. |
| tempdir = '%s%s' % (self._new_tempdir(), os.sep) |
| path = self._create_temp_file(dir=tempdir) |
| os.utime(path, (1234.5, 1234.5)) |
| |
| interval = 0.2 |
| start = Timestamp.now() |
| stop = start + interval + 0.1 |
| |
| def _touch(element): |
| os.utime(path, (2345.5, 2345.5)) |
| return element.path |
| |
| with TestPipeline() as p: |
| match_continiously = ( |
| p |
| | fileio.MatchContinuously( |
| file_pattern=FileSystems.join(tempdir, '*'), |
| interval=interval, |
| start_timestamp=start, |
| stop_timestamp=stop, |
| timestamp_cursor=True) |
| | beam.Map(_touch)) |
| |
| assert_that(match_continiously, equal_to([path, path])) |
| |
| |
| class WriteFilesTest(_TestCaseWithTempDirCleanUp): |
| |
| SIMPLE_COLLECTION = [ |
| { |
| 'project': 'beam', 'foundation': 'apache' |
| }, |
| { |
| 'project': 'prometheus', 'foundation': 'cncf' |
| }, |
| { |
| 'project': 'flink', 'foundation': 'apache' |
| }, |
| { |
| 'project': 'grpc', 'foundation': 'cncf' |
| }, |
| { |
| 'project': 'spark', 'foundation': 'apache' |
| }, |
| { |
| 'project': 'kubernetes', 'foundation': 'cncf' |
| }, |
| { |
| 'project': 'spark', 'foundation': 'apache' |
| }, |
| { |
| 'project': 'knative', 'foundation': 'cncf' |
| }, |
| { |
| 'project': 'linux', 'foundation': 'linux' |
| }, |
| ] |
| |
| LARGER_COLLECTION = ['{:05d}'.format(i) for i in range(200)] |
| |
| CSV_HEADERS = ['project', 'foundation'] |
| |
| SIMPLE_COLLECTION_VALIDATION_SET = {(elm['project'], elm['foundation']) |
| for elm in SIMPLE_COLLECTION} |
| |
| class CsvSink(fileio.TextSink): |
| def __init__(self, headers): |
| self.headers = headers |
| |
| def write(self, record): |
| self._fh.write(','.join([record[h] for h in self.headers]).encode('utf8')) |
| self._fh.write('\n'.encode('utf8')) |
| |
| class JsonSink(fileio.TextSink): |
| def write(self, record): |
| self._fh.write(json.dumps(record).encode('utf8')) |
| self._fh.write('\n'.encode('utf8')) |
| |
| def test_write_to_single_file_batch(self): |
| |
| dir = self._new_tempdir() |
| |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | beam.Create(WriteFilesTest.SIMPLE_COLLECTION) |
| | "Serialize" >> beam.Map(json.dumps) |
| | beam.io.fileio.WriteToFiles(path=dir)) |
| |
| with TestPipeline() as p: |
| result = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(dir, '*')) |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda f: f.read_utf8().strip().split('\n')) |
| | beam.Map(json.loads)) |
| |
| assert_that(result, equal_to([row for row in self.SIMPLE_COLLECTION])) |
| |
| def test_write_to_dynamic_destination(self): |
| |
| sink_params = [ |
| fileio.TextSink, # pass a type signature |
| fileio.TextSink() # pass a FileSink object |
| ] |
| |
| # Test assumes that all records will be handled by same worker process, |
| # pin to FnApiRunner to guarantee hthis |
| runner = 'FnApiRunner' |
| |
| for sink in sink_params: |
| dir = self._new_tempdir() |
| |
| with TestPipeline(runner) as p: |
| _ = ( |
| p |
| | "Create" >> beam.Create(range(100)) |
| | beam.Map(lambda x: str(x)) |
| | fileio.WriteToFiles( |
| path=dir, |
| destination=lambda n: "odd" if int(n) % 2 else "even", |
| sink=sink, |
| file_naming=fileio.destination_prefix_naming("test"))) |
| |
| with TestPipeline(runner) as p: |
| result = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(dir, '*')) |
| | fileio.ReadMatches() |
| | beam.Map( |
| lambda f: ( |
| os.path.basename(f.metadata.path).split('-')[0], sorted( |
| map(int, f.read_utf8().strip().split('\n')))))) |
| |
| assert_that( |
| result, |
| equal_to([('odd', list(range(1, 100, 2))), |
| ('even', list(range(0, 100, 2)))])) |
| |
| def test_write_to_different_file_types_some_spilling(self): |
| |
| dir = self._new_tempdir() |
| |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | beam.Create(WriteFilesTest.SIMPLE_COLLECTION) |
| | beam.io.fileio.WriteToFiles( |
| path=dir, |
| destination=lambda record: record['foundation'], |
| sink=lambda dest: ( |
| WriteFilesTest.CsvSink(WriteFilesTest.CSV_HEADERS) |
| if dest == 'apache' else WriteFilesTest.JsonSink()), |
| file_naming=fileio.destination_prefix_naming(), |
| max_writers_per_bundle=1)) |
| |
| with TestPipeline() as p: |
| cncf_res = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(dir, 'cncf*')) |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda f: f.read_utf8().strip().split('\n')) |
| | beam.Map(json.loads)) |
| |
| apache_res = ( |
| p |
| | |
| "MatchApache" >> fileio.MatchFiles(FileSystems.join(dir, 'apache*')) |
| | "ReadApache" >> fileio.ReadMatches() |
| | "MapApache" >> |
| beam.FlatMap(lambda rf: csv.reader(_get_file_reader(rf)))) |
| |
| assert_that( |
| cncf_res, |
| equal_to([ |
| row for row in self.SIMPLE_COLLECTION |
| if row['foundation'] == 'cncf' |
| ]), |
| label='verifyCNCF') |
| |
| assert_that( |
| apache_res, |
| equal_to([[row['project'], row['foundation']] |
| for row in self.SIMPLE_COLLECTION |
| if row['foundation'] == 'apache']), |
| label='verifyApache') |
| |
| @unittest.skip('https://github.com/apache/beam/issues/21269') |
| def test_find_orphaned_files(self): |
| dir = self._new_tempdir() |
| |
| write_transform = beam.io.fileio.WriteToFiles(path=dir) |
| |
| def write_orphaned_file(temp_dir, writer_key): |
| temp_dir_path = FileSystems.join(dir, temp_dir) |
| |
| file_prefix_dir = FileSystems.join( |
| temp_dir_path, str(abs(hash(writer_key)))) |
| |
| file_name = '%s_%s' % (file_prefix_dir, uuid.uuid4()) |
| with FileSystems.create(file_name) as f: |
| f.write(b'Hello y\'all') |
| |
| return file_name |
| |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | beam.Create(WriteFilesTest.SIMPLE_COLLECTION) |
| | "Serialize" >> beam.Map(json.dumps) |
| | write_transform) |
| |
| # Pre-create the temp directory. |
| temp_dir_path = FileSystems.mkdirs( |
| FileSystems.join(dir, write_transform._temp_directory.get())) |
| write_orphaned_file( |
| write_transform._temp_directory.get(), (None, GlobalWindow())) |
| f2 = write_orphaned_file( |
| write_transform._temp_directory.get(), ('other-dest', GlobalWindow())) |
| |
| temp_dir_path = FileSystems.join(dir, write_transform._temp_directory.get()) |
| leftovers = FileSystems.match(['%s%s*' % (temp_dir_path, os.sep)]) |
| found_files = [m.path for m in leftovers[0].metadata_list] |
| self.assertListEqual(found_files, [f2]) |
| |
| def test_write_to_different_file_types(self): |
| |
| dir = self._new_tempdir() |
| |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | beam.Create(WriteFilesTest.SIMPLE_COLLECTION) |
| | beam.io.fileio.WriteToFiles( |
| path=dir, |
| destination=lambda record: record['foundation'], |
| sink=lambda dest: ( |
| WriteFilesTest.CsvSink(WriteFilesTest.CSV_HEADERS) |
| if dest == 'apache' else WriteFilesTest.JsonSink()), |
| file_naming=fileio.destination_prefix_naming())) |
| |
| with TestPipeline() as p: |
| cncf_res = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(dir, 'cncf*')) |
| | fileio.ReadMatches() |
| | beam.FlatMap(lambda f: f.read_utf8().strip().split('\n')) |
| | beam.Map(json.loads)) |
| |
| apache_res = ( |
| p |
| | |
| "MatchApache" >> fileio.MatchFiles(FileSystems.join(dir, 'apache*')) |
| | "ReadApache" >> fileio.ReadMatches() |
| | "MapApache" >> |
| beam.FlatMap(lambda rf: csv.reader(_get_file_reader(rf)))) |
| |
| assert_that( |
| cncf_res, |
| equal_to([ |
| row for row in self.SIMPLE_COLLECTION |
| if row['foundation'] == 'cncf' |
| ]), |
| label='verifyCNCF') |
| |
| assert_that( |
| apache_res, |
| equal_to([[row['project'], row['foundation']] |
| for row in self.SIMPLE_COLLECTION |
| if row['foundation'] == 'apache']), |
| label='verifyApache') |
| |
| def record_dofn(self): |
| class RecordDoFn(beam.DoFn): |
| def process(self, element): |
| WriteFilesTest.all_records.append(element) |
| |
| return RecordDoFn() |
| |
| def test_streaming_complex_timing(self): |
| # Use state on the TestCase class, since other references would be pickled |
| # into a closure and not have the desired side effects. |
| # |
| # TODO(https://github.com/apache/beam/issues/18987): Use assert_that after |
| # it works for the cases here in streaming mode. |
| WriteFilesTest.all_records = [] |
| |
| dir = '%s%s' % (self._new_tempdir(), os.sep) |
| |
| # Setting up the input (TestStream) |
| ts = TestStream().advance_watermark_to(0) |
| for elm in WriteFilesTest.LARGER_COLLECTION: |
| timestamp = int(elm) |
| |
| ts.add_elements([('key', '%s' % elm)]) |
| if timestamp % 5 == 0 and timestamp != 0: |
| # TODO(https://github.com/apache/beam/issues/18721): Add many firings |
| # per window after getting PaneInfo. |
| ts.advance_processing_time(5) |
| ts.advance_watermark_to(timestamp) |
| ts.advance_watermark_to_infinity() |
| |
| def no_colon_file_naming(*args): |
| file_name = fileio.destination_prefix_naming()(*args) |
| return file_name.replace(':', '_') |
| |
| # The pipeline that we are testing |
| options = PipelineOptions() |
| options.view_as(StandardOptions).streaming = True |
| with TestPipeline(options=options) as p: |
| res = ( |
| p |
| | ts |
| | beam.WindowInto( |
| FixedWindows(10), |
| trigger=trigger.AfterWatermark(), |
| accumulation_mode=trigger.AccumulationMode.DISCARDING) |
| | beam.GroupByKey() |
| | beam.FlatMap(lambda x: x[1])) |
| # Triggering after 5 processing-time seconds, and on the watermark. Also |
| # discarding old elements. |
| |
| _ = ( |
| res |
| | beam.io.fileio.WriteToFiles( |
| path=dir, |
| file_naming=no_colon_file_naming, |
| max_writers_per_bundle=0) |
| | beam.Map(lambda fr: FileSystems.join(dir, fr.file_name)) |
| | beam.ParDo(self.record_dofn())) |
| |
| # Verification pipeline |
| with TestPipeline() as p: |
| files = (p | beam.io.fileio.MatchFiles(FileSystems.join(dir, '*'))) |
| |
| file_names = (files | beam.Map(lambda fm: fm.path)) |
| |
| file_contents = ( |
| files |
| | beam.io.fileio.ReadMatches() |
| | beam.Map( |
| lambda rf: (rf.metadata.path, rf.read_utf8().strip().split('\n'))) |
| ) |
| |
| content = ( |
| file_contents |
| | beam.FlatMap(lambda fc: [ln.strip() for ln in fc[1]])) |
| |
| assert_that( |
| file_names, |
| equal_to(WriteFilesTest.all_records), |
| label='AssertFilesMatch') |
| assert_that( |
| content, |
| matches_all(WriteFilesTest.LARGER_COLLECTION), |
| label='AssertContentsMatch') |
| |
| def test_streaming_different_file_types(self): |
| dir = self._new_tempdir() |
| input = iter(WriteFilesTest.SIMPLE_COLLECTION) |
| ts = ( |
| TestStream().advance_watermark_to(0).add_elements( |
| [next(input), next(input)]).advance_watermark_to(10).add_elements( |
| [next(input), |
| next(input)]).advance_watermark_to(20).add_elements([ |
| next(input), next(input) |
| ]).advance_watermark_to(30).add_elements([ |
| next(input), next(input) |
| ]).advance_watermark_to(40).advance_watermark_to_infinity()) |
| |
| def no_colon_file_naming(*args): |
| file_name = fileio.destination_prefix_naming()(*args) |
| return file_name.replace(':', '_') |
| |
| with TestPipeline() as p: |
| _ = ( |
| p |
| | ts |
| | beam.WindowInto(FixedWindows(10)) |
| | beam.io.fileio.WriteToFiles( |
| path=dir, |
| destination=lambda record: record['foundation'], |
| sink=lambda dest: ( |
| WriteFilesTest.CsvSink(WriteFilesTest.CSV_HEADERS) |
| if dest == 'apache' else WriteFilesTest.JsonSink()), |
| file_naming=no_colon_file_naming, |
| max_writers_per_bundle=0, |
| )) |
| |
| with TestPipeline() as p: |
| cncf_files = ( |
| p |
| | fileio.MatchFiles(FileSystems.join(dir, 'cncf*')) |
| | "CncfFileNames" >> beam.Map(lambda fm: fm.path)) |
| |
| apache_files = ( |
| p |
| | |
| "MatchApache" >> fileio.MatchFiles(FileSystems.join(dir, 'apache*')) |
| | "ApacheFileNames" >> beam.Map(lambda fm: fm.path)) |
| |
| assert_that( |
| cncf_files, |
| matches_all([ |
| stringmatches.matches_regexp( |
| '.*cncf-1970-01-01T00_00_00-1970-01-01T00_00_10.*'), |
| stringmatches.matches_regexp( |
| '.*cncf-1970-01-01T00_00_10-1970-01-01T00_00_20.*'), |
| stringmatches.matches_regexp( |
| '.*cncf-1970-01-01T00_00_20-1970-01-01T00_00_30.*'), |
| stringmatches.matches_regexp( |
| '.*cncf-1970-01-01T00_00_30-1970-01-01T00_00_40.*') |
| ]), |
| label='verifyCNCFFiles') |
| |
| assert_that( |
| apache_files, |
| matches_all([ |
| stringmatches.matches_regexp( |
| '.*apache-1970-01-01T00_00_00-1970-01-01T00_00_10.*'), |
| stringmatches.matches_regexp( |
| '.*apache-1970-01-01T00_00_10-1970-01-01T00_00_20.*'), |
| stringmatches.matches_regexp( |
| '.*apache-1970-01-01T00_00_20-1970-01-01T00_00_30.*'), |
| stringmatches.matches_regexp( |
| '.*apache-1970-01-01T00_00_30-1970-01-01T00_00_40.*') |
| ]), |
| label='verifyApacheFiles') |
| |
| def test_shard_naming(self): |
| namer = fileio.default_file_naming(prefix='/path/to/file', suffix='.txt') |
| self.assertEqual( |
| namer(GlobalWindow(), None, None, None, None, None), |
| '/path/to/file.txt') |
| self.assertEqual( |
| namer(GlobalWindow(), None, 1, 5, None, None), |
| '/path/to/file-00001-of-00005.txt') |
| self.assertEqual( |
| namer(GlobalWindow(), None, 1, 5, 'gz', None), |
| '/path/to/file-00001-of-00005.txt.gz') |
| self.assertEqual( |
| namer(IntervalWindow(0, 100), None, 1, 5, None, None), |
| '/path/to/file' |
| '-1970-01-01T00:00:00-1970-01-01T00:01:40-00001-of-00005.txt') |
| |
| |
| if __name__ == '__main__': |
| logging.getLogger().setLevel(logging.INFO) |
| unittest.main() |