Skip to content

Commit f7d8d7c

Browse files
authored
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
1 parent b9e4e2f commit f7d8d7c

3 files changed

Lines changed: 290 additions & 15 deletions

File tree

sdks/python/apache_beam/io/gcp/bigquery_file_loads.py

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@
8282
_SLEEP_DURATION_BETWEEN_POLLS = 10
8383

8484

85+
def _has_partitioning_load_parameters(additional_parameters):
86+
return (
87+
'timePartitioning' in additional_parameters or
88+
'rangePartitioning' in additional_parameters)
89+
90+
91+
def _add_destination_partitioning_load_parameters(
92+
additional_parameters, destination_table):
93+
if destination_table is None:
94+
return additional_parameters
95+
96+
additional_parameters = dict(additional_parameters)
97+
time_partitioning = getattr(destination_table, 'timePartitioning', None)
98+
range_partitioning = getattr(destination_table, 'rangePartitioning', None)
99+
100+
if ('timePartitioning' not in additional_parameters and
101+
isinstance(time_partitioning, bigquery_tools.bigquery.TimePartitioning)):
102+
additional_parameters['timePartitioning'] = time_partitioning
103+
104+
if ('rangePartitioning' not in additional_parameters and isinstance(
105+
range_partitioning, bigquery_tools.bigquery.RangePartitioning)):
106+
additional_parameters['rangePartitioning'] = range_partitioning
107+
108+
return additional_parameters
109+
110+
85111
def _generate_job_name(job_name, job_type, step_name):
86112
return bigquery_tools.generate_bq_job_name(
87113
job_name=job_name,
@@ -688,6 +714,7 @@ def start_bundle(self):
688714
self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
689715
self.pending_jobs = []
690716
self.schema_cache = {}
717+
self.destination_table_cache = {}
691718

692719
def process(
693720
self,
@@ -716,6 +743,7 @@ def process(
716743
additional_parameters = self.additional_bq_parameters.get()
717744
else:
718745
additional_parameters = self.additional_bq_parameters
746+
additional_parameters = dict(additional_parameters or {})
719747

720748
table_reference = bigquery_tools.parse_table_reference(destination)
721749
if table_reference.projectId is None:
@@ -735,28 +763,55 @@ def process(
735763

736764
create_disposition = self.create_disposition
737765
if self.temporary_tables:
766+
destination_table = None
767+
hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
768+
need_schema = schema is None and hashed_dest not in self.schema_cache
769+
need_partitioning = not _has_partitioning_load_parameters(
770+
additional_parameters)
771+
if need_schema or need_partitioning:
772+
try:
773+
if hashed_dest in self.destination_table_cache:
774+
destination_table = self.destination_table_cache[hashed_dest]
775+
else:
776+
destination_table = self.bq_wrapper.get_table(
777+
project_id=table_reference.projectId,
778+
dataset_id=table_reference.datasetId,
779+
table_id=table_reference.tableId)
780+
self.destination_table_cache[hashed_dest] = destination_table
781+
except Exception as e:
782+
if need_schema:
783+
_LOGGER.warning(
784+
"Input schema is absent and could not fetch the final "
785+
"destination table's schema [%s]. Creating temp table [%s] "
786+
"will likely fail: %s",
787+
hashed_dest,
788+
job_name,
789+
e)
790+
destination_table = None
791+
738792
# we need to create temp tables, so we need a schema.
739793
# if there is no input schema, fetch the destination table's schema
740794
if schema is None:
741-
hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
742795
if hashed_dest in self.schema_cache:
743796
schema = self.schema_cache[hashed_dest]
744-
else:
745-
try:
746-
schema = bigquery_tools.table_schema_to_dict(
747-
bigquery_tools.BigQueryWrapper().get_table(
748-
project_id=table_reference.projectId,
749-
dataset_id=table_reference.datasetId,
750-
table_id=table_reference.tableId).schema)
797+
elif destination_table is not None:
798+
destination_schema = getattr(destination_table, 'schema', None)
799+
if isinstance(destination_schema,
800+
bigquery_tools.bigquery.TableSchema):
801+
schema = bigquery_tools.table_schema_to_dict(destination_schema)
751802
self.schema_cache[hashed_dest] = schema
752-
except Exception as e:
803+
else:
753804
_LOGGER.warning(
754-
"Input schema is absent and could not fetch the final "
755-
"destination table's schema [%s]. Creating temp table [%s] "
756-
"will likely fail: %s",
805+
"Input schema is absent and the final destination table [%s] "
806+
"does not have a usable schema. Creating temp table [%s] will "
807+
"likely fail.",
757808
hashed_dest,
758-
job_name,
759-
e)
809+
job_name)
810+
811+
if (destination_table is not None and
812+
not _has_partitioning_load_parameters(additional_parameters)):
813+
additional_parameters = _add_destination_partitioning_load_parameters(
814+
additional_parameters, destination_table)
760815

761816
# If we are using temporary tables, then we must always create the
762817
# temporary tables, so we replace the create_disposition.

sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,157 @@ def test_one_load_job_failed_after_waiting(self, sleep_mock):
703703

704704
sleep_mock.assert_called_once()
705705

706+
def test_temporary_table_load_inherits_destination_time_partitioning(self):
707+
destination = 'project1:dataset1.table1'
708+
partition = (destination, (0, ['gs://bucket/file1']))
709+
job_reference = bigquery_api.JobReference(
710+
projectId='project1', jobId='job_name1')
711+
destination_table = bigquery_api.Table(
712+
timePartitioning=bigquery_api.TimePartitioning(type='DAY'))
713+
714+
dofn = bqfl.TriggerLoadJobs(
715+
schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(), temporary_tables=True)
716+
dofn.start_bundle()
717+
dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
718+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
719+
720+
list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
721+
722+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
723+
self.assertEqual(
724+
load_call['additional_load_parameters']['timePartitioning'],
725+
destination_table.timePartitioning)
726+
dofn.bq_wrapper.get_table.assert_called_once_with(
727+
project_id='project1', dataset_id='dataset1', table_id='table1')
728+
729+
def test_temporary_table_load_inherits_destination_range_partitioning(self):
730+
destination = 'project1:dataset1.table1'
731+
partition = (destination, (0, ['gs://bucket/file1']))
732+
job_reference = bigquery_api.JobReference(
733+
projectId='project1', jobId='job_name1')
734+
destination_table = bigquery_api.Table(
735+
rangePartitioning=bigquery_api.RangePartitioning())
736+
737+
dofn = bqfl.TriggerLoadJobs(
738+
schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(), temporary_tables=True)
739+
dofn.start_bundle()
740+
dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
741+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
742+
743+
list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
744+
745+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
746+
self.assertEqual(
747+
load_call['additional_load_parameters']['rangePartitioning'],
748+
destination_table.rangePartitioning)
749+
dofn.bq_wrapper.get_table.assert_called_once_with(
750+
project_id='project1', dataset_id='dataset1', table_id='table1')
751+
752+
def test_temporary_table_load_keeps_explicit_partitioning_parameters(self):
753+
destination = 'project1:dataset1.table1'
754+
partition = (destination, (0, ['gs://bucket/file1']))
755+
explicit_partitioning = {'timePartitioning': {'type': 'DAY'}}
756+
job_reference = bigquery_api.JobReference(
757+
projectId='project1', jobId='job_name1')
758+
759+
dofn = bqfl.TriggerLoadJobs(
760+
schema=_ELEMENTS_SCHEMA,
761+
test_client=mock.Mock(),
762+
temporary_tables=True,
763+
additional_bq_parameters=explicit_partitioning)
764+
dofn.start_bundle()
765+
dofn.bq_wrapper.get_table = mock.Mock()
766+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
767+
768+
list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
769+
770+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
771+
self.assertEqual(
772+
load_call['additional_load_parameters'], explicit_partitioning)
773+
dofn.bq_wrapper.get_table.assert_not_called()
774+
775+
def test_temporary_table_load_uses_cached_schema_with_explicit_partitioning(
776+
self):
777+
destination = 'project1:dataset1.table1'
778+
partition = (destination, (0, ['gs://bucket/file1']))
779+
explicit_partitioning = {'timePartitioning': {'type': 'DAY'}}
780+
job_reference = bigquery_api.JobReference(
781+
projectId='project1', jobId='job_name1')
782+
table_reference = bigquery_tools.parse_table_reference(destination)
783+
hashed_dest = bigquery_tools.get_hashable_destination(table_reference)
784+
785+
dofn = bqfl.TriggerLoadJobs(
786+
schema=None,
787+
test_client=mock.Mock(),
788+
temporary_tables=True,
789+
additional_bq_parameters=explicit_partitioning)
790+
dofn.start_bundle()
791+
dofn.schema_cache[hashed_dest] = _ELEMENTS_SCHEMA
792+
dofn.bq_wrapper.get_table = mock.Mock()
793+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
794+
795+
list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
796+
797+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
798+
self.assertEqual(load_call['schema'], _ELEMENTS_SCHEMA)
799+
self.assertEqual(
800+
load_call['additional_load_parameters'], explicit_partitioning)
801+
dofn.bq_wrapper.get_table.assert_not_called()
802+
803+
def test_temporary_table_load_caches_destination_table_per_bundle(self):
804+
destination = 'project1:dataset1.table1'
805+
first_partition = (destination, (0, ['gs://bucket/file1']))
806+
second_partition = (destination, (1, ['gs://bucket/file2']))
807+
job_reference = bigquery_api.JobReference(
808+
projectId='project1', jobId='job_name1')
809+
destination_table = bigquery_api.Table(
810+
timePartitioning=bigquery_api.TimePartitioning(type='DAY'))
811+
812+
dofn = bqfl.TriggerLoadJobs(
813+
schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(), temporary_tables=True)
814+
dofn.start_bundle()
815+
dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
816+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
817+
818+
list(
819+
dofn.process(first_partition, 'test_job', pane_info=mock.Mock(index=0)))
820+
list(
821+
dofn.process(
822+
second_partition, 'test_job', pane_info=mock.Mock(index=1)))
823+
824+
dofn.bq_wrapper.get_table.assert_called_once_with(
825+
project_id='project1', dataset_id='dataset1', table_id='table1')
826+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
827+
self.assertEqual(
828+
load_call['additional_load_parameters']['timePartitioning'],
829+
destination_table.timePartitioning)
830+
831+
def test_temporary_table_load_ignores_invalid_mock_partitioning_metadata(
832+
self):
833+
destination = 'project1:dataset1.table1'
834+
partition = (destination, (0, ['gs://bucket/file1']))
835+
job_reference = bigquery_api.JobReference(
836+
projectId='project1', jobId='job_name1')
837+
destination_table = mock.Mock()
838+
destination_table.timePartitioning = mock.Mock()
839+
destination_table.rangePartitioning = mock.Mock()
840+
841+
dofn = bqfl.TriggerLoadJobs(
842+
schema=_ELEMENTS_SCHEMA, test_client=mock.Mock(), temporary_tables=True)
843+
dofn.start_bundle()
844+
dofn.bq_wrapper.get_table = mock.Mock(return_value=destination_table)
845+
dofn.bq_wrapper.perform_load_job = mock.Mock(return_value=job_reference)
846+
847+
list(dofn.process(partition, 'test_job', pane_info=mock.Mock(index=0)))
848+
849+
load_call = dofn.bq_wrapper.perform_load_job.call_args.kwargs
850+
self.assertNotIn(
851+
'timePartitioning', load_call['additional_load_parameters'])
852+
self.assertNotIn(
853+
'rangePartitioning', load_call['additional_load_parameters'])
854+
dofn.bq_wrapper.get_table.assert_called_once_with(
855+
project_id='project1', dataset_id='dataset1', table_id='table1')
856+
706857
def test_multiple_partition_files(self):
707858
destination = 'project1:dataset1.table1'
708859

sdks/python/apache_beam/io/gcp/bigquery_write_it_test.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from apache_beam.io.gcp.bigquery_tools import FileFormat
4444
from apache_beam.io.gcp.internal.clients import bigquery
4545
from apache_beam.io.gcp.tests.bigquery_matcher import BigqueryFullResultMatcher
46+
from apache_beam.io.gcp.tests.bigquery_matcher import BigQueryTableMatcher
4647
from apache_beam.testing.test_pipeline import TestPipeline
4748
from apache_beam.testing.util import assert_that
4849
from apache_beam.testing.util import equal_to
@@ -87,7 +88,7 @@ def tearDown(self):
8788
self.dataset_id,
8889
self.project)
8990

90-
def create_table(self, table_name):
91+
def create_table(self, table_name, time_partitioning=None):
9192
table_schema = bigquery.TableSchema()
9293
table_field = bigquery.TableFieldSchema()
9394
table_field.name = 'int64'
@@ -112,6 +113,8 @@ def create_table(self, table_name):
112113
datasetId=self.dataset_id,
113114
tableId=table_name),
114115
schema=table_schema)
116+
if time_partitioning is not None:
117+
table.timePartitioning = time_partitioning
115118
request = bigquery.BigqueryTablesInsertRequest(
116119
projectId=self.project, datasetId=self.dataset_id, table=table)
117120
self.bigquery_client.client.tables.Insert(request)
@@ -377,6 +380,72 @@ def test_big_query_write_without_schema(self):
377380
write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND,
378381
temp_file_format=FileFormat.JSON))
379382

