Skip to content

Commit 8e4ea73

Browse files
authored
[Python] Optimize BigQuery copy jobs in file loads using multi-source copy (#38983)
* [GCP] Optimize BigQuery TriggerCopyJobs performance for WRITE_APPEND * 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 0802263 commit 8e4ea73

3 files changed

Lines changed: 250 additions & 135 deletions

File tree

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

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

493493
TRIGGER_DELETE_TEMP_TABLES = 'TriggerDeleteTempTables'
494+
# https://docs.cloud.google.com/bigquery/quotas#copy_jobs
495+
MAX_SOURCES_PER_COPY_JOB = 1200
494496

495497
def __init__(
496498
self,
@@ -528,96 +530,90 @@ def process(
528530
self, element_list, job_name_prefix=None, unused_schema_mod_jobs=None):
529531
if isinstance(element_list, tuple):
530532
# 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)
533+
element_list = [element_list]
535534

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

539-
copy_to_reference = bigquery_tools.parse_table_reference(destination)
538+
first_destination = element_list[0][0]
539+
copy_to_reference = bigquery_tools.parse_table_reference(first_destination)
540540
if copy_to_reference.projectId is None:
541541
copy_to_reference.projectId = vp.RuntimeValueProvider.get_value(
542542
'project', str, '') or self.project
543543

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)
544+
copy_from_references = []
545+
for destination, job_reference in element_list:
546+
copy_from_reference = bigquery_tools.parse_table_reference(destination)
547+
copy_from_reference.tableId = job_reference.jobId
548+
if copy_from_reference.projectId is None:
549+
copy_from_reference.projectId = vp.RuntimeValueProvider.get_value(
550+
'project', str, '') or self.project
551+
copy_from_references.append(copy_from_reference)
554552

555-
wait_for_job, write_disposition = (
556-
self._determine_write_disposition(copy_to_reference))
553+
full_table_ref = bigquery_tools.get_hashable_destination(copy_to_reference)
557554

558-
if not self.bq_io_metadata:
559-
self.bq_io_metadata = create_bigquery_io_metadata(self._step_name)
555+
is_first_time = full_table_ref not in self._observed_tables
556+
if is_first_time:
557+
self._observed_tables.add(full_table_ref)
558+
if self.bq_io_metadata:
559+
Lineage.sinks().add(
560+
'bigquery',
561+
copy_to_reference.projectId,
562+
copy_to_reference.datasetId,
563+
copy_to_reference.tableId)
564+
565+
# Split into chunks of MAX_SOURCES_PER_COPY_JOB
566+
chunks = [
567+
copy_from_references[i:i + self.MAX_SOURCES_PER_COPY_JOB]
568+
for i in range(
569+
0, len(copy_from_references), self.MAX_SOURCES_PER_COPY_JOB)
570+
]
571+
572+
copy_job_name_base = '%s_%s' % (
573+
job_name_prefix,
574+
_bq_uuid(bigquery_tools.get_hashable_destination(copy_to_reference)))
560575

561576
project_id = (
562577
copy_to_reference.projectId
563578
if self.load_job_project_id is None else self.load_job_project_id)
564-
copy_job_name = '%s_%s' % (
565-
job_name_prefix,
566-
_bq_uuid(
567-
'%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)))
584579

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

622618
def finish_bundle(self):
623619
for windowed_value in self.pending_jobs:
@@ -744,7 +740,7 @@ def process(
744740
else:
745741
try:
746742
schema = bigquery_tools.table_schema_to_dict(
747-
bigquery_tools.BigQueryWrapper().get_table(
743+
self.bq_wrapper.get_table(
748744
project_id=table_reference.projectId,
749745
dataset_id=table_reference.datasetId,
750746
table_id=table_reference.tableId).schema)
@@ -855,7 +851,8 @@ def process(self, element):
855851
if latest_partition.can_accept(file_size):
856852
latest_partition.add(file_path, file_size)
857853
else:
858-
partitions.append(latest_partition.files)
854+
if latest_partition.files:
855+
partitions.append(latest_partition.files)
859856
latest_partition = PartitionFiles.Partition(
860857
self.max_partition_size, self.max_files_per_partition)
861858
latest_partition.add(file_path, file_size)
@@ -1181,12 +1178,13 @@ def _load_data(
11811178
# the truncation happens only once. See
11821179
# https://github.com/apache/beam/issues/24535.
11831180
finished_temp_tables_load_job_ids_list_pc = (
1184-
finished_temp_tables_load_job_ids_pc | beam.MapTuple(
1181+
finished_temp_tables_load_job_ids_pc
1182+
| beam.MapTuple(
11851183
lambda destination, job_reference: (
1186-
bigquery_tools.parse_table_reference(destination).tableId,
1184+
bigquery_tools.get_hashable_destination(destination),
11871185
(destination, job_reference)))
11881186
| beam.GroupByKey()
1189-
| beam.MapTuple(lambda tableId, batch: list(batch)))
1187+
| beam.MapTuple(lambda dest, batch: list(batch)))
11901188
else:
11911189
# Loads can happen in parallel.
11921190
finished_temp_tables_load_job_ids_list_pc = (

0 commit comments

Comments
 (0)