Skip to content

feat(bigquery): wait for unfinished jobs before streaming Arrow results - #9599

Open
ohmayr wants to merge 5 commits into
feat/bigquery-arrow-default-streamfrom
feat/bigquery-arrow-job-wait
Open

ohmayr wants to merge 5 commits into
feat/bigquery-arrow-default-streamfrom
feat/bigquery-arrow-job-wait

Conversation

@ohmayr

@ohmayr ohmayr commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Extends bigquery.createQueryArrowStream(query) to wait for unfinished queries (jobComplete === false) before streaming Arrow results from the job's default read stream (projects/{project}/locations/{location}/jobs/{job}/streams/_default).

  • Adds the ArrowQuery options type (beta, like the method) with timeoutMs.
  • Passes timeoutMs to jobs.query and fails if jobComplete === false when a caller-provided timeoutMs expires.
  • Polls job.getQueryResults with maxResults: 0 and autoPaginate: false until the job completes without fetching row data, then streams all batches via ReadRows starting at offset = 0.
  • Emits a 0-row RecordBatch when reading from offset = 0 and the stream closes without any record batches.
  • When a result read from offset = 0 has no rows, reads its schema from the job's destination table (jobs.get, then CreateReadSession), because streams/_default sends nothing for an empty result. This is skipped if the consumer has destroyed the stream.
  • Re-runs the Arrow system tests under BIGQUERY_PICOSECOND_SUPPORT (high_precision_timestamp.ts) to cover a TIMESTAMP(12) column during the job wait.

@product-auto-label product-auto-label Bot added the api: bigquery Issues related to the BigQuery API. label Oct 13, 2026
@ohmayr
ohmayr added this pull request to stack #9597 October 13, 2026 02:12

@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 adds support for waiting on unfinished BigQuery queries when executing createQueryArrowStream. It introduces a polling mechanism (waitForJobCompletion) using job.getQueryResults with maxResults: 0 to wait for job completion before streaming results, and adds configuration for timeoutMs. Additionally, comprehensive unit and system tests are added to cover these scenarios. The feedback highlights a critical issue in waitForJobCompletion where polling is executed in a tight loop without any delay, which could lead to API rate limiting. It is recommended to introduce a delay using setTimeout and implement proper cleanup on stream destruction.

Comment thread handwritten/bigquery/src/arrow.ts Outdated
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from e9e8c87 to 1922007 Compare October 13, 2026 02:41
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from 1922007 to bdcf7cc Compare October 13, 2026 04:30
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from bdcf7cc to 2e564cc Compare October 13, 2026 08:41
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch 3 times, most recently from 96ebe98 to 67aba4c Compare October 13, 2026 10:42
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch 2 times, most recently from 4469c39 to d2751f2 Compare October 13, 2026 12:00
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from d2751f2 to 632e0c9 Compare October 13, 2026 19:26
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from 632e0c9 to 9d734b9 Compare October 15, 2026 09:55
Comment thread handwritten/bigquery/src/arrow.ts Outdated
Comment thread handwritten/bigquery/src/arrow.ts Outdated
Comment thread handwritten/bigquery/src/arrow.ts Outdated
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from 9d734b9 to 09093e3 Compare October 15, 2026 18:15
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch 2 times, most recently from 16abd40 to c254891 Compare October 15, 2026 19:30
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from c254891 to f6cb6da Compare October 16, 2026 01:12
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from f6cb6da to 09fb20b Compare October 16, 2026 02:19
@ohmayr
ohmayr marked this pull request as ready for review October 16, 2026 02:33
@ohmayr
ohmayr requested review from a team as code owners October 16, 2026 02:33

@shivanee-p shivanee-p 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.

It's great to see the progress!

I have some concerns with the polling logic. I think having it be asynchronous would be really beneficial.

