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 | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,7 +17,7 @@ | |||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import base64 | ||||||||||||||||||||||||||||
| from typing import Any, Dict, Iterator, Optional, TYPE_CHECKING | ||||||||||||||||||||||||||||
| from typing import Any, Dict, Iterator, Optional, Tuple, TYPE_CHECKING | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| from google.cloud.bigquery import _versions_helpers | ||||||||||||||||||||||||||||
| from google.cloud.bigquery import enums | ||||||||||||||||||||||||||||
|
|
@@ -113,14 +113,29 @@ def serialization_options( | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def has_more_rows(response: Dict[str, Any]) -> bool: | ||||||||||||||||||||||||||||
| def has_more_rows(response: Dict[str, Any], max_results: Optional[int]) -> bool: | ||||||||||||||||||||||||||||
| """Return True if rows remain after an ``ARROW`` ``jobs.query`` response. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ``ARROW`` responses say how many rows the first page holds in | ||||||||||||||||||||||||||||
| ``pageRowCount``, so compare that with ``totalRows`` instead of checking | ||||||||||||||||||||||||||||
| for a ``pageToken``. | ||||||||||||||||||||||||||||
| ``pageRowCount``, so compare that with ``totalRows``, or ``max_results`` if | ||||||||||||||||||||||||||||
| it's smaller, instead of checking for a ``pageToken``. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| return int(response.get("pageRowCount", 0)) < int(response.get("totalRows", 0)) | ||||||||||||||||||||||||||||
| rows_wanted = int(response.get("totalRows", 0)) | ||||||||||||||||||||||||||||
| if max_results is not None: | ||||||||||||||||||||||||||||
| rows_wanted = min(rows_wanted, max_results) | ||||||||||||||||||||||||||||
| return int(response.get("pageRowCount", 0)) < rows_wanted | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _slice_batch( | ||||||||||||||||||||||||||||
| batch: "pyarrow.RecordBatch", remaining: Optional[int] | ||||||||||||||||||||||||||||
| ) -> Tuple["pyarrow.RecordBatch", Optional[int]]: | ||||||||||||||||||||||||||||
| """Cut ``batch`` to at most ``remaining`` rows, and return it with the | ||||||||||||||||||||||||||||
| number of rows still wanted after it (``None`` for no limit).""" | ||||||||||||||||||||||||||||
| if remaining is None: | ||||||||||||||||||||||||||||
| return batch, None | ||||||||||||||||||||||||||||
| if batch.num_rows > remaining: | ||||||||||||||||||||||||||||
| batch = batch.slice(0, remaining) | ||||||||||||||||||||||||||||
| return batch, remaining - batch.num_rows | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]: | ||||||||||||||||||||||||||||
|
|
@@ -159,6 +174,7 @@ def download_arrow_from_job( | |||||||||||||||||||||||||||
| pa_schema = None | ||||||||||||||||||||||||||||
| total_rows = row_iterator.total_rows | ||||||||||||||||||||||||||||
| job_complete = False | ||||||||||||||||||||||||||||
| remaining = row_iterator.max_results | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Step 2: Process initial inline Arrow response from jobs.query if available. | ||||||||||||||||||||||||||||
| if row_iterator._first_page_response: | ||||||||||||||||||||||||||||
|
|
@@ -187,10 +203,12 @@ def download_arrow_from_job( | |||||||||||||||||||||||||||
| pa_schema, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| offset += batch.num_rows | ||||||||||||||||||||||||||||
| batch, remaining = _slice_batch(batch, remaining) | ||||||||||||||||||||||||||||
| yield batch | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Step 3: Return early if all results were delivered in the first page response. | ||||||||||||||||||||||||||||
| if job_complete and offset >= total_rows: | ||||||||||||||||||||||||||||
| # Step 3: Return early if all results were delivered in the first page | ||||||||||||||||||||||||||||
| # response, or if the first page already reached max_results. | ||||||||||||||||||||||||||||
| if (job_complete and offset >= total_rows) or remaining == 0: | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Step 4: Stream remaining Arrow record batches from the job default stream. | ||||||||||||||||||||||||||||
|
|
@@ -209,22 +227,36 @@ def download_arrow_from_job( | |||||||||||||||||||||||||||
| row_iterator._arrow_serialization_options | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| for response in reader: | ||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||
| response.arrow_schema | ||||||||||||||||||||||||||||
| and response.arrow_schema.serialized_schema | ||||||||||||||||||||||||||||
| and pa_schema is None | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| pa_schema = pyarrow.ipc.read_schema( | ||||||||||||||||||||||||||||
| pyarrow.py_buffer(response.arrow_schema.serialized_schema) | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||
| response.arrow_record_batch | ||||||||||||||||||||||||||||
| and response.arrow_record_batch.serialized_record_batch | ||||||||||||||||||||||||||||
| and pa_schema is not None | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| batch = pyarrow.ipc.read_record_batch( | ||||||||||||||||||||||||||||
| pyarrow.py_buffer(response.arrow_record_batch.serialized_record_batch), | ||||||||||||||||||||||||||||
| pa_schema, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| yield batch | ||||||||||||||||||||||||||||
| finished = False | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| for response in reader: | ||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||
| response.arrow_schema | ||||||||||||||||||||||||||||
| and response.arrow_schema.serialized_schema | ||||||||||||||||||||||||||||
| and pa_schema is None | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| pa_schema = pyarrow.ipc.read_schema( | ||||||||||||||||||||||||||||
| pyarrow.py_buffer(response.arrow_schema.serialized_schema) | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||
| response.arrow_record_batch | ||||||||||||||||||||||||||||
| and response.arrow_record_batch.serialized_record_batch | ||||||||||||||||||||||||||||
| and pa_schema is not None | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| batch = pyarrow.ipc.read_record_batch( | ||||||||||||||||||||||||||||
| pyarrow.py_buffer( | ||||||||||||||||||||||||||||
| response.arrow_record_batch.serialized_record_batch | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| pa_schema, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| batch, remaining = _slice_batch(batch, remaining) | ||||||||||||||||||||||||||||
| yield batch | ||||||||||||||||||||||||||||
| if remaining == 0: | ||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| finished = True | ||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||
| # Stop the stream if reading ends before it does: at max_results, on | ||||||||||||||||||||||||||||
| # an error, or when the caller closes the iterator. | ||||||||||||||||||||||||||||
| if not finished: | ||||||||||||||||||||||||||||
| reader.cancel() | ||||||||||||||||||||||||||||
|
Comment on lines
+258
to
+262
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. If
Suggested change
References
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. 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.
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. I assume we've protected to make sure we have a new enough version of google-cloud-bigquery-storage by the time we get to this point? IMO this line means we should block this PR from being merged until |
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2396,11 +2396,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
+2399
to
+2409
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: | ||
|
|
@@ -2899,7 +2905,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.
Use some caution when we're in this layer. The BigQuery REST API doesn't follow the typical style for APIs. It names its page size parameter to
maxResultsrather thanpageSize. If we're introducting this field on RowIterator, then let's try to disambiguate somehow. Ignore if this field already existed.