This is an automated email from the ASF dual-hosted git repository.
ahmedabu98 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new f7d8d7c8b58 Preserve partitioning on temp FILE_LOADS tables (#38833)
f7d8d7c8b58 is described below
commit f7d8d7c8b586bcb768d2b6f786bde8cc30a1e99d
Author: PRADDZY <[email protected]>
AuthorDate: Fri Jul 31 00:29:36 2026 +0530
Preserve partitioning on temp FILE_LOADS tables (#38833)
* Preserve partitioning on temp file loads
* Apply yapf formatting for temp file loads
* Cache temp load destination metadata
* Filter invalid temp load partition metadata
* Retrigger cancelled CI workflow
* Add BigQuery file loads partitioning IT
* Fix BigQuery write IT import order
---
.../apache_beam/io/gcp/bigquery_file_loads.py | 83 +++++++++--
.../apache_beam/io/gcp/bigquery_file_loads_test.py | 151 +++++++++++++++++++++
.../apache_beam/io/gcp/bigquery_write_it_test.py | 71 +++++++++-
3 files changed, 290 insertions(+), 15 deletions(-)
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
index 8857b5fb433..dabe80c5c27 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py
@@ -82,6 +82,32 @@ _FILE_TRIGGERING_BATCHING_DURATION_SECS = 1
_SLEEP_DURATION_BETWEEN_POLLS = 10
+def _has_partitioning_load_parameters(additional_parameters):
+ return (
+ 'timePartitioning' in additional_parameters or
+ 'rangePartitioning' in additional_parameters)
+
+
+def _add_destination_partitioning_load_parameters(
+ additional_parameters, destination_table):
+ if destination_table is None:
+ return additional_parameters
+
+ additional_parameters = dict(additional_parameters)
+ time_partitioning = getattr(destination_table, 'timePartitioning', None)
+ range_partitioning = getattr(destination_table, 'rangePartitioning', None)
+
+ if ('timePartitioning' not in additional_parameters and
+ isinstance(time_partitioning, bigquery_tools.bigquery.TimePartitioning)):
+ additional_parameters['timePartitioning'] = time_partitioning
+
+ if ('rangePartitioning' not in additional_parameters and isinstance(
+ range_partitioning, bigquery_tools.bigquery.RangePartitioning)):
+ additional_parameters['rangePartitioning'] = range_partitioning
+
+ return additional_parameters
+
+
def _generate_job_name(job_name, job_type, step_name):
return bigquery_tools.generate_bq_job_name(
job_name=job_name,
@@ -688,6 +714,7 @@ class TriggerLoadJobs(beam.DoFn):
self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
self.pending_jobs = []
self.schema_cache = {}
+ self.destination_table_cache = {}
def process(
self,
@@ -716,6 +743,7 @@ class TriggerLoadJobs(beam.DoFn):
additional_parameters = self.additional_bq_parameters.get()
else:
additional_parameters = self.additional_bq_parameters
+ additional_parameters = dict(additional_parameters or {})
table_reference = bigquery_tools.parse_table_reference(destination)
if table_reference.projectId is None:
@@ -735,28 +763,55 @@ class TriggerLoadJobs(beam.DoFn):
create_disposition = self.create_disposition
if self.temporary_tables:
+ destination_table = None
+ hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
+ need_schema = schema is None and hashed_dest not in self.schema_cache
+ need_partitioning = not _has_partitioning_load_parameters(
+ additional_parameters)
+ if need_schema or need_partitioning:
+ try:
+ if hashed_dest in self.destination_table_cache:
+ destination_table = self.destination_table_cache[hashed_dest]
+ else:
+ destination_table = self.bq_wrapper.get_table(
+ project_id=table_reference.projectId,
+ dataset_id=table_reference.datasetId,
+ table_id=table_reference.tableId)
+ self.destination_table_cache[hashed_dest] = destination_table
+ except Exception as e:
+ if need_schema:
+ _LOGGER.warning(
+ "Input schema is absent and could not fetch the final "
+ "destination table's schema [%s]. Creating temp table [%s] "
+ "will likely fail: %s",
+ hashed_dest,
+ job_name,
+ e)
+ destination_table = None
+
# we need to create temp tables, so we need a schema.
# if there is no input schema, fetch the destination table's schema
if schema is None:
- hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
if hashed_dest in self.schema_cache:
schema = self.schema_cache[hashed_dest]
- else:
- try:
- schema = bigquery_tools.table_schema_to_dict(
- bigquery_tools.BigQueryWrapper().get_table(
- project_id=table_reference.projectId,
- dataset_id=table_reference.datasetId,
- table_id=table_reference.tableId).schema)
+ elif destination_table is not None:
+ destination_schema = getattr(destination_table, 'schema', None)
+ if isinstance(destination_schema,
+ bigquery_tools.bigquery.TableSchema):
+ schema = bigquery_tools.table_schema_to_dict(destination_schema)
self.schema_cache[hashed_dest] = schema
- except Exception as e:
+ else:
_LOGGER.warning(
- "Input schema is absent and could not fetch the final "
- "destination table's schema [%s]. Creating temp table [%s] "
- "will likely fail: %s",
+ "Input schema is absent and the final destination table [%s] "
+ "does not have a usable schema. Creating temp table [%s] will "
+ "likely fail.",
hashed_dest,
- job_name,
- e)
+ job_name)
+
+ if (destination_table is not None and
+ not _has_partitioning_load_parameters(additional_parameters)):
+ additional_parameters = _add_destination_partitioning_load_parameters(
+ additional_parameters, destination_table)
# If we are using temporary tables, then we must always create the
# temporary tables, so we replace the create_disposition.
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
index 191719e6a20..fda4a3e9d52 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py
@@ -703,6 +703,157 @@ class TestBigQueryFileLoads(_TestCaseWithTempDirCleanUp):
sleep_mock.assert_called_once()
+ def test_temporary_table_load_inherits_destination_time_partitioning(self):
+ destination = 'project1:dataset1.table1'
+ partition = (destination, (0, ['gs://bucket/file1']))
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+ destination_table = bigquery_api.Table(
+ timePartitioning=bigquery_api.TimePartitioning(type='DAY'))
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(),
temporary_tables=True)
+ dofn.start_bundle()
+ dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
+
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertEqual(
+ load_call['additional_load_parameters']['timePartitioning'],
+ destination_table.timePartitioning)
+ dofn.bq_wrapper.get_table.assert_called_once_with(
+ project_id='project1', dataset_id='dataset1', table_id='table1')
+
+ def test_temporary_table_load_inherits_destination_range_partitioning(self):
+ destination = 'project1:dataset1.table1'
+ partition = (destination, (0, ['gs://bucket/file1']))
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+ destination_table = bigquery_api.Table(
+ rangePartitioning=bigquery_api.RangePartitioning())
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(),
temporary_tables=True)
+ dofn.start_bundle()
+ dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
+
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertEqual(
+ load_call['additional_load_parameters']['rangePartitioning'],
+ destination_table.rangePartitioning)
+ dofn.bq_wrapper.get_table.assert_called_once_with(
+ project_id='project1', dataset_id='dataset1', table_id='table1')
+
+ def test_temporary_table_load_keeps_explicit_partitioning_parameters(self):
+ destination = 'project1:dataset1.table1'
+ partition = (destination, (0, ['gs://bucket/file1']))
+ explicit_partitioning = {'timePartitioning': {'type': 'DAY'}}
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=_ELEMENTS_SCHEMA,
+ test_client=mock.Mock(),
+ temporary_tables=True,
+ additional_bq_parameters=explicit_partitioning)
+ dofn.start_bundle()
+ dofn.bq_wrapper.get_table = mock.Mock()
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
+
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertEqual(
+ load_call['additional_load_parameters'], explicit_partitioning)
+ dofn.bq_wrapper.get_table.assert_not_called()
+
+ def test_temporary_table_load_uses_cached_schema_with_explicit_partitioning(
+ self):
+ destination = 'project1:dataset1.table1'
+ partition = (destination, (0, ['gs://bucket/file1']))
+ explicit_partitioning = {'timePartitioning': {'type': 'DAY'}}
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+ table_reference = bigquery_tools.parse_table_reference(destination)
+ hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=None,
+ test_client=mock.Mock(),
+ temporary_tables=True,
+ additional_bq_parameters=explicit_partitioning)
+ dofn.start_bundle()
+ dofn.schema_cache[hashed_dest] = _ELEMENTS_SCHEMA
+ dofn.bq_wrapper.get_table = mock.Mock()
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
+
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertEqual(load_call['schema'], _ELEMENTS_SCHEMA)
+ self.assertEqual(
+ load_call['additional_load_parameters'], explicit_partitioning)
+ dofn.bq_wrapper.get_table.assert_not_called()
+
+ def test_temporary_table_load_caches_destination_table_per_bundle(self):
+ destination = 'project1:dataset1.table1'
+ first_partition = (destination, (0, ['gs://bucket/file1']))
+ second_partition = (destination, (1, ['gs://bucket/file2']))
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+ destination_table = bigquery_api.Table(
+ timePartitioning=bigquery_api.TimePartitioning(type='DAY'))
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(),
temporary_tables=True)
+ dofn.start_bundle()
+ dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(
+ dofn.process(first_partition, 'test_job',
pane_info=mock.Mock(index=0)))
+ list(
+ dofn.process(
+ second_partition, 'test_job', pane_info=mock.Mock(index=1)))
+
+ dofn.bq_wrapper.get_table.assert_called_once_with(
+ project_id='project1', dataset_id='dataset1', table_id='table1')
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertEqual(
+ load_call['additional_load_parameters']['timePartitioning'],
+ destination_table.timePartitioning)
+
+ def test_temporary_table_load_ignores_invalid_mock_partitioning_metadata(
+ self):
+ destination = 'project1:dataset1.table1'
+ partition = (destination, (0, ['gs://bucket/file1']))
+ job_reference = bigquery_api.JobReference(
+ projectId='project1', jobId='job_name1')
+ destination_table = mock.Mock()
+ destination_table.timePartitioning = mock.Mock()
+ destination_table.rangePartitioning = mock.Mock()
+
+ dofn = bqfl.TriggerLoadJobs(
+ schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(),
temporary_tables=True)
+ dofn.start_bundle()
+ dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
+ dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
+
+ list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
+
+ load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
+ self.assertNotIn(
+ 'timePartitioning', load_call['additional_load_parameters'])
+ self.assertNotIn(
+ 'rangePartitioning', load_call['additional_load_parameters'])
+ dofn.bq_wrapper.get_table.assert_called_once_with(
+ project_id='project1', dataset_id='dataset1', table_id='table1')
+
def test_multiple_partition_files(self):
destination = 'project1:dataset1.table1'
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_write_it_test.py
b/sdks/python/apache_beam/io/gcp/bigquery_write_it_test.py
index c694383dcf9..adf0c210d94 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_write_it_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_write_it_test.py
@@ -43,6 +43,7 @@ from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper
from apache_beam.io.gcp.bigquery_tools import FileFormat
from apache_beam.io.gcp.internal.clients import bigquery
from apache_beam.io.gcp.tests.bigquery_matcher import BigqueryFullResultMatcher
+from apache_beam.io.gcp.tests.bigquery_matcher import BigQueryTableMatcher
from apache_beam.testing.test_pipeline import TestPipeline
from apache_beam.testing.util import assert_that
from apache_beam.testing.util import equal_to
@@ -87,7 +88,7 @@ class BigQueryWriteIntegrationTests(unittest.TestCase):
self.dataset_id,
self.project)
- def create_table(self, table_name):
+ def create_table(self, table_name, time_partitioning=None):
table_schema = bigquery.TableSchema()
table_field = bigquery.TableFieldSchema()
table_field.name = 'int64'
@@ -112,6 +113,8 @@ class BigQueryWriteIntegrationTests(unittest.TestCase):
datasetId=self.dataset_id,
tableId=table_name),
schema=table_schema)
+ if time_partitioning is not None:
+ table.timePartitioning = time_partitioning
request = bigquery.BigqueryTablesInsertRequest(
projectId=self.project, datasetId=self.dataset_id, table=table)
self.bigquery_client.client.tables.Insert(request)
@@ -377,6 +380,72 @@ class BigQueryWriteIntegrationTests(unittest.TestCase):
write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND,
temp_file_format=FileFormat.JSON))
+ @pytest.mark.it_postcommit
+ @mock.patch(
+ "apache_beam.io.gcp.bigquery_file_loads._MAXIMUM_SOURCE_URIS", new=1)
+ @retry(reraise=True, stop=stop_after_attempt(3))
+ def test_big_query_file_loads_existing_partitioned_table(self):
+ table_name = 'python_file_loads_partitioned_table'
+ self.create_table(
+ table_name,
+ time_partitioning=bigquery.TimePartitioning(field='date', type='DAY'))
+ table_id = '{}.{}'.format(self.dataset_id, table_name)
+
+ input_data = [{
+ 'int64': 1, 'bytes': b'abc', 'date': '2026-01-01', 'time': '00:00:00'
+ },
+ {
+ 'int64': 2,
+ 'bytes': b'xyz',
+ 'date': '2026-01-02',
+ 'time': '12:34:56'
+ }]
+ for row in input_data:
+ row['bytes'] = base64.b64encode(row['bytes'])
+
+ args = self.test_pipeline.get_full_options_as_args(
+ on_success_matcher=hc.all_of(
+ BigqueryFullResultMatcher(
+ project=self.project,
+ query=(
+ "SELECT int64, bytes, date, time FROM %s ORDER BY int64" %
+ table_id),
+ data=[
+ (
+ 1,
+ b'abc',
+ datetime.date(2026, 1, 1),
+ datetime.time(0, 0, 0),
+ ),
+ (
+ 2,
+ b'xyz',
+ datetime.date(2026, 1, 2),
+ datetime.time(12, 34, 56),
+ ),
+ ]),
+ BigQueryTableMatcher(
+ project=self.project,
+ dataset=self.dataset_id,
+ table=table_name,
+ expected_properties={
+ 'timePartitioning': {
+ 'field': 'date',
+ 'type': 'DAY',
+ }
+ })))
+
+ with beam.Pipeline(argv=args) as p:
+ # pylint: disable=expression-not-assigned
+ (
+ p | 'create' >> beam.Create(input_data)
+ | 'write' >> beam.io.WriteToBigQuery(
+ table_id,
+ write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND,
+ max_file_size=1, # bytes
+ method=beam.io.WriteToBigQuery.Method.FILE_LOADS,
+ temp_file_format=FileFormat.JSON))
+
@pytest.mark.it_postcommit
def test_big_query_write_insert_errors_reporting(self):
"""