Skip to content

Commit 898dca4

Browse files
committed
Optimize BigQuery copy jobs in file loads using multi-source copy
Updates BigQuery file loads in Python SDK to use multi-source copy jobs when copying temporary tables to the final destination table. * Update BigQueryWrapper._insert_copy_job to support a list of source tables, utilizing BigQuery's multi-source copy capability. * Update TriggerCopyJobs to process temporary tables in batch, splitting them into chunks of 1,200 (BigQuery limit) and triggering multi-source copy jobs. * Implement inline wait for the first chunk in TriggerCopyJobs when write disposition is WRITE_TRUNCATE or WRITE_EMPTY and there are multiple chunks. This ensures the destination table is initialized by the first job before subsequent chunks append to it. * Fix grouping key in _load_data for WRITE_TRUNCATE/WRITE_EMPTY to use the full hashable destination instead of just tableId, preventing incorrect grouping of tables with the same name in different datasets. * Fix TriggerLoadJobs to use bq_wrapper with mock client in tests, resolving credential refresh warnings. * Fix PartitionFiles to avoid yielding empty partitions when a file exceeds limits. TAG=agy CONV=126370d2-f42e-4132-a237-16bd5ccf72a3
1 parent 8a23f3b commit 898dca4

4 files changed

Lines changed: 216 additions & 118 deletions

File tree

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

Lines changed: 87 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ class TriggerCopyJobs(beam.DoFn):
491491
"""
492492

493493
TRIGGER_DELETE_TEMP_TABLES = 'TriggerDeleteTempTables'
494+
MAX_SOURCES_PER_COPY_JOB = 1200
494495

495496
def __init__(
496497
self,
@@ -528,97 +529,98 @@ def process(
528529
self, element_list, job_name_prefix=None, unused_schema_mod_jobs=None):
529530
if isinstance(element_list, tuple):
530531
# Allow this for streaming update compatibility while fixing BEAM-24535.
531-
self.process_one(element_list, job_name_prefix)
532-
else:
533-
for element in element_list:
534-
self.process_one(element, job_name_prefix)
532+
element_list = [element_list]
535533

536-
def process_one(self, element, job_name_prefix):
537-
destination, job_reference = element
534+
if not element_list:
535+
return
538536

539-
copy_to_reference = bigquery_tools.parse_table_reference(destination)
537+
first_destination = element_list[0][0]
538+
copy_to_reference = bigquery_tools.parse_table_reference(first_destination)
540539
if copy_to_reference.projectId is None:
541540
copy_to_reference.projectId = vp.RuntimeValueProvider.get_value(
542541
'project', str, '') or self.project
543542

544-
copy_from_reference = bigquery_tools.parse_table_reference(destination)
545-
copy_from_reference.tableId = job_reference.jobId
546-
if copy_from_reference.projectId is None:
547-
copy_from_reference.projectId = vp.RuntimeValueProvider.get_value(
548-
'project', str, '') or self.project
549-
550-
_LOGGER.info(
551-
"Triggering copy job from %s to %s",
552-
copy_from_reference,
553-
copy_to_reference)
543+
copy_from_references = []
544+
for destination, job_reference in element_list:
545+
copy_from_reference = bigquery_tools.parse_table_reference(destination)
546+
copy_from_reference.tableId = job_reference.jobId
547+
if copy_from_reference.projectId is None:
548+
copy_from_reference.projectId = vp.RuntimeValueProvider.get_value(
549+
'project', str, '') or self.project
550+
copy_from_references.append(copy_from_reference)
554551

555-
wait_for_job, write_disposition = (
556-
self._determine_write_disposition(copy_to_reference))
557-
558-
if not self.bq_io_metadata:
559-
self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
552+
full_table_ref = '%s:%s.%s' % (
553+
copy_to_reference.projectId,
554+
copy_to_reference.datasetId,
555+
copy_to_reference.tableId)
560556

561-
project_id = (
562-
copy_to_reference.projectId
563-
if self.load_job_project_id is None else self.load_job_project_id)
564-
copy_job_name = '%s_%s' % (
557+
is_first_time = full_table_ref not in self._observed_tables
558+
if is_first_time:
559+
self._observed_tables.add(full_table_ref)
560+
if self.bq_io_metadata:
561+
Lineage.sinks().add(
562+
'bigquery',
563+
copy_to_reference.projectId,
564+
copy_to_reference.datasetId,
565+
copy_to_reference.tableId)
566+
567+
# Split into chunks of MAX_SOURCES_PER_COPY_JOB
568+
chunks = [
569+
copy_from_references[i:i + self.MAX_SOURCES_PER_COPY_JOB]
570+
for i in range(
571+
0, len(copy_from_references), self.MAX_SOURCES_PER_COPY_JOB)
572+
]
573+
574+
copy_job_name_base = '%s_%s' % (
565575
job_name_prefix,
566576
_bq_uuid(
567577
'%s:%s.%s' % (
568-
copy_from_reference.projectId,
569-
copy_from_reference.datasetId,
570-
copy_from_reference.tableId)))
571-
job_reference = self.bq_wrapper._insert_copy_job(
572-
project_id,
573-
copy_job_name,
574-
copy_from_reference,
575-
copy_to_reference,
576-
create_disposition=self.create_disposition,
577-
write_disposition=write_disposition,
578-
job_labels=self.bq_io_metadata.add_additional_bq_job_labels())
579-
580-
if wait_for_job:
581-
self.bq_wrapper.wait_for_bq_job(job_reference, sleep_duration_sec=10)
582-
self.pending_jobs.append(
583-
GlobalWindows.windowed_value((destination, job_reference)))
578+
copy_to_reference.projectId,
579+
copy_to_reference.datasetId,
580+
copy_to_reference.tableId)))
584581

585-
def _determine_write_disposition(self, copy_to_reference) -> tuple[bool, str]:
586-
"""
587-
Determines the write disposition for a BigQuery copy job,
588-
based on destination.
589-
590-
When the write_disposition for a job is WRITE_TRUNCATE, multiple copy jobs
591-
to the same destination can interfere with each other, truncate data, and
592-
write to the BigQuery table repeatedly. To prevent this, the first copy job
593-
runs with the user's specified write_disposition, but subsequent jobs must
594-
always use WRITE_APPEND. This ensures that subsequent copy jobs do not
595-
clear out data appended by previous jobs.
596-
597-
Args:
598-
copy_to_reference: The reference to the destination table.
582+
project_id = (
583+
copy_to_reference.projectId
584+
if self.load_job_project_id is None else self.load_job_project_id)
599585

