Skip to content

test(bigquery): run CI on the Arrow query results stack (do not merge) - #18647

Draft
ohmayr wants to merge 10 commits into
mainfrom
ci/bigquery-arrow-stack
Draft

ohmayr wants to merge 10 commits into
mainfrom
ci/bigquery-arrow-stack

Conversation

@ohmayr

@ohmayr ohmayr commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for reading BigQuery query results in Apache Arrow format with serialization options (such as buffer compression) across both google-cloud-bigquery and google-cloud-bigquery-storage. It adds a new _arrow_query module to handle validation, serialization options, and downloading of Arrow record batches, integrates these options into query_and_wait and RowIterator, and implements a cancel() method on ReadRowsStream to allow early termination of streams. Feedback on these changes suggests raising a ValueError if an Arrow record batch is received without a schema to prevent silent data loss, refactoring the optional parameter handling in _read_rows_options for better encapsulation, and adding upfront validation for project, location, and job_id to avoid malformed stream names.

Comment on lines +214 to +223
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If response.arrow_record_batch is present but pa_schema is None, the record batch will be silently skipped. Since there is no fallback mechanism when streaming from the BigQuery Read API, this can lead to silent data loss where some rows are omitted from the final results without raising any error. Raising a ValueError explicitly when a batch is received without a schema prevents silent data corruption.

Suggested change
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
if (
response.arrow_record_batch
and response.arrow_record_batch.serialized_record_batch
):
if pa_schema is None:
raise ValueError("An Arrow record batch was received, but the schema is missing.")
batch = pyarrow.ipc.read_record_batch(
pyarrow.py_buffer(response.arrow_record_batch.serialized_record_batch),
pa_schema,
)
yield batch

Comment on lines +118 to +125
def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]:
"""Convert ``jobs.query`` ``arrowSerializationOptions`` to the
``ArrowSerializationOptions`` fields that ``ReadRows`` takes."""
options = options or serialization_options(None)
return {
"buffer_compression": options["bufferCompression"],
"picos_timestamp_precision": options["picosTimestampPrecision"],
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When processing optional parameters with a helper function like serialization_options, prefer placing the null or None check within the function body rather than at the call site to simplify the calling code and improve encapsulation. Therefore, we should pass options directly to serialization_options instead of performing a manual inline OR check.

Suggested change
def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]:
"""Convert ``jobs.query`` ``arrowSerializationOptions`` to the
``ArrowSerializationOptions`` fields that ``ReadRows`` takes."""
options = options or serialization_options(None)
return {
"buffer_compression": options["bufferCompression"],
"picos_timestamp_precision": options["picosTimestampPrecision"],
}
def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]:
"""Convert ``jobs.query`` ``arrowSerializationOptions`` to the
``ArrowSerializationOptions`` fields that ``ReadRows`` takes."""
options = serialization_options(options)
return {
"buffer_compression": options.get("bufferCompression", "COMPRESSION_UNSPECIFIED"),
"picos_timestamp_precision": options.get("picosTimestampPrecision", "TIMESTAMP_PRECISION_MICROS"),
}
References
  1. When processing optional parameters with a helper function, prefer placing the null or None check within the function body rather than at the call site to simplify the calling code and improve encapsulation.

Comment on lines +190 to +196
project = row_iterator._project or (
row_iterator.client.project if row_iterator.client else None
)
location = row_iterator._location or (
row_iterator.client.location if row_iterator.client else None
)
stream_name = f"projects/{project}/locations/{location}/jobs/{row_iterator._job_id}/streams/_default"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If project, location, or row_iterator._job_id are None or empty, the constructed stream_name will contain "None", leading to a confusing gRPC or path formatting error from the API. Adding explicit validation checks upfront will provide a much clearer error message to the user.

    project = row_iterator._project or (
        row_iterator.client.project if row_iterator.client else None
    )
    location = row_iterator._location or (
        row_iterator.client.location if row_iterator.client else None
    )
    if not project:
        raise ValueError("Project is required to stream Arrow results.")
    if not location:
        raise ValueError("Location is required to stream Arrow results.")
    if not row_iterator._job_id:
        raise ValueError("Job ID is required to stream Arrow results.")
    stream_name = f"projects/{project}/locations/{location}/jobs/{row_iterator._job_id}/streams/_default"

ohmayr added 10 commits October 13, 2026 09:35
Default job streams (projects/.../jobs/.../streams/_default) accept
ArrowSerializationOptions on each ReadRowsRequest, but
BigQueryReadClient.read_rows() had no way to send them, and
ReadRowsStream dropped them when it reconnected after a transient
error. There was also no way to stop a stream early without closing
the whole client.

read_rows() now takes arrow_serialization_options, and ReadRowsStream
sends them on the first request and on every reconnect, so all
batches in a stream use the same encoding. ReadRowsStream.cancel()
cancels the current call and stops further reconnects, including a
reconnect already in progress, without closing the client.

google-cloud-bigquery needs both to read query results in Arrow
format through the default job stream.
RowIterator._download_arrow_from_job_id() holds the whole ARROW
read path inline in table.py, and upcoming Arrow work (serialization
options, row limits, explicit destinations, empty results) would
grow it further. _pyarrow_helpers isn't an option, because it's
deprecated in favor of pandas-gbq.

