Skip to content

feat(bigquery): share one BigQuery Storage client per Client - #18652

Merged
ohmayr merged 6 commits into
mainfrom
feat/bigquery-arrow-shared-bqstorage-client
Oct 21, 2026
Merged

ohmayr merged 6 commits into
mainfrom
feat/bigquery-arrow-shared-bqstorage-client

Conversation

@ohmayr

@ohmayr ohmayr commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Shares one BigQueryReadClient per Client for Arrow results, instead of creating a new one for every read and never closing it.

  • Creates it on first use, with the client's credentials and universe domain, and closes it in Client.close().
  • Uses a bqstorage_client passed by the caller instead, without closing it.
  • Sets gRPC keepalive on its channel (grpc.keepalive_time_ms: 60000, grpc.keepalive_timeout_ms: 60000).
  • Adds unit tests and system tests for sharing, closing, and cancelling one read early.

@ohmayr
ohmayr requested review from a team as code owners October 13, 2026 08:34
@ohmayr
ohmayr requested review from sycai and removed request for a team October 13, 2026 08:35
@ohmayr
ohmayr added this pull request to stack #18648 October 13, 2026 08:37

@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 a shared, thread-safe BigQuery Storage client for ARROW query results, allowing multiple queries to reuse a single client instance. It also configures the shared client's gRPC transport with keepalive pings to prevent idle connections from being dropped, and ensures the shared client is closed when the main BigQuery client is closed. The feedback suggests defensively handling the options parameter in create_channel to prevent a potential TypeError if None is passed.

Comment on lines +187 to +191
def create_channel(*args, options=(), **channel_kwargs):
options = list(options) + [
("grpc.keepalive_time_ms", _BQSTORAGE_KEEPALIVE_TIME_MS)
]
return transport_class.create_channel(*args, options=options, **channel_kwargs)

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 options is explicitly passed as None to create_channel, calling list(options) will raise a TypeError: 'NoneType' object is not iterable. Using options or [] ensures defensive handling and prevents potential runtime crashes.

Suggested change
def create_channel(*args, options=(), **channel_kwargs):
options = list(options) + [
("grpc.keepalive_time_ms", _BQSTORAGE_KEEPALIVE_TIME_MS)
]
return transport_class.create_channel(*args, options=options, **channel_kwargs)
def create_channel(*args, options=None, **channel_kwargs):
options = list(options or []) + [
("grpc.keepalive_time_ms", _BQSTORAGE_KEEPALIVE_TIME_MS)
]
return transport_class.create_channel(*args, options=options, **channel_kwargs)
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.

@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch from 47c18a9 to 18c7c06 Compare October 13, 2026 10:02
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch 3 times, most recently from 3cba84a to 54bdf24 Compare October 16, 2026 01:16
Comment on lines +190 to +194
options = list(options) + [
("grpc.keepalive_time_ms", _BQSTORAGE_KEEPALIVE_TIME_MS),
("grpc.keepalive_timeout_ms", _BQSTORAGE_KEEPALIVE_TIMEOUT_MS),
]
return transport_class.create_channel(*args, options=options, **channel_kwargs)

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.

Would it make more sense to upstream this to the google-cloud-bigquery-storage client library?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I think it belongs in bigquery-storage long term. Any long ReadRows read could use it, not just ours.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd rather do it as a follow-up though. We'd only get it here once our bqstorage minimum includes it, so it'd mean another release and another floor bump. Filed #18784 for it and left a TODO on _create_bqstorage_transport. There's already a ReadRowsStream change waiting on the next bqstorage release (#18781), so it could go out with that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Opened #18792 to make keepalive the default in google-cloud-bigquery-storage. Once a release with it is our minimum, we can drop _create_bqstorage_transport here (#18785).

Comment thread packages/google-cloud-bigquery/google/cloud/bigquery/client.py Outdated

return bqstorage_client

def _get_shared_bqstorage_client(

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.

Why introduce a new method instead of doing this in_ensure_bqstorage_client or renaming _ensure_bqstorage_client to _get_shared_bqstorage_client?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Every caller of _ensure_bqstorage_client() gets its own client and closes the transport when it's done (to_dataframe(), DB-API, magics). If it handed back the shared one, the first to_dataframe() would close the channel the Arrow queries are using.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So I kept them separate: _ensure_bqstorage_client() gives you a client you own, and _get_shared_bqstorage_client() gives you one only Client.close() closes. Added a line to the docstring saying that.

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.

Gotcha. We should reconsider automatically closing in those methods at some point, but out of scope for these changes.

@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch from 54bdf24 to 7e419d3 Compare October 16, 2026 21:45
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch 2 times, most recently from 61eec40 to eb2e0fc Compare October 19, 2026 23:26
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch 3 times, most recently from 30a1c50 to 928b0de Compare October 20, 2026 00:49

@tswast tswast 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.

Thanks! I suspect this will have a big impact on performance for long-lived client objects.

Base automatically changed from fix/bigquery-arrow-max-results to main October 21, 2026 16:01
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.
bigquery-storage 2.43.0's ReadRowsStream.__del__ also calls cancel()
on streams that finished normally. The call sends no RPC, but the
system test counted it, so it failed with the released library.
Count only cancels of streams that are still open, until the
bqstorage extra requires a release with the fix (#18785).
Note that callers must not close the shared BigQuery Storage client,
unlike the clients from _ensure_bqstorage_client(). Add a TODO to
move keepalive into google-cloud-bigquery-storage (#18784).
The no-schema tests from main mock _ensure_bqstorage_client(), but
download_arrow_from_job now gets its client from
_get_shared_bqstorage_client(). Mock that instead, like the other
Arrow tests.
Client.__init__ already sets _universe_domain, and
_ensure_bqstorage_client() adds the universe domain when no
client_options are passed. Drop the duplicate assignment and stop
building ClientOptions in _get_shared_bqstorage_client().
@tswast
tswast force-pushed the feat/bigquery-arrow-shared-bqstorage-client branch from 2330555 to 68d6bfb Compare October 21, 2026 16:01
@tswast tswast added cla: yes This human has signed the Contributor License Agreement. kokoro:force-run Add this label to force Kokoro to re-run the tests. labels Oct 21, 2026
@yoshi-kokoro yoshi-kokoro removed the kokoro:force-run Add this label to force Kokoro to re-run the tests. label Oct 21, 2026
_get_shared_bqstorage_client() no longer passes client_options to
_ensure_bqstorage_client(), so the system test's expected call failed.
Match the unit tests and expect only the transport argument.
@ohmayr ohmayr added the kokoro:force-run Add this label to force Kokoro to re-run the tests. label Oct 21, 2026
@yoshi-kokoro yoshi-kokoro removed the kokoro:force-run Add this label to force Kokoro to re-run the tests. label Oct 21, 2026
@ohmayr
ohmayr merged commit 89cbc50 into main Oct 21, 2026
54 checks passed
@ohmayr
ohmayr deleted the feat/bigquery-arrow-shared-bqstorage-client branch October 21, 2026 18:55
ohmayr added a commit that referenced this pull request Oct 21, 2026
Makes `BigQueryReadClient` send gRPC keepalive pings by default.
`ReadRows` streams can sit idle for a long time, for example while
BigQuery finishes a query job, and without pings a NAT or load balancer
can drop the idle connection without telling either side.
google-cloud-bigquery works around this with its own transport for Arrow
query results (#18652). This moves the setting here, so every long read
gets it.

- Uses a gRPC channel with `grpc.keepalive_time_ms` and
`grpc.keepalive_timeout_ms` set to 60s when no `transport` is passed (or
`transport="grpc"`).
- Leaves a caller's transport instance or callable unchanged.
- Adds unit tests for the default channel options and for
caller-provided transports.

This changes the default channel for every bigquery-storage user, but
normal table reads shouldn't notice: pings only go out while a call is
open and the connection has been quiet for 60s
(`keepalive_permit_without_calls` isn't set). A read on a dropped
connection now fails with `UNAVAILABLE` after about 2 minutes instead of
hanging, and `ReadRowsStream` already reconnects on that.
google-cloud-bigtable and google-cloud-firestore already send keepalive
pings by default, every 30s.

Fixes #18784
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: yes This human has signed the Contributor License Agreement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants