Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for streaming multi-page Arrow query results from BigQuery using the BigQuery Storage API (_default stream) when results exceed the inline page size. It introduces lazy creation and reuse of a BigQueryReadClient, handles location-specific streams, supports early cancellation, and adds comprehensive unit and system tests. Regarding the feedback, overriding stream._destroy on an instantiated stream is an anti-pattern and redundant since stream.once('close', ...) is already registered for cleanup; this override should be removed.
6f50082 to
5a4b92b
Compare
5a4b92b to
15beb0e
Compare
42ce043 to
3e91b88
Compare
3e91b88 to
579849b
Compare
579849b to
38ab043
Compare
498668a to
f9a6776
Compare
f9a6776 to
67855b7
Compare
78bf25d to
ed46fbf
Compare
311a1db to
2948937
Compare
Implements the BigQuery Storage Read API fallback for inline jobs.query
calls when rows remain after the first response page (pageRowCount <
totalRows, or a pageToken is set):
- Lazily initializes and reuses a single BigQueryReadClient on the
BigQuery instance with explicit authClient, projectId, universeDomain,
and 60s/60s gRPC keepalive settings.
- Emits inline RecordBatch results first, then calls ReadRows on the
anonymous default job stream
(projects/{p}/locations/{l}/jobs/{j}/streams/_default) at
offset = Number(pageRowCount).
- Cancels only the active ReadRows gRPC stream if the caller terminates
iteration early without closing the shared BigQueryReadClient.
2948937 to
d5bdcaf
Compare
shivanee-p
left a comment
There was a problem hiding this comment.
Left a few comments. I'm a little apprehensive about how streams are getting manually cancelled. Can you go through and confirm that all of the stream error listeners are 1) not destroyed early so an error event isn't emitted without a listener attached and 2) correctly detached on completion/destruction? I think some unit tests covering those scenarios would be helpful. Nice work on the implementation!
| // TODO: Bump minimum version (here and in package.json) once | ||
| // @google-cloud/bigquery-storage is released with | ||
| // @google-cloud/bigquery-storage-api >= 0.5.0. |
There was a problem hiding this comment.
Can you add a link to a bug or issue to track this TODO?
| if (!finished) { | ||
| finished = true; | ||
| grpcStream.cancel(); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Calling .cancel() will internally destroy the call and emit an error event with the status code 1 (CANCELLED) on the next event loop tick. This will cause an UnhandledException to be thrown.
I think attaching a no-op listener is the best way to go:
if (!finished) {
grpcStream.on('error', () => {});
grpcStream.cancel();
grpcStream = null;
}
There was a problem hiding this comment.
Done in c4b2070. All ReadRows cleanup now goes through one settle(), which removes every listener, adds a no-op 'error' listener before cancel(), and only cancels if the call hasn't finished. grpcStream = null isn't needed: once the listeners and ctx.onRead are cleared, nothing references the stream.
| return; | ||
| } | ||
|
|
||
| if (stream._ended || stream.destroyed) { |
There was a problem hiding this comment.
We shouldn't be referencing private API flags like _ended. We should instead only rely on either stream.destroyed or your own private variable within the code
There was a problem hiding this comment.
Removed. The query now runs in one async function, and its outcome is the only place that ends or destroys the stream, so only stream.destroyed checks remain.
Run the query in one async function whose outcome is the only place that ends or destroys the stream, so helpers no longer check the private ResourceStream _ended flag. Wrap ReadRows in a promise that settles through one settle() on end, error, decode failure, or early destroy. settle() removes every listener, keeps a no-op 'error' listener so the CANCELLED error that follows cancel() can't crash the process, and cancels the call unless it already finished.
|
Thanks! Checked every place the stream ends: normal end, gRPC error, decode error, and early destroy all go through |
BigQueryReadClient and StorageModule are exported only so the unit tests can stub them. Document them as @Private, like the exported helper functions in arrow.ts, so they stay out of the public API docs.
| }); | ||
| process.nextTick(() => { | ||
| // Emit more batches in one tick than the output stream buffers | ||
| // (highWaterMark 16), before the consumer can read any of them. |
There was a problem hiding this comment.
nit: Do we need to leave highwaterMark at 16? Passing in 1 or 2 would be more efficient and prevent high memory spikes.
There was a problem hiding this comment.
Set highWaterMark: 1 in 8c53d44, so at most one decoded RecordBatch is buffered ahead of the consumer.
| * | ||
| * @private | ||
| */ | ||
| function streamReadRows( |
There was a problem hiding this comment.
This function needs to be explicitly defined as async
| ); | ||
| }; | ||
|
|
||
| stream._read = () => { |
There was a problem hiding this comment.
We should avoid internal variable names beginning with _. Node provides the Simplified Construction API so callers pass a read() option
There was a problem hiding this comment.
Switched to the read option in 8c53d44, passed through ResourceStream's streamOptions.
| } | ||
| started = true; | ||
| // The only place that ends or destroys the stream. | ||
| void (async () => { |
There was a problem hiding this comment.
Now that runQuery() is asynchronous, we don't need to have a try/catch within this definition. We can move the try/catch into the runQuery() definition.
There was a problem hiding this comment.
Moved in 8c53d44: runQuery now owns the try/catch and is the only place that ends or destroys the stream; the query body is streamQueryResults.
| return; | ||
| } | ||
| const [job, res] = (await bq.runJobsQuery(queryReq)) as JobsQueryResponse; | ||
|
|
There was a problem hiding this comment.
We should add if (stream.destroyed) return; after await bq.runJobsQuery(queryReq) to avoid emitting events on a closed stream
There was a problem hiding this comment.
Added in 8c53d44, with a test that a stream destroyed during jobs.query emits no job event or batches.
Address review feedback on the default job stream: - Return after jobs.query if the consumer destroyed the stream, so no 'job' event or batches are emitted on a closed stream. - Buffer at most one RecordBatch (highWaterMark 1) instead of 16. - Pass read() through streamOptions instead of assigning stream._read, and move the try/catch that ends or destroys the stream into runQuery. - Mark streamReadRows async. - Document calling destroy() when stopping early, and that the no-op ReadRows 'error' listener swallows every later error.
jobs.queryreturns only the first page of results inline. When rows remain, this reads them from the job's default read stream (projects/{p}/locations/{l}/jobs/{j}/streams/_default), starting right after the inline page.pageRowCount < totalRowsor apageTokenis set. ThepageTokencheck covers ARROW cache hits, wherejobs.queryreportstotalRows: "0"and omitspageRowCount; those results are read from offset 0.BigQueryReadClientperBigQueryinstance, created on first use with the client's credentials, project,universeDomain, and a 60s keepalive. The location comes fromjobReference, falling back to the client'slocation.ReadRowspauses while the consumer is behind and resumes on read, so a slow consumer doesn't buffer the whole result.ReadRowsstream; the shared client stays open.RecordBatchahead of the consumer (highWaterMark: 1).jobs.queryruns, nojobevent or batches are emitted.