383+
@pytest.mark.it_postcommit
384+
@mock.patch(
385+
"apache_beam.io.gcp.bigquery_file_loads._MAXIMUM_SOURCE_URIS", new=1)
386+
@retry(reraise=True, stop=stop_after_attempt(3))
387+
def test_big_query_file_loads_existing_partitioned_table(self):
388+
table_name = 'python_file_loads_partitioned_table'
389+
self.create_table(
390+
table_name,
391+
time_partitioning=bigquery.TimePartitioning(field='date', type='DAY'))
392+
table_id = '{}.{}'.format(self.dataset_id, table_name)
393+
394+
input_data = [{
395+
'int64': 1, 'bytes': b'abc', 'date': '2026-01-01', 'time': '00:00:00'
396+
},
397+
{
398+
'int64': 2,
399+
'bytes': b'xyz',
400+
'date': '2026-01-02',
401+
'time': '12:34:56'
402+
}]
403+
for row in input_data:
404+
row['bytes'] = base64.b64encode(row['bytes'])
405+
406+
args = self.test_pipeline.get_full_options_as_args(
407+
on_success_matcher=hc.all_of(
408+
BigqueryFullResultMatcher(
409+
project=self.project,
410+
query=(
411+
"SELECT int64, bytes, date, time FROM %s ORDER BY int64" %
412+
table_id),
413+
data=[
414+
(
415+
1,
416+
b'abc',
417+
datetime.date(2026, 1, 1),
418+
datetime.time(0, 0, 0),
419+
),
420+
(
421+
2,
422+
b'xyz',
423+
datetime.date(2026, 1, 2),
424+
datetime.time(12, 34, 56),
425+
),
426+
]),
427+
BigQueryTableMatcher(
428+
project=self.project,
429+
dataset=self.dataset_id,
430+
table=table_name,
431+
expected_properties={
432+
'timePartitioning': {
433+
'field': 'date',
434+
'type': 'DAY',
435+
}
436+
})))
437+
438+
with beam.Pipeline(argv=args) as p:
439+
# pylint: disable=expression-not-assigned
440+
(
441+
p | 'create' >> beam.Create(input_data)
442+
| 'write' >> beam.io.WriteToBigQuery(
443+
table_id,
444+
write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND,
445+
max_file_size=1, # bytes
446+
method=beam.io.WriteToBigQuery.Method.FILE_LOADS,
447+
temp_file_format=FileFormat.JSON))
448+
380449
@pytest.mark.it_postcommit
381450
def test_big_query_write_insert_errors_reporting(self):
382451
"""

0 commit comments

Comments
 (0)