Move the method body unchanged into a new private module,
_arrow_query.download_arrow_from_job(), with self renamed to
row_iterator. The RowIterator method keeps its signature and
docstring and delegates to it. Tests that mocked table.pyarrow for
this path now mock _arrow_query.pyarrow. No behavior change.
query_and_wait() with query_results_format=ARROW only found a missing
pyarrow or google-cloud-bigquery-storage when results were read, after
the query had already run. A query could work while its results fit
in the jobs.query response and then fail once they needed a stream.
It also accepted WRITE_APPEND, whose results can't be read on their
own, and compression_codec without ARROW, where it does nothing.

Check all three before the request is built. compression_codec
without ARROW and an ARROW query with WRITE_APPEND (including through
default_query_job_config) raise ValueError. An ARROW query checks
pyarrow and google-cloud-bigquery-storage with the existing version
helpers, and also checks that BigQueryReadClient.read_rows() accepts
arrow_serialization_options. That check looks for
ReadRowsStream.cancel(), which shipped in the same change, rather than
a version number, so it's correct for the unreleased in-repo build
that the system tests install until the next storage release.
query_and_wait() sent compression_codec inside formatOptions, which
only has useInt64Timestamp and timestampOutputFormat, so jobs.query
ignored it, and ReadRows never got it at all. The first page and the
stream each used their own endpoint's defaults, so one result could
mix encodings.

Build one arrowSerializationOptions value for ARROW queries and send
it at the top level of the jobs.query request, where it also joins
the jobs.query allowlist. Options the caller didn't set are sent with
their defaults (COMPRESSION_UNSPECIFIED, TIMESTAMP_PRECISION_MICROS),
so the encoding never depends on server defaults. RowIterator keeps
the value on both the jobs.query and the waited-job paths and sends
it on read_rows(). jobs.insert never gets these fields, since they
come from query_and_wait() arguments rather than QueryJobConfig.

The mypy session now installs the in-repo google-cloud-bigquery-storage
too, so the new read_rows() argument is type-checked.
ARROW results always truncated picosecond TIMESTAMP columns to
microseconds, because query_and_wait() had no way to ask for another
precision. Client.query() and Client.list_rows() already take
timestamp_precision for STRUCT_ENCODING results.

Add the same timestamp_precision parameter, with the existing
TimestampPrecision enum, to query_and_wait(). PICOSECOND maps to
TIMESTAMP_PRECISION_PICOS, which returns picosecond columns at full
precision as ISO 8601 strings, and anything else maps to
TIMESTAMP_PRECISION_MICROS. Nanoseconds aren't offered, since the
picosecond strings already keep full precision. The value goes into
the arrowSerializationOptions sent on jobs.query and ReadRows, and
setting it without ARROW raises ValueError, like compression_codec.
It's labeled [Private Preview], as on Client.query().
When an ARROW query's results didn't fit in the jobs.query response,
QueryJob.result() dropped that response, because it only kept first
pages that had STRUCT_ENCODING rows. The stream then reread every row
from offset 0. While waiting for the job, query_and_wait() also passed
page_size to jobs.getQueryResults, which returned STRUCT_ENCODING rows
that ARROW results never use.

For ARROW queries, decide whether rows remain by comparing
pageRowCount with totalRows instead of checking for a pageToken, wait
with page_size=0 so jobs.getQueryResults is called with maxResults=0,
and keep a jobs.query response that has arrowRecordBatch as the first
page. RowIterator then returns the first page and streams the rest
from the next row, also when it's read again. Queries that don't use
ARROW are unchanged.
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.
ARROW query results created a new BigQueryReadClient for every read,
even ones that never opened a stream, and to_arrow_iterable() never
closed it, so each read left a gRPC channel open until garbage
collection. The new client also ignored the BigQuery client's
universe domain.

Client now creates one BigQueryReadClient under a lock the first time
an ARROW result needs a stream, with its credentials and universe
domain, and reuses it. close() closes it, and a later query creates a
new one. Results that fit in the first page never create it. A
bqstorage_client passed to to_arrow_iterable() is used instead and
never closed. Other BigQuery Storage reads keep creating their own
clients.

The shared client's gRPC channel sets keepalive (60s ping time, 60s
ping timeout) through the generated transport's channel callable.
gRPC stops pinging after two pings while ReadRows sends nothing, so
longer pauses rely on ReadRowsStream resuming at the next unread row.
The job's default read stream rejects queries whose job config sets a
destination table ("This API does not support explicitly-specified
destination tables"), so query_and_wait() with ARROW results failed for
those queries.

When the query wrote to an explicit destination, read the table through
create_read_session() instead, with data_format=ARROW and
max_stream_count=1 so rows stay in order. The session's read options
carry the same serialization options as jobs.query. An empty table has
no streams and yields nothing. WRITE_APPEND destinations are already
rejected by validate_query().
A query that returned no rows with ARROW yielded no batches, so
to_arrow() fell back to the iterator's schema, which jobs.query omits
for ARROW, and returned a table without columns. jobs.query sends
arrowSchema even for zero rows, but the default job stream sends
nothing for them.

to_arrow_iterable() now yields one empty RecordBatch if no rows arrive,
like _EmptyRowIterator already does. It has the Arrow schema from the
first page or the read session; otherwise the schema from
jobs.getQueryResults, which the jobs.insert path has; otherwise no
columns. to_arrow() and pyarrow.Table.from_batches() keep that schema.
@ohmayr
ohmayr force-pushed the ci/bigquery-arrow-stack branch from 825fed7 to b501684 Compare October 13, 2026 10:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant