Skip to content

Commit 21d547f

Browse files
committed
feat(bigquery): add ArrowQueryPageFetcher for Arrow query result pagination
1 parent 4d1e4ee commit 21d547f

2 files changed

Lines changed: 515 additions & 0 deletions

File tree

‎java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.google.api.gax.core.NoCredentialsProvider;
2828
import com.google.api.gax.paging.Page;
2929
import com.google.api.gax.rpc.HeaderProvider;
30+
import com.google.api.gax.rpc.ServerStream;
3031
import com.google.api.services.bigquery.model.ErrorProto;
3132
import com.google.api.services.bigquery.model.GetQueryResultsResponse;
3233
import com.google.api.services.bigquery.model.ProjectList;
@@ -53,6 +54,8 @@
5354
import com.google.cloud.bigquery.storage.v1.BigQueryReadSettings;
5455
import com.google.cloud.bigquery.storage.v1.CreateReadSessionRequest;
5556
import com.google.cloud.bigquery.storage.v1.DataFormat;
57+
import com.google.cloud.bigquery.storage.v1.ReadRowsRequest;
58+
import com.google.cloud.bigquery.storage.v1.ReadRowsResponse;
5659
import com.google.cloud.bigquery.storage.v1.ReadSession;
5760
import com.google.common.annotations.VisibleForTesting;
5861
import com.google.common.base.Function;
@@ -70,10 +73,13 @@
7073
import io.opentelemetry.context.Scope;
7174
import java.io.IOException;
7275
import java.net.URI;
76+
import java.util.ArrayDeque;
7377
import java.util.ArrayList;
7478
import java.util.Collections;
79+
import java.util.Iterator;
7580
import java.util.List;
7681
import java.util.Map;
82+
import java.util.Queue;
7783
import java.util.concurrent.Callable;
7884
import java.util.concurrent.locks.ReentrantLock;
7985
import java.util.regex.Matcher;
@@ -280,9 +286,177 @@ public Page<FieldValueList> getNextPage() {
280286
}
281287
}
282288

289+
/**
290+
* NextPageFetcher implementation for queries returning results in Arrow format. Reads subsequent
291+
* pages from the job's default gRPC storage read stream.
292+
*
293+
* <p>Note: Neither {@link Page} nor {@link TableResult} implements {@link AutoCloseable}. The
294+
* underlying gRPC stream is automatically canceled and resources released when iteration reaches
295+
* the end (or maximum results requested) or when an error occurs. Callers that do not iterate to
296+
* completion rely on server-side stream timeouts and garbage collection to release stream
297+
* resources.
298+
*/
299+
static class ArrowQueryPageFetcher implements NextPageFetcher<FieldValueList> {
300+
private static final long serialVersionUID = 1L;
301+
private static final long DEFAULT_PAGE_SIZE = 10000L;
302+
303+
private final JobId jobId;
304+
private final Schema schema;
305+
private final byte[] arrowSchemaBytes;
306+
private final BigQueryOptions serviceOptions;
307+
private final long maxResults;
308+
private final Map<BigQueryRpc.Option, ?> optionsMap;
309+
310+
private transient org.apache.arrow.vector.types.pojo.Schema arrowSchemaPojo;
311+
private transient BigQueryReadClient bqReadClient;
312+
private transient ServerStream<ReadRowsResponse> stream;
313+
private transient Iterator<ReadRowsResponse> streamIterator;
314+
private final Queue<FieldValueList> buffer = new ArrayDeque<>();
315+
private long totalRowsReturned = 0L;
316+
private boolean streamClosed = false;
317+
318+
ArrowQueryPageFetcher(
319+
JobId jobId,
320+
Schema schema,
321+
byte[] arrowSchemaBytes,
322+
org.apache.arrow.vector.types.pojo.Schema arrowSchemaPojo,
323+
BigQueryOptions serviceOptions,
324+
long initialRowOffset,
325+
Long maxResults,
326+
Map<BigQueryRpc.Option, ?> optionsMap) {
327+
this.jobId = jobId;
328+
this.schema = schema;
329+
this.arrowSchemaBytes = arrowSchemaBytes;
330+
this.arrowSchemaPojo = arrowSchemaPojo;
331+
this.serviceOptions = serviceOptions;
332+
this.totalRowsReturned = initialRowOffset;
333+
this.maxResults = maxResults != null ? maxResults : Long.MAX_VALUE;
334+
this.optionsMap = optionsMap;
335+
}
336+
337+
@Override
338+
public Page<FieldValueList> getNextPage() {
339+
if (streamClosed || totalRowsReturned >= maxResults) {
340+
closeClient();
341+
return null;
342+
}
343+
344+
Number optionPageSize =
345+
optionsMap != null ? (Number) optionsMap.get(BigQueryRpc.Option.MAX_RESULTS) : null;
346+
long pageSize =
347+
optionPageSize != null && optionPageSize.longValue() > 0
348+
? optionPageSize.longValue()
349+
: DEFAULT_PAGE_SIZE;
350+
List<FieldValueList> rowBatch = new ArrayList<>((int) Math.min(pageSize, 10000L));
351+
352+
try {
353+
String location =
354+
jobId.getLocation() != null ? jobId.getLocation() : serviceOptions.getLocation();
355+
if (location == null) {
356+
throw new BigQueryException(
357+
0, "Location must be specified to read Arrow rows from storage stream");
358+
}
359+
360+
if (bqReadClient == null) {
361+
BigQuery service = serviceOptions.getService();
362+
if (service instanceof BigQueryImpl) {
363+
BigQueryImpl impl = (BigQueryImpl) service;
364+
bqReadClient = impl.getBigQueryReadClient();
365+
} else {
366+
throw new IllegalStateException(
367+
"Arrow query result pagination requires an instance of BigQueryImpl to manage BigQueryReadClient lifecycle");
368+
}
369+
}
370+
371+
if (streamIterator == null) {
372+
String streamName =
373+
String.format(
374+
"projects/%s/locations/%s/jobs/%s/streams/_default",
375+
jobId.getProject() != null ? jobId.getProject() : serviceOptions.getProjectId(),
376+
location,
377+
jobId.getJob());
378+
379+
ReadRowsRequest readRowsRequest =
380+
ReadRowsRequest.newBuilder()
381+
.setReadStream(streamName)
382+
.setOffset(totalRowsReturned)
383+
.build();
384+
385+
stream = bqReadClient.readRowsCallable().call(readRowsRequest);
386+
streamIterator = stream.iterator();
387+
}
388+
389+
if (arrowSchemaPojo == null && arrowSchemaBytes != null) {
390+
arrowSchemaPojo = ArrowDeserializer.deserializeSchema(arrowSchemaBytes);
391+
}
392+
393+
boolean hasMore =
394+
ArrowDeserializer.loadArrowRows(
395+
streamIterator,
396+
arrowSchemaPojo,
397+
schema,
398+
rowBatch,
399+
buffer,
400+
pageSize,
401+
totalRowsReturned,
402+
maxResults);
403+
404+
if (rowBatch.isEmpty()) {
405+
streamClosed = true;
406+
closeClient();
407+
return null;
408+
}
409+
410+
totalRowsReturned += rowBatch.size();
411+
412+
String nextPageToken = null;
413+
if (hasMore && totalRowsReturned < maxResults) {
414+
nextPageToken = String.valueOf(totalRowsReturned);
415+
} else {
416+
streamClosed = true;
417+
closeClient();
418+
}
419+
420+
return new PageImpl<>(this, nextPageToken, rowBatch);
421+
422+
} catch (BigQueryException e) {
423+
streamClosed = true;
424+
closeClient();
425+
throw e;
426+
} catch (Exception e) {
427+
streamClosed = true;
428+
closeClient();
429+
throw new BigQueryException(0, "Failed to read Arrow rows from storage stream", e);
430+
}
431+
}
432+
433+
private void closeClient() {
434+
if (stream != null) {
435+
try {
436+
stream.cancel();
437+
} catch (Exception e) {
438+
// Ignore cancellation exceptions
439+
}
440+
}
441+
bqReadClient = null;
442+
streamIterator = null;
443+
stream = null;
444+
}
445+
}
446+
283447
private final ReentrantLock readClientLock = new ReentrantLock();
284448
private transient BigQueryReadClient bqReadClient;
285449

450+
@VisibleForTesting
451+
void setBigQueryReadClient(BigQueryReadClient client) {
452+
readClientLock.lock();
453+
try {
454+
this.bqReadClient = client;
455+
} finally {
456+
readClientLock.unlock();
457+
}
458+
}
459+
286460
/**
287461
* Lazily creates or retrieves the shared {@link BigQueryReadClient} instance used for streaming
288462
* Arrow query results, reusing credentials and channel configuration from this {@link

0 commit comments

Comments
 (0)