Repository navigation
fix(bigquery): apply max_results to the whole Arrow result #18651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2408,11 +2408,17 @@ def to_arrow( | |
| if pyarrow is None: | ||
| raise ValueError(_NO_PYARROW_ERROR) | ||
|
|
||
| self._maybe_warn_max_results(bqstorage_client) | ||
|
|
||
| if not self._should_use_bqstorage(bqstorage_client, create_bqstorage_client): | ||
| if self._query_results_format == QueryResultsFormat.ARROW.value: | ||
| # ARROW results apply max_results themselves and read with the | ||
| # bqstorage_client they're given, so keep it. | ||
| create_bqstorage_client = False | ||
| bqstorage_client = None | ||
| else: | ||
| self._maybe_warn_max_results(bqstorage_client) | ||
| if not self._should_use_bqstorage( | ||
| bqstorage_client, create_bqstorage_client | ||
| ): | ||
| create_bqstorage_client = False | ||
| bqstorage_client = None | ||
|
Comment on lines
+2411
to
+2421
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting if self._query_results_format != QueryResultsFormat.ARROW.value:
self._maybe_warn_max_results(bqstorage_client)
if not self._should_use_bqstorage(
bqstorage_client, create_bqstorage_client
):
create_bqstorage_client = False
bqstorage_client = None
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't fail: when to_arrow() passes bqstorage_client=None for ARROW results, the download creates the client itself before it opens a stream (test_download_arrow_from_job_id_constructs_stream_and_reads covers that path). Creating one in to_arrow() would also make it create and close a client even when every row is in the first page. The next PR (#18652) replaces that with one shared client per Client. |
||
|
|
||
| owns_bqstorage_client = False | ||
| if not bqstorage_client and create_bqstorage_client: | ||
|
|
@@ -2911,7 +2917,9 @@ def to_dataframe( | |
| if dtypes is None: | ||
| dtypes = {} | ||
|
|
||
| self._maybe_warn_max_results(bqstorage_client) | ||
| # ARROW results apply max_results with the BigQuery Storage API too. | ||
| if self._query_results_format != QueryResultsFormat.ARROW.value: | ||
| self._maybe_warn_max_results(bqstorage_client) | ||
|
|
||
| if ( | ||
| self._query_results_format != QueryResultsFormat.ARROW.value | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,9 @@ | |
| import base64 | ||
| import unittest | ||
| from unittest import mock | ||
| import warnings | ||
|
|
||
| import google.api_core.exceptions as core_exceptions | ||
| import packaging.version | ||
| import pytest | ||
|
|
||
|
|
@@ -1026,5 +1028,175 @@ def test_query_and_wait_without_arrow_waits_with_page_size(): | |
| assert wait_or_cancel.call_args.kwargs["page_size"] == 10 | ||
|
|
||
|
|
||
| def _arrow_pages(*row_counts): | ||
| """Serialize an Arrow schema and record batches of consecutive x values.""" | ||
| pyarrow = pytest.importorskip("pyarrow") | ||
| schema = pyarrow.schema([("x", pyarrow.int64())]) | ||
| batches, start = [], 1 | ||
| for count in row_counts: | ||
| values = pyarrow.array(range(start, start + count), type=pyarrow.int64()) | ||
| batch = pyarrow.record_batch([values], schema=schema) | ||
| batches.append(batch.serialize().to_pybytes()) | ||
| start += count | ||
| return schema.serialize().to_pybytes(), batches | ||
|
|
||
|
|
||
| def _stream_response(batch): | ||
| return mock.Mock( | ||
| arrow_schema=None, | ||
| arrow_record_batch=mock.Mock(serialized_record_batch=batch), | ||
| ) | ||
|
|
||
|
|
||
| def _limited_iterator(row_counts, total_rows, max_results): | ||
| """A RowIterator whose first page has row_counts[0] rows and whose stream | ||
| returns the rest, plus its mock BigQuery Storage client and stream. | ||
| """ | ||
| schema, batches = _arrow_pages(*row_counts) | ||
| reader = mock.MagicMock() | ||
| reader.__iter__.return_value = iter( | ||
| [_stream_response(batch) for batch in batches[1:]] | ||
| ) | ||
| bqstorage_client = mock.MagicMock() | ||
| bqstorage_client.read_rows.return_value = reader | ||
| iterator = RowIterator( | ||
| client=mock.MagicMock(), | ||
| api_request=mock.MagicMock(), | ||
| path=None, | ||
| schema=(), | ||
| max_results=max_results, | ||
| project="p", | ||
| location="US", | ||
| job_id="j", | ||
| query_results_format="ARROW", | ||
| first_page_response={ | ||
| "jobComplete": True, | ||
| "totalRows": str(total_rows), | ||
| "arrowSchema": {"serializedSchema": base64.b64encode(schema).decode()}, | ||
| "arrowRecordBatch": { | ||
| "serializedRecordBatch": base64.b64encode(batches[0]).decode() | ||
| }, | ||
| }, | ||
| ) | ||
| return iterator, bqstorage_client, reader | ||
|
|
||
|
|
||
| def _values(batches): | ||
| return [value for batch in batches for value in batch.column(0).to_pylist()] | ||
|
|
||
|
|
||
| def test_download_arrow_max_results_within_first_page_skips_stream(): | ||
| iterator, bqstorage_client, _ = _limited_iterator([10, 15], 25, max_results=4) | ||
|
|
||
| batches = list(iterator._download_arrow_from_job_id(bqstorage_client)) | ||
|
|
||
| assert _values(batches) == [1, 2, 3, 4] | ||
| bqstorage_client.read_rows.assert_not_called() | ||
|
|
||
|
|
||
| def test_download_arrow_max_results_across_stream_cancels_it(): | ||
| iterator, bqstorage_client, reader = _limited_iterator( | ||
| [10, 10, 5], 25, max_results=15 | ||
| ) | ||
|
|
||
| batches = list(iterator._download_arrow_from_job_id(bqstorage_client)) | ||
|
|
||
| assert _values(batches) == list(range(1, 16)) | ||
| assert bqstorage_client.read_rows.call_args.kwargs["offset"] == 10 | ||
| reader.cancel.assert_called_once_with() | ||
|
|
||
|
|
||
| def test_download_arrow_max_results_past_last_row_reads_everything(): | ||
| iterator, bqstorage_client, reader = _limited_iterator( | ||
| [10, 10, 5], 25, max_results=100 | ||
| ) | ||
|
|
||
| batches = list(iterator._download_arrow_from_job_id(bqstorage_client)) | ||
|
|
||
| assert _values(batches) == list(range(1, 26)) | ||
| reader.cancel.assert_not_called() | ||
|
|
||
|
|
||
| def test_download_arrow_cancels_stream_when_closed_early(): | ||
| iterator, bqstorage_client, reader = _limited_iterator( | ||
| [10, 10, 5], 25, max_results=None | ||
| ) | ||
| batches = iterator._download_arrow_from_job_id(bqstorage_client) | ||
|
|
||
| next(batches) # first page | ||
| next(batches) # first stream batch | ||
| batches.close() | ||
|
|
||
| reader.cancel.assert_called_once_with() | ||
|
|
||
|
|
||
| def test_download_arrow_raises_stream_errors_after_earlier_batches(): | ||
| iterator, bqstorage_client, reader = _limited_iterator( | ||
| [10, 10, 5], 25, max_results=None | ||
| ) | ||
| first_stream_response = next(reader.__iter__.return_value) | ||
|
|
||
| def fail_after_one_response(): | ||
| yield first_stream_response | ||
| raise core_exceptions.PermissionDenied("stream failed") | ||
|
|
||
| reader.__iter__.return_value = fail_after_one_response() | ||
| received = [] | ||
|
|
||
| with pytest.raises(core_exceptions.PermissionDenied): | ||
| for batch in iterator._download_arrow_from_job_id(bqstorage_client): | ||
| received.append(batch) | ||
|
|
||
| # The caller gets the error after the batches that arrived before it, so | ||
| # a partial result never looks complete. | ||
| assert _values(received) == list(range(1, 21)) | ||
| reader.cancel.assert_called_once_with() | ||
|
|
||
|
|
||
| def test_query_and_wait_arrow_first_page_reaching_max_results_doesnt_wait(): | ||
| client = mock.MagicMock(spec=Client) | ||
| client._call_api.return_value = _arrow_query_response( | ||
| pageRowCount="10", totalRows="25" | ||
| ) | ||
|
|
||
| with mock.patch.object(_job_helpers, "_wait_or_cancel") as wait_or_cancel: | ||
| rows = _query_and_wait(client, query_results_format="ARROW", max_results=10) | ||
|
|
||
| wait_or_cancel.assert_not_called() | ||
| assert rows.max_results == 10 | ||
| assert client._call_api.call_args.kwargs["data"]["maxResults"] == 10 | ||
|
|
||
|
|
||
| def test_to_arrow_with_arrow_format_and_max_results_keeps_bqstorage_client(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test only verifies the behavior when a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea. #18652 (the next PR) adds test_to_arrow_with_arrow_format_reads_with_the_shared_client, which covers to_arrow() with bqstorage_client=None and checks that the stream gets the shared client. |
||
| pyarrow = pytest.importorskip("pyarrow") | ||
| bqstorage_client = mock.MagicMock() | ||
| iterator = RowIterator( | ||
| client=mock.MagicMock(), | ||
| api_request=mock.MagicMock(), | ||
| path=None, | ||
| schema=(), | ||
| max_results=5, | ||
| query_results_format="ARROW", | ||
| ) | ||
| batch = pyarrow.record_batch([pyarrow.array([1, 2])], names=["x"]) | ||
|
|
||
| with ( | ||
| mock.patch.object( | ||
| iterator, "to_arrow_iterable", return_value=iter([batch]) | ||
| ) as to_arrow_iterable, | ||
| warnings.catch_warnings(record=True) as caught, | ||
| ): | ||
| warnings.simplefilter("always") | ||
| table = iterator.to_arrow(bqstorage_client=bqstorage_client) | ||
|
|
||
| to_arrow_iterable.assert_called_once_with( | ||
| bqstorage_client=bqstorage_client, timeout=None | ||
| ) | ||
| assert table.num_rows == 2 | ||
| # ARROW results apply max_results while streaming, so there's no warning | ||
| # about falling back to the REST API. | ||
| assert not [w for w in caught if "max_results" in str(w.message)] | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
reader.cancel()raises an exception (for example, if the stream is in a state where callingcancelraises an error, or if it's a mock/wrapper that doesn't fully implement the interface), it will mask any original exception raised inside thetryblock. To prevent masking the original exception and making debugging difficult, wrapreader.cancel()in atry...exceptblock and log the exception as a warning instead of silently passing.References
except Exception:blocks that silently returnNone. Instead, log the exception (e.g., usinglogger.warning) to aid in debugging and prevent masking underlying issues.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reader here is our ReadRowsStream, whose cancel() just sets a flag and cancels the gRPC call, which doesn't raise even if the call already finished. I'd rather not add a try/except for an error that can't happen.