600-
Returns:
601-
A tuple containing a boolean indicating whether to wait for the job to
602-
complete and the write disposition to use for the job.
603-
"""
604-
full_table_ref = '%s:%s.%s' % (
605-
copy_to_reference.projectId,
606-
copy_to_reference.datasetId,
607-
copy_to_reference.tableId)
608-
if full_table_ref not in self._observed_tables:
609-
write_disposition = self.write_disposition
610-
wait_for_job = (
611-
self.write_disposition != BigQueryDisposition.WRITE_APPEND)
612-
self._observed_tables.add(full_table_ref)
613-
Lineage.sinks().add(
614-
'bigquery',
615-
copy_to_reference.projectId,
616-
copy_to_reference.datasetId,
617-
copy_to_reference.tableId)
618-
else:
619-
wait_for_job = False
620-
write_disposition = 'WRITE_APPEND'
621-
return wait_for_job, write_disposition
586+
for i, chunk in enumerate(chunks):
587+
if i == 0 and is_first_time:
588+
write_disposition = self.write_disposition
589+
# Wait inline only if we have multiple chunks and write disposition is WRITE_TRUNCATE or WRITE_EMPTY.
590+
# This ensures the first chunk initializes the table, and subsequent chunks (WRITE_APPEND) append to it.
591+
wait_for_job = (
592+
self.write_disposition in ('WRITE_TRUNCATE', 'WRITE_EMPTY') and
593+
len(chunks) > 1)
594+
else:
595+
write_disposition = 'WRITE_APPEND'
596+
wait_for_job = False
597+
598+
chunk_job_name = copy_job_name_base
599+
if len(chunks) > 1:
600+
chunk_job_name = f"{copy_job_name_base}_{i}"
601+
602+
_LOGGER.info(
603+
"Triggering copy job %s from %s to %s (write_disposition: %s)",
604+
chunk_job_name,
605+
[str(r) for r in chunk],
606+
copy_to_reference,
607+
write_disposition)
608+
609+
job_reference = self.bq_wrapper._insert_copy_job(
610+
project_id,
611+
chunk_job_name,
612+
chunk,
613+
copy_to_reference,
614+
create_disposition=self.create_disposition,
615+
write_disposition=write_disposition,
616+
job_labels=self.bq_io_metadata.add_additional_bq_job_labels()
617+
if self.bq_io_metadata else None)
618+
619+
if wait_for_job:
620+
self.bq_wrapper.wait_for_bq_job(job_reference, sleep_duration_sec=10)
621+
622+
self.pending_jobs.append(
623+
GlobalWindows.windowed_value((first_destination, job_reference)))
622624

623625
def finish_bundle(self):
624626
for windowed_value in self.pending_jobs:
@@ -745,7 +747,7 @@ def process(
745747
else:
746748
try:
747749
schema = bigquery_tools.table_schema_to_dict(
748-
bigquery_tools.BigQueryWrapper().get_table(
750+
self.bq_wrapper.get_table(
749751
project_id=table_reference.projectId,
750752
dataset_id=table_reference.datasetId,
751753
table_id=table_reference.tableId).schema)
@@ -856,7 +858,8 @@ def process(self, element):
856858
if latest_partition.can_accept(file_size):
857859
latest_partition.add(file_path, file_size)
858860
else:
859-
partitions.append(latest_partition.files)
861+
if latest_partition.files:
862+
partitions.append(latest_partition.files)
860863
latest_partition = PartitionFiles.Partition(
861864
self.max_partition_size, self.max_files_per_partition)
862865
latest_partition.add(file_path, file_size)
@@ -1182,12 +1185,13 @@ def _load_data(
11821185
# the truncation happens only once. See
11831186
# https://github.com/apache/beam/issues/24535.
11841187
finished_temp_tables_load_job_ids_list_pc = (
1185-
finished_temp_tables_load_job_ids_pc | beam.MapTuple(
1188+
finished_temp_tables_load_job_ids_pc
1189+
| beam.MapTuple(
11861190
lambda destination, job_reference: (
1187-
bigquery_tools.parse_table_reference(destination).tableId,
1191+
bigquery_tools.get_hashable_destination(destination),
11881192
(destination, job_reference)))
11891193
| beam.GroupByKey()
1190-
| beam.MapTuple(lambda tableId, batch: list(batch)))
1194+
| beam.MapTuple(lambda dest, batch: list(batch)))
11911195
else:
11921196
# Loads can happen in parallel.
11931197
finished_temp_tables_load_job_ids_list_pc = (

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

Lines changed: 117 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -929,10 +929,16 @@ def dynamic_destination_resolver(element, *side_inputs):
929929
call(
930930
'project1',
931931
mock.ANY,
932-
TableReference(
933-
datasetId='dataset1',
934-
projectId='project1',
935-
tableId='job_name1'),
932+
[
933+
TableReference(
934+
datasetId='dataset1',
935+
projectId='project1',
936+
tableId='job_name1'),
937+
TableReference(
938+
datasetId='dataset1',
939+
projectId='project1',
940+
tableId='job_name1'),
941+
],
936942
TableReference(
937943
datasetId='dataset1',
938944
projectId='project1',
@@ -943,50 +949,132 @@ def dynamic_destination_resolver(element, *side_inputs):
943949
call(
944950
'project1',
945951
mock.ANY,
946-
TableReference(
947-
datasetId='dataset1',
948-
projectId='project1',
949-
tableId='job_name1'),
950-
TableReference(
951-
datasetId='dataset1',
952-
projectId='project1',
953-
tableId='table1'),
954-
create_disposition=None,
955-
write_disposition='WRITE_APPEND',
956-
job_labels={'step_name': 'bigquerybatchfileloads'}),
957-
call(
958-
'project1',
959-
mock.ANY,
960-
TableReference(
961-
datasetId='dataset2',
962-
projectId='project1',
963-
tableId='job_name1'),
952+
[
953+
TableReference(
954+
datasetId='dataset2',
955+
projectId='project1',
956+
tableId='job_name1'),
957+
],
964958
TableReference(
965959
datasetId='dataset2',
966960
projectId='project1',
967961
tableId='table1'),
968962
create_disposition=None,
969-
# Previously this was `WRITE_APPEND`.
970963
write_disposition='WRITE_TRUNCATE',
971964
job_labels={'step_name': 'bigquerybatchfileloads'}),
972965
call(
973966
'project1',
974967
mock.ANY,
975-
TableReference(
976-
datasetId='dataset3',
977-
projectId='project1',
978-
tableId='job_name1'),
968+
[
969+
TableReference(
970+
datasetId='dataset3',
971+
projectId='project1',
972+
tableId='job_name1'),
973+
],
979974
TableReference(
980975
datasetId='dataset3',
981976
projectId='project1',
982977
tableId='table1'),
983978
create_disposition=None,
984-
# Previously this was `WRITE_APPEND`.
985979
write_disposition='WRITE_TRUNCATE',
986980
job_labels={'step_name': 'bigquerybatchfileloads'}),
987981
],
988982
any_order=True)
989-
self.assertEqual(4, mock_insert_copy_job.call_count)
983+
self.assertEqual(3, mock_insert_copy_job.call_count)
984+
985+
@mock.patch(
986+
'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper.wait_for_bq_job')
987+
@mock.patch(
988+
'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._insert_copy_job')
989+
def test_copy_jobs_splitting(self, mock_insert_copy_job, mock_wait_for_bq_job):
990+
destination = 'project1:dataset1.table1'
991+
992+
from apache_beam.io.gcp.bigquery_file_loads import TriggerCopyJobs
993+
original_max_sources = TriggerCopyJobs.MAX_SOURCES_PER_COPY_JOB
994+
TriggerCopyJobs.MAX_SOURCES_PER_COPY_JOB = 2
995+
996+
try:
997+
job_reference = bigquery_api.JobReference()
998+
job_reference.projectId = 'project1'
999+
job_reference.jobId = 'job_name1'
1000+
result_job = mock.Mock()
1001+
result_job.jobReference = job_reference
1002+
1003+
mock_job = mock.Mock()
1004+
mock_job.status.state = 'DONE'
1005+
mock_job.status.errorResult = None
1006+
mock_job.jobReference = job_reference
1007+
1008+
bq_client = mock.Mock()
1009+
bq_client.jobs.Get.return_value = mock_job
1010+
bq_client.jobs.Insert.return_value = result_job
1011+
bq_client.tables.Delete.return_value = None
1012+
mock_insert_copy_job.return_value = job_reference
1013+
temp_dir = self._new_tempdir()
1014+
1015+
with TestPipeline('FnApiRunner') as p:
1016+
_ = (
1017+
p
1018+
| beam.Create([
1019+
{'name': 'a'},
1020+
{'name': 'b'},
1021+
{'name': 'c'},
1022+
{'name': 'd'},
1023+
{'name': 'e'},
1024+
], reshuffle=False)
1025+
| bqfl.BigQueryBatchFileLoads(
1026+
destination,
1027+
custom_gcs_temp_location=temp_dir,
1028+
test_client=bq_client,
1029+
validate=False,
1030+
temp_file_format=bigquery_tools.FileFormat.JSON,
1031+
max_file_size=10,
1032+
max_partition_size=10,
1033+
max_files_per_partition=1,
1034+
write_disposition=BigQueryDisposition.WRITE_TRUNCATE))
1035+
1036+
self.assertEqual(3, mock_insert_copy_job.call_count)
1037+
1038+
from apache_beam.io.gcp.internal.clients.bigquery import TableReference
1039+
expected_calls = [
1040+
call(
1041+
'project1',
1042+
mock.ANY,
1043+
[
1044+
TableReference(datasetId='dataset1', projectId='project1', tableId='job_name1'),
1045+
TableReference(datasetId='dataset1', projectId='project1', tableId='job_name1'),
1046+
],
1047+
TableReference(datasetId='dataset1', projectId='project1', tableId='table1'),
1048+
create_disposition=None,
1049+
write_disposition='WRITE_TRUNCATE',
1050+
job_labels=mock.ANY),
1051+
call(
1052+
'project1',
1053+
mock.ANY,
1054+
[
1055+
TableReference(datasetId='dataset1', projectId='project1', tableId='job_name1'),
1056+
TableReference(datasetId='dataset1', projectId='project1', tableId='job_name1'),
1057+
],
1058+
TableReference(datasetId='dataset1', projectId='project1', tableId='table1'),
1059+
create_disposition=None,
1060+
write_disposition='WRITE_APPEND',
1061+
job_labels=mock.ANY),
1062+
call(
1063+
'project1',
1064+
mock.ANY,
1065+
[
1066+
TableReference(datasetId='dataset1', projectId='project1', tableId='job_name1'),
1067+
],
1068+
TableReference(datasetId='dataset1', projectId='project1', tableId='table1'),
1069+
create_disposition=None,
1070+
write_disposition='WRITE_APPEND',
1071+
job_labels=mock.ANY),
1072+
]
1073+
mock_insert_copy_job.assert_has_calls(expected_calls, any_order=True)
1074+
self.assertEqual(9, mock_wait_for_bq_job.call_count)
1075+
1076+
finally:
1077+
TriggerCopyJobs.MAX_SOURCES_PER_COPY_JOB = original_max_sources
9901078

9911079
@parameterized.expand([
9921080
param(is_streaming=False, with_auto_sharding=False, compat_version=None),

0 commit comments

Comments
 (0)