Skip to content

Commit b74c496

Browse files
estimate request size in BQ streaming inserts when with_auto_sharding is true (#35212)
1 parent b09e5ef commit b74c496

2 files changed

Lines changed: 202 additions & 12 deletions

File tree

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

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1590,6 +1590,21 @@ def _create_table_if_needed(self, table_reference, schema=None):
15901590
additional_create_parameters=self.additional_bq_parameters)
15911591
_KNOWN_TABLES.add(str_table_reference)
15921592

1593+
def _check_row_size(self, row_and_insert_id) -> Tuple[int, Optional[str]]:
1594+
"""Returns error string when the row estimated size is too big"""
1595+
row_byte_size = get_deep_size(row_and_insert_id)
1596+
1597+
# Check if individual row exceeds size limit
1598+
if row_byte_size >= self._max_insert_payload_size:
1599+
row_mb_size = row_byte_size / 1_000_000
1600+
max_mb_size = self._max_insert_payload_size / 1_000_000
1601+
return (
1602+
row_byte_size,
1603+
(
1604+
f"Received row with size {row_mb_size}MB that exceeds "
1605+
f"the maximum insert payload size set ({max_mb_size}MB)."))
1606+
return (row_byte_size, None)
1607+
15931608
def process(
15941609
self, element, window_value=DoFn.WindowedValueParam, *schema_side_inputs):
15951610
destination = bigquery_tools.get_hashable_destination(element[0])
@@ -1606,20 +1621,15 @@ def process(
16061621

16071622
if not self.with_batched_input:
16081623
row_and_insert_id = element[1]
1609-
row_byte_size = get_deep_size(row_and_insert_id)
1624+
row_byte_size, row_too_big_error = self._check_row_size(row_and_insert_id)
16101625

16111626
# send large rows that exceed BigQuery insert limits to DLQ
1612-
if row_byte_size >= self._max_insert_payload_size:
1613-
row_mb_size = row_byte_size / 1_000_000
1614-
max_mb_size = self._max_insert_payload_size / 1_000_000
1615-
error = (
1616-
f"Received row with size {row_mb_size}MB that exceeds "
1617-
f"the maximum insert payload size set ({max_mb_size}MB).")
1627+
if row_too_big_error is not None:
16181628
return [
16191629
pvalue.TaggedOutput(
16201630
BigQueryWriteFn.FAILED_ROWS_WITH_ERRORS,
16211631
window_value.with_value(
1622-
(destination, row_and_insert_id[0], error))),
1632+
(destination, row_and_insert_id[0], row_too_big_error))),
16231633
pvalue.TaggedOutput(
16241634
BigQueryWriteFn.FAILED_ROWS,
16251635
window_value.with_value((destination, row_and_insert_id[0])))
@@ -1643,11 +1653,50 @@ def process(
16431653
if self._total_buffered_rows >= self._max_buffered_rows:
16441654
return self._flush_all_batches()
16451655
else:
1646-
# The input is already batched per destination, flush the rows now.
1656+
# The input is already batched per destination
1657+
# but we verify the payload size.
1658+
# The batch might be split into smaller batches
16471659
batched_rows = element[1]
1648-
for r in batched_rows:
1649-
self._rows_buffer[destination].append((r, window_value))
1650-
return self._flush_batch(destination)
1660+
failed_outputs = []
1661+
current_batch = []
1662+
current_batch_size = 0
1663+
1664+
for row in batched_rows:
1665+
row_byte_size, row_too_big_error = self._check_row_size(row)
1666+
# Check if individual row exceeds size limit
1667+
if row_too_big_error is not None:
1668+
failed_outputs.extend([
1669+
pvalue.TaggedOutput(
1670+
BigQueryWriteFn.FAILED_ROWS_WITH_ERRORS,
1671+
window_value.with_value(
1672+
(destination, row[0], row_too_big_error))),
1673+
pvalue.TaggedOutput(
1674+
BigQueryWriteFn.FAILED_ROWS,
1675+
window_value.with_value((destination, row[0])))
1676+
])
1677+
continue
1678+
1679+
# Check if adding this row would exceed batch size limit
1680+
if (len(current_batch) != 0 and
1681+
current_batch_size + row_byte_size > self._max_insert_payload_size):
1682+
1683+
self._rows_buffer[destination].extend(
1684+
((batch_row, window_value) for batch_row in current_batch))
1685+
failed_outputs.extend(self._flush_batch(destination))
1686+
1687+
# Start new batch with current row
1688+
current_batch = [row]
1689+
current_batch_size = row_byte_size
1690+
else:
1691+
current_batch.append(row)
1692+
current_batch_size += row_byte_size
1693+
1694+
if current_batch:
1695+
for batch_row in current_batch:
1696+
self._rows_buffer[destination].append((batch_row, window_value))
1697+
failed_outputs.extend(self._flush_batch(destination))
1698+
1699+
return failed_outputs
16511700

16521701
def finish_bundle(self):
16531702
bigquery_tools.BigQueryWrapper.HISTOGRAM_METRIC_LOGGER.log_metrics(

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,6 +1944,147 @@ def store_callback(table, **kwargs):
19441944
assert_that(failed_values, equal_to(failed_rows))
19451945

19461946

1947+
def test_with_batched_input_exceeds_size_limit(self):
1948+
from apache_beam.utils.windowed_value import WindowedValue
1949+
from apache_beam.transforms import window
1950+
1951+
client = mock.Mock()
1952+
client.tables.Get.return_value = bigquery.Table(
1953+
tableReference=bigquery.TableReference(
1954+
projectId='project-id', datasetId='dataset_id', tableId='table_id'))
1955+
client.insert_rows_json.return_value = []
1956+
fn = beam.io.gcp.bigquery.BigQueryWriteFn(
1957+
batch_size=10,
1958+
create_disposition=beam.io.BigQueryDisposition.CREATE_IF_NEEDED,
1959+
write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND,
1960+
kms_key=None,
1961+
with_batched_input=True,
1962+
max_insert_payload_size=500, # Small size to trigger the check
1963+
test_client=client)
1964+
1965+
fn.start_bundle()
1966+
1967+
# Create rows where the third one exceeds the size limit
1968+
rows = [
1969+
({
1970+
'month': 1
1971+
}, 'insertid1'),
1972+
({
1973+
'month': 2
1974+
}, 'insertid2'),
1975+
({
1976+
'columnA': 'large_string' * 100
1977+
}, 'insertid3') # This exceeds 500 bytes
1978+
]
1979+
1980+
# Create a windowed value for testing
1981+
test_window = window.GlobalWindow()
1982+
test_timestamp = window.MIN_TIMESTAMP
1983+
windowed_value = WindowedValue(None, test_timestamp, [test_window])
1984+
1985+
# Process the batched input
1986+
result = fn.process(('project-id:dataset_id.table_id', rows), windowed_value)
1987+
1988+
# Convert generator to list to check results
1989+
failed_rows = list(result)
1990+
1991+
# Should have 2 failed outputs (FAILED_ROWS_WITH_ERRORS and FAILED_ROWS)
1992+
self.assertEqual(len(failed_rows), 2)
1993+
1994+
# Check that the large row was sent to DLQ
1995+
failed_with_errors = [
1996+
r for r in failed_rows if r.tag == fn.FAILED_ROWS_WITH_ERRORS
1997+
]
1998+
failed_without_errors = [r for r in failed_rows if r.tag == fn.FAILED_ROWS]
1999+
2000+
self.assertEqual(len(failed_with_errors), 1)
2001+
self.assertEqual(len(failed_without_errors), 1)
2002+
2003+
# Verify the error message
2004+
destination, row, error = failed_with_errors[0].value.value
2005+
self.assertEqual(destination, 'project-id:dataset_id.table_id')
2006+
self.assertEqual(row, {'columnA': 'large_string' * 100})
2007+
self.assertIn('exceeds the maximum insert payload size', error)
2008+
2009+
# Verify that only the valid rows were sent to BigQuery
2010+
self.assertTrue(client.insert_rows_json.called)
2011+
# Check that the rows were inserted (might be in multiple batches)
2012+
total_rows = []
2013+
for call in client.insert_rows_json.call_args_list:
2014+
total_rows.extend(call[1]['json_rows'])
2015+
2016+
self.assertEqual(len(total_rows), 2)
2017+
self.assertEqual(total_rows[0], {'month': 1})
2018+
self.assertEqual(total_rows[1], {'month': 2})
2019+
2020+
2021+
def test_with_batched_input_splits_large_batch(self):
2022+
from apache_beam.utils.windowed_value import WindowedValue
2023+
from apache_beam.transforms import window
2024+
2025+
client = mock.Mock()
2026+
client.tables.Get.return_value = bigquery.Table(
2027+
tableReference=bigquery.TableReference(
2028+
projectId='project-id', datasetId='dataset_id', tableId='table_id'))
2029+
client.insert_rows_json.return_value = []
2030+
create_disposition = beam.io.BigQueryDisposition.CREATE_IF_NEEDED
2031+
write_disposition = beam.io.BigQueryDisposition.WRITE_APPEND
2032+
2033+
fn = beam.io.gcp.bigquery.BigQueryWriteFn(
2034+
batch_size=10,
2035+
create_disposition=create_disposition,
2036+
write_disposition=write_disposition,
2037+
kms_key=None,
2038+
with_batched_input=True,
2039+
max_insert_payload_size=800, # Small size to force batch splitting
2040+
test_client=client)
2041+
2042+
fn.start_bundle()
2043+
2044+
# Create rows that together exceed the batch size limit.
2045+
# Each row with 'data' * 10 is about 200 bytes
2046+
# So 2 rows should fit, 3rd should cause flush.
2047+
rows = [
2048+
({
2049+
'data': 'x' * 10
2050+
}, 'insertid1'),
2051+
({
2052+
'data': 'y' * 10
2053+
}, 'insertid2'),
2054+
({
2055+
'data': 'z' * 10
2056+
}, 'insertid3'), # This should go in second batch
2057+
]
2058+
2059+
# Create a windowed value for testing
2060+
test_window = window.GlobalWindow()
2061+
test_timestamp = window.MIN_TIMESTAMP
2062+
windowed_value = WindowedValue(None, test_timestamp, [test_window])
2063+
2064+
# Process the batched input
2065+
result = fn.process(('project-id:dataset_id.table_id', rows), windowed_value)
2066+
2067+
# Convert generator to list (should be empty as no failures)
2068+
failed_rows = list(result)
2069+
self.assertEqual(len(failed_rows), 0)
2070+
2071+
# With 800 byte limit and ~341 byte rows
2072+
# we should be able to fit 2 rows per batch.
2073+
# So we expect 2 calls: [row1, row2] and [row3]
2074+
self.assertEqual(client.insert_rows_json.call_count, 2)
2075+
2076+
# Check first batch (2 rows)
2077+
first_call = client.insert_rows_json.call_args_list[0][1]
2078+
self.assertEqual(len(first_call['json_rows']), 2)
2079+
self.assertEqual(first_call['json_rows'][0], {'data': 'x' * 10})
2080+
self.assertEqual(first_call['json_rows'][1], {'data': 'y' * 10})
2081+
2082+
# Check second batch (1 row)
2083+
second_call = client.insert_rows_json.call_args_list[1][1]
2084+
self.assertEqual(len(second_call['json_rows']), 1)
2085+
self.assertEqual(second_call['json_rows'][0], {'data': 'z' * 10})
2086+
2087+
19472088
@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed')
19482089
class BigQueryStreamingInsertTransformTests(unittest.TestCase):
19492090
def test_dofn_client_process_performs_batching(self):

0 commit comments

Comments
 (0)