Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for limiting query results via max_results when using the ARROW format, including slicing Arrow record batches and canceling the BigQuery Storage API stream early if the limit is reached. The review feedback highlights a critical bug in table.py where disabling create_bqstorage_client for ARROW format prevents automatic client creation when streaming is required. Additionally, the reviewer recommends wrapping reader.cancel() in a try-except block to prevent masking other exceptions and suggests adding a unit test to cover the scenario where bqstorage_client is not provided.
| 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 |
There was a problem hiding this comment.
Setting create_bqstorage_client = False when self._query_results_format == QueryResultsFormat.ARROW.value prevents the creation of a bqstorage_client when the user hasn't provided one. If the query results exceed the first page, to_arrow() will fail with an AttributeError because it needs a bqstorage_client to stream the remaining pages. Instead, we should preserve create_bqstorage_client so that a client can be automatically created if needed.
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 = NoneThere was a problem hiding this comment.
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.
| 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() |
There was a problem hiding this comment.
If reader.cancel() raises an exception (for example, if the stream is in a state where calling cancel raises an error, or if it's a mock/wrapper that doesn't fully implement the interface), it will mask any original exception raised inside the try block. To prevent masking the original exception and making debugging difficult, wrap reader.cancel() in a try...except block and log the exception as a warning instead of silently passing.
| 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() | |
| 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: | |
| try: | |
| reader.cancel() | |
| except Exception as exc: | |
| logger.warning("Failed to cancel reader: %s", exc) |
References
- Avoid broad
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.
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.
| assert client._call_api.call_args.kwargs["data"]["maxResults"] == 10 | ||
|
|
||
|
|
||
| def test_to_arrow_with_arrow_format_and_max_results_keeps_bqstorage_client(): |
There was a problem hiding this comment.
This test only verifies the behavior when a bqstorage_client is explicitly provided. It would be highly beneficial to also add a test case where bqstorage_client=None (the default) to ensure that to_arrow() correctly creates a temporary bqstorage_client when streaming is required, preventing regressions like the one identified in table.py.
There was a problem hiding this comment.
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.
With ARROW, max_results only limited the first page from jobs.query. Every remaining row was then streamed, so a query with max_results=150 and a 100-row first page returned the whole result. to_arrow() and to_dataframe() also warned that max_results made them fall back to REST, which isn't true for ARROW, and to_arrow() then dropped the bqstorage_client the caller passed. RowIterator now counts rows across the first page and the stream, slices the batch that reaches max_results, and stops. If the first page already reaches the limit, it doesn't open a stream, and query_and_wait() doesn't wait for rows it won't read. A stream that ends early, at the limit, on an error, or because the caller closed the iterator, is cancelled with ReadRowsStream.cancel(), and errors are raised after the batches that arrived before them. For ARROW results, to_arrow() and to_dataframe() skip the warning and keep the caller's bqstorage_client.
df4bf08 to
06c5425
Compare
Applies
max_resultsto the whole Arrow result. Before, it only limited thejobs.queryfirst page and every remaining row was streamed.to_arrow()andto_dataframe()no longer warn about a REST fallback for Arrow results, andto_arrow()keeps the caller'sbqstorage_client.