Skip to content

Commit af3b026

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 af3b026

3 files changed

Lines changed: 256 additions & 136 deletions

File tree

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

Lines changed: 86 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,97 @@ 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, [str(r) for r in chunk],
605+
copy_to_reference,
606+
write_disposition)
607+
608+
job_reference = self.bq_wrapper._insert_copy_job(
609+
project_id,
610+
chunk_job_name,
611+
chunk,
612+
copy_to_reference,
613+
create_disposition=self.create_disposition,
614+
write_disposition=write_disposition,
615+
job_labels=self.bq_io_metadata.add_additional_bq_job_labels()
616+
if self.bq_io_metadata else None)
617+
618+
if wait_for_job:
619+
self.bq_wrapper.wait_for_bq_job(job_reference, sleep_duration_sec=10)
620+
621+
self.pending_jobs.append(
622+
GlobalWindows.windowed_value((first_destination, job_reference)))
622623

623624
def finish_bundle(self):
624625
for windowed_value in self.pending_jobs:
@@ -745,7 +746,7 @@ def process(
745746
else:
746747
try:
747748
schema = bigquery_tools.table_schema_to_dict(
748-
bigquery_tools.BigQueryWrapper().get_table(
749+
self.bq_wrapper.get_table(
749750
project_id=table_reference.projectId,
750751
dataset_id=table_reference.datasetId,
751752
table_id=table_reference.tableId).schema)
@@ -856,7 +857,8 @@ def process(self, element):
856857
if latest_partition.can_accept(file_size):
857858
latest_partition.add(file_path, file_size)
858859
else:
859-
partitions.append(latest_partition.files)
860+
if latest_partition.files:
861+
partitions.append(latest_partition.files)
860862
latest_partition = PartitionFiles.Partition(
861863
self.max_partition_size, self.max_files_per_partition)
862864
latest_partition.add(file_path, file_size)
@@ -1182,12 +1184,13 @@ def _load_data(
11821184
# the truncation happens only once. See
11831185
# https://github.com/apache/beam/issues/24535.
11841186
finished_temp_tables_load_job_ids_list_pc = (
1185-
finished_temp_tables_load_job_ids_pc | beam.MapTuple(
1187+
finished_temp_tables_load_job_ids_pc
1188+
| beam.MapTuple(
11861189
lambda destination, job_reference: (
1187-
bigquery_tools.parse_table_reference(destination).tableId,
1190+
bigquery_tools.get_hashable_destination(destination),
11881191
(destination, job_reference)))
11891192
| beam.GroupByKey()
1190-
| beam.MapTuple(lambda tableId, batch: list(batch)))
1193+
| beam.MapTuple(lambda dest, batch: list(batch)))
11911194
else:
11921195
# Loads can happen in parallel.
11931196
finished_temp_tables_load_job_ids_list_pc = (

0 commit comments

Comments
 (0)