Comment thread handwritten/bigquery/src/arrow.ts Outdated
}
stream.push(batch);
}
if (!stream._ended && !stream.destroyed) {

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.

See previous comment on #9598 regarding private API variables

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.

Fixed. _ended is gone here too; the stream only ends in one place (see #9598).

Comment thread handwritten/bigquery/src/arrow.ts Outdated
Comment on lines +540 to +573
function waitForJobCompletion(
job: Job,
stream: ArrowRecordBatchStream,
onComplete: () => void,
): void {
const poll = () => {
if (stream._ended || stream.destroyed) {
return;
}
// Set maxResults: 0 so jobs.getQueryResults blocks up to the server's
// default wait interval and surfaces job errors without returning JSON rows.
job.getQueryResults(
{
autoPaginate: false,
maxResults: 0,
},
(err, _rows, _nextQuery, res) => {
if (err) {
stream.destroy(err);
return;
}
if (stream._ended || stream.destroyed) {
return;
}
if (res?.jobComplete === false) {
poll();
return;
}
onComplete();
},
);
};
poll();
}

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.

It makes sense regarding the polling mechanism. However there are a few concerns:

  • poll() is called immediately when jobComplete is false, but we should have a slight delay to prevent throwing rate limiting errors
  • I think transforming this code to be asynchronous would be a good call. Using an async/await loop would be preferred to avoid nested recursive chaining

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.

Made it an async loop. I left out a client-side delay: getQueryResults blocks on the server for about 10 s per call while the job runs (measured: 20 polls in 201 s on a long job), so the loop already polls about 6 times a minute, and a delay would only add latency after the job finishes. This matches how query() waits today.

@ohmayr ohmayr Oct 21, 2026 •

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.

Following up on the polling delay: each jobs.getQueryResults call with maxResults: 0 waits on the server for up to about 10 s while the job runs, so polling again right away doesn't spin. A client-side delay would only add latency after the job finishes.

@ohmayr

ohmayr commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on the #9598 changes. Waiting behaves the same; only how it's written changed:

  • Polling is an await loop instead of a recursive callback (no client-side delay; see the L573 reply).
  • The destination-schema lookup uses await instead of nested callbacks.
  • The empty-result fallback runs after await streamReadRows(...) instead of inside the gRPC 'end' handler, and the stream only ends in one place, so the _ended checks are gone.
  • Added a small pushBatches helper and used it for the first-page push loop from feat(bigquery): stream multi-page Arrow results from default job stream #9598 as well, so the two loops don't duplicate each other.

Implements unfinished job polling (jobComplete === false) in
createQueryArrowStream:
- Adds ArrowQuery (Query plus Arrow-only options, beta like
  createQueryArrowStream) with timeoutMs, and
  forwards it into buildQueryRequest_ so jobs.query receives the
  caller's server-side timeout.
- When jobs.query returns jobComplete === false:
  - Fails immediately with a timeout error if a non-zero timeoutMs was
    specified, matching BigQuery#query.
  - Otherwise polls job.getQueryResults with maxResults: 0 (so
    BigQuery blocks until completion without serializing any
    STRUCT_ENCODING rows) and then streams all Arrow batches from
    streams/_default at offset = 0.
  - streams/_default sends nothing for a result without rows, so the
    0-row RecordBatch reads its schema from the job's destination table
    via CreateReadSession (none for statements without a result table).
- Adds a system test that waits on a job with a picosecond TIMESTAMP(12)
  column, run when BIGQUERY_PICOSECOND_SUPPORT is set, and adds the
  Arrow system tests to the high-precision re-run in
  high_precision_timestamp.ts so CI runs it.
IReadSession is only used inside arrow.ts, so stop exporting it.
CreateReadSessionRequest stays exported for the unit tests and is now
documented as @Private, like the other test-only exports in arrow.ts.
@ohmayr
ohmayr force-pushed the feat/bigquery-arrow-job-wait branch from 8a9c7bd to 6fce0fd Compare October 20, 2026 20:43
Comment thread handwritten/bigquery/src/arrow.ts Outdated
// the job's current metadata instead of the response that created it.
const [metadata] = await job.getMetadata();
const destRef = metadata?.configuration?.query?.destinationTable;
if (!destRef) {

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.

We should also check if (ctx.stream.destroyed) in the event that the caller destroys the stream

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.

Added in b31bbd7: readDestinationSchema returns without creating a read session once the stream is destroyed, with a test.

Comment thread handwritten/bigquery/test/arrow.ts Outdated
}

/**
* Stubs `job.getQueryResults` with a callback-style `impl`, returning a

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.

These stubs should be changed to be promises to mimic the behavior of the actual implementation.

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.

Converted in b31bbd7: stubJobGetQueryResults now takes an async function. I left stubCreateJob and the bq.request stubs callback-based, because createQueryJob calls createJob with a callback internally and request is callback-based.

Return from readDestinationSchema without a CreateReadSession call if
the consumer destroyed the stream during jobs.get. Stub
job.getQueryResults with async functions in the unit tests, matching
the awaited promise API instead of adapting callbacks.

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

api: bigquery Issues related to the BigQuery API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants