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 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 | ||||||||||||||||||||||||||||
|
|
@@ -114,6 +114,26 @@ def serialization_options( | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def reached_max_results(response: Dict[str, Any], max_results: Optional[int]) -> bool: | ||||||||||||||||||||||||||||
| """Return True if an ``ARROW`` ``jobs.query`` response's first page | ||||||||||||||||||||||||||||
| already holds ``max_results`` rows.""" | ||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||
| max_results is not None and int(response.get("pageRowCount", 0)) >= max_results | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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]: | ||||||||||||||||||||||||||||
| """Convert ``jobs.query`` ``arrowSerializationOptions`` to the | ||||||||||||||||||||||||||||
| ``ArrowSerializationOptions`` fields that ``ReadRows`` takes.""" | ||||||||||||||||||||||||||||
|
|
@@ -150,6 +170,7 @@ def download_arrow_from_job( | |||||||||||||||||||||||||||
| pa_schema = None | ||||||||||||||||||||||||||||
| page_token = None | ||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||
|
|
@@ -182,12 +203,14 @@ 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. Only a pageToken says rows remain: totalRows is 0 when cached | ||||||||||||||||||||||||||||
| # results are too large to return inline. | ||||||||||||||||||||||||||||
| if job_complete and page_token is None: | ||||||||||||||||||||||||||||
| # response, or if the first page already reached max_results. Only a | ||||||||||||||||||||||||||||
| # pageToken says rows remain: totalRows is 0 when cached results are too | ||||||||||||||||||||||||||||
| # large to return inline. | ||||||||||||||||||||||||||||
| if (job_complete and page_token is None) or remaining == 0: | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Step 4: Stream remaining Arrow record batches from the job default stream. | ||||||||||||||||||||||||||||
|
|
@@ -206,23 +229,37 @@ 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 | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| if pa_schema is None: | ||||||||||||||||||||||||||||
| raise ValueError(_NO_ARROW_SCHEMA_ERROR) | ||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||
| if pa_schema is None: | ||||||||||||||||||||||||||||
| raise ValueError(_NO_ARROW_SCHEMA_ERROR) | ||||||||||||||||||||||||||||
| 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
+261
to
+265
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
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.
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2397,11 +2397,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
+2400
to
+2410
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: | ||
|
|
@@ -2900,7 +2906,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 | ||
|
|
||
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.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.
it already existed. it's the client-side total, not the REST maxResults (that one's per page, which is page_size on our side). query_and_wait only sends maxResults as min(page_size, max_results) so the first page doesn't go over the total.
ReadRows has no row limit, so this PR enforces the total on our end by slicing the last batch and cancelling the stream. before this, ARROW ignored it after the first page: on 3.46.2, max_results=10 returned all 1000 rows. regular results don't change, same rows and same warning as 3.46.2.