hyperdb_api_core/client/grpc/executor.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! gRPC query executor with state machine for result fetching.
5//!
6//! This module implements the query execution state machine that handles
7//! the different transfer modes (SYNC, ASYNC, ADAPTIVE) and manages
8//! fetching results from the Hyper gRPC service.
9//!
10//! # Transfer Mode State Machines
11//!
12//! Each transfer mode follows a different path through the executor states:
13//!
14//! **SYNC** — simplest path, all data in one response:
15//! ```text
16//! ReadInitialResults ──(stream exhausted)──> Finished
17//! ```
18//! The `ExecuteQuery` RPC returns a server-streaming response containing the
19//! schema header followed by one or more binary/string data parts, then the
20//! stream closes. Subject to the server's 100-second SYNC timeout.
21//!
22//! **ASYNC** — decouples submission from fetching:
23//! ```text
24//! ReadInitialResults ──(QueryStatus: Running)──> RequestStatus
25//! ──> ReadStatus ──(Running)──> RequestStatus (poll loop)
26//! ──> ReadStatus ──(Finished)──> RequestResults
27//! ──> ReadResults ──(more chunks)──> RequestResults
28//! ──> ReadResults ──(all chunks)──> Finished
29//! ```
30//! The initial `ExecuteQuery` response contains only a `QueryStatus` with a
31//! server-assigned `query_id`. The client polls `GetQueryInfo` until
32//! `CompletionStatus::Finished`, then fetches result chunks via
33//! `GetQueryResult` using chunk IDs.
34//!
35//! **ADAPTIVE** (default, recommended) — hybrid of SYNC and ASYNC:
36//! ```text
37//! ReadInitialResults ──(data + Finished)──> Finished (small result)
38//! ReadInitialResults ──(data + Running)──> RequestStatus (large result)
39//! ──> ... (same as ASYNC from here)
40//! ```
41//! The first chunk of results is returned inline in the `ExecuteQuery`
42//! response. If the query completes within that first chunk, the path is
43//! identical to SYNC (no polling). If the result is larger, the response
44//! includes a `QueryStatus` with `Running` and the executor transitions
45//! to the ASYNC polling path for remaining chunks.
46
47use bytes::Bytes;
48use tonic::Streaming;
49use tracing::{debug, trace, warn};
50
51use crate::client::error::{Error, Result};
52
53use super::error::from_grpc_status;
54use super::proto::hyper_service::query_param::TransferMode;
55use super::proto::hyper_service::query_result::Result as QueryResultPayload;
56use super::proto::hyper_service::query_status::CompletionStatus;
57use super::proto::{
58 ExecuteQueryResponse, HyperServiceClient, QueryInfo, QueryInfoParam, QueryResult,
59 QueryResultParam, QueryStatus,
60};
61use super::result::{GrpcQueryResult, GrpcResultChunk};
62
63/// State of the query executor.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65enum ExecutorState {
66 /// Reading initial results from `ExecuteQuery` stream
67 ReadInitialResults,
68 /// Requesting query status via `GetQueryInfo`
69 RequestStatus,
70 /// Reading query status
71 ReadStatus,
72 /// Requesting result chunks via `GetQueryResult`
73 RequestResults,
74 /// Reading result chunks
75 ReadResults,
76 /// Query execution complete
77 Finished,
78}
79
80/// Executes gRPC queries and manages result fetching.
81///
82/// This mirrors the C++ `GrpcQueryExecutor` implementation, handling the
83/// different transfer modes and async result fetching.
84pub(crate) struct GrpcQueryExecutor<T> {
85 /// The gRPC client
86 client: HyperServiceClient<T>,
87 /// Metadata headers for requests
88 headers: Vec<(String, String)>,
89 /// Current state of the executor
90 state: ExecutorState,
91 /// Transfer mode being used
92 transfer_mode: TransferMode,
93 /// Stream for `ExecuteQuery` responses
94 execute_stream: Option<Streaming<ExecuteQueryResponse>>,
95 /// Stream for `GetQueryInfo` responses
96 query_info_stream: Option<Streaming<QueryInfo>>,
97 /// Stream for `GetQueryResult` responses
98 query_result_stream: Option<Streaming<QueryResult>>,
99 /// Query status from server
100 query_status: Option<QueryStatus>,
101 /// Query ID for async operations
102 query_id: Option<String>,
103 /// Monotonic label for the next `GrpcResultChunk` appended to
104 /// `self.result.chunks`. Bumped once per received `QueryResult` /
105 /// `BinaryPart` / `StringPart` message. This is *purely* a local
106 /// identifier for downstream consumers and has no relationship to
107 /// server-side chunk IDs.
108 next_local_chunk_id: u64,
109 /// Server-side chunk ID to request in the next `GetQueryResult` RPC,
110 /// and the value compared against `QueryStatus.chunk_count` to decide
111 /// when all chunks have been fetched.
112 ///
113 /// Bumped by exactly 1 after each `GetQueryResult` stream is fully
114 /// drained — regardless of how many `QueryResult` messages that stream
115 /// contained. Mirrors `nextChunkId_` in the C++ `GrpcQueryExecutor`.
116 ///
117 /// Initial value depends on transfer mode:
118 /// - `ASYNC`: `0` — no chunks delivered inline.
119 /// - `ADAPTIVE`: `1` — server sends chunk 0 inline on `ExecuteQuery`.
120 /// - `SYNC`: unused (all data arrives via `ExecuteQuery`).
121 next_server_chunk_id: u64,
122 /// Result being built
123 result: GrpcQueryResult,
124}
125
126impl<T> GrpcQueryExecutor<T>
127where
128 T: tonic::client::GrpcService<tonic::body::Body> + Clone + Send + 'static,
129 T::ResponseBody: tonic::codegen::Body<Data = tonic::codegen::Bytes> + Send + 'static,
130 <T::ResponseBody as tonic::codegen::Body>::Error:
131 Into<tonic::codegen::StdError> + Send + 'static,
132 T::Future: Send,
133{
134 /// Creates a new query executor.
135 pub(crate) fn new(
136 client: HyperServiceClient<T>,
137 headers: Vec<(String, String)>,
138 transfer_mode: TransferMode,
139 ) -> Self {
140 // Match the C++ `GrpcQueryExecutor` constructor: ADAPTIVE delivers
141 // server-side chunk 0 inline on the `ExecuteQuery` response, so the
142 // first chunk we need to request via `GetQueryResult` is chunk 1.
143 // ASYNC doesn't deliver any chunks inline, so we start at 0. SYNC
144 // never reaches the GetQueryResult state machine.
145 let next_server_chunk_id = match transfer_mode {
146 TransferMode::Adaptive => 1,
147 _ => 0,
148 };
149 GrpcQueryExecutor {
150 client,
151 headers,
152 state: ExecutorState::ReadInitialResults,
153 transfer_mode,
154 execute_stream: None,
155 query_info_stream: None,
156 query_result_stream: None,
157 query_status: None,
158 query_id: None,
159 next_local_chunk_id: 0,
160 next_server_chunk_id,
161 result: GrpcQueryResult::new(),
162 }
163 }
164
165 /// Starts query execution.
166 pub(crate) async fn execute(&mut self, query: super::proto::QueryParam) -> Result<()> {
167 debug!(query = %query.query, transfer_mode = ?self.transfer_mode, "Executing gRPC query");
168
169 // Create request with metadata
170 let mut request = tonic::Request::new(query);
171 for (key, value) in &self.headers {
172 if let (Ok(key), Ok(value)) = (
173 key.parse::<tonic::metadata::MetadataKey<_>>(),
174 value.parse(),
175 ) {
176 request.metadata_mut().insert(key, value);
177 }
178 }
179
180 // Execute the query
181 let response = self
182 .client
183 .execute_query(request)
184 .await
185 .map_err(from_grpc_status)?;
186
187 self.execute_stream = Some(response.into_inner());
188 self.state = ExecutorState::ReadInitialResults;
189
190 Ok(())
191 }
192
193 /// Gets the next result.
194 ///
195 /// Returns `None` when all results have been consumed.
196 pub(crate) async fn next_result(&mut self) -> Result<Option<GrpcQueryResult>> {
197 loop {
198 trace!(state = ?self.state, "Query executor state");
199
200 match self.state {
201 ExecutorState::ReadInitialResults => {
202 self.read_initial_results().await?;
203 }
204 ExecutorState::RequestStatus => {
205 self.request_status().await?;
206 }
207 ExecutorState::ReadStatus => {
208 self.read_status().await?;
209 }
210 ExecutorState::RequestResults => {
211 self.request_results().await?;
212 }
213 ExecutorState::ReadResults => {
214 self.read_results().await?;
215 }
216 ExecutorState::Finished => {
217 self.result.is_complete = true;
218 // Return the accumulated result
219 return Ok(Some(std::mem::take(&mut self.result)));
220 }
221 }
222
223 // Yield back to the caller as soon as we either have some
224 // chunks to deliver or have reached the terminal state.
225 // Streaming out of `ReadInitialResults` keeps peak memory
226 // bounded for SYNC/inline paths (otherwise we would buffer
227 // the entire ExecuteQuery stream before the first yield).
228 if self.state == ExecutorState::Finished || !self.result.chunks.is_empty() {
229 break;
230 }
231 }
232
233 if self.result.is_complete || !self.result.chunks.is_empty() {
234 Ok(Some(std::mem::take(&mut self.result)))
235 } else {
236 Ok(None)
237 }
238 }
239
240 /// Reads one message from the `ExecuteQuery` stream.
241 ///
242 /// We deliberately do **not** transition state the moment we see a
243 /// `QueryStatus` — under `ADAPTIVE` the server delivers the whole of
244 /// chunk 0 inline *followed by* a `QueryStatus(Running)` and then
245 /// closes the stream, so bailing early would silently drop the tail
246 /// of chunk 0. Only the server-side close (a `None` message) decides
247 /// where to go next. This mirrors the C++ `GrpcQueryExecutor::
248 /// READ_INITIAL_RESULTS` loop, which reads until `Read()` returns
249 /// false and only then inspects `queryStatus_` to pick the next
250 /// state.
251 ///
252 /// One message per call keeps memory bounded: the outer `next_result`
253 /// loop yields accumulated chunks back to the caller as they arrive
254 /// instead of buffering the entire inline response.
255 async fn read_initial_results(&mut self) -> Result<()> {
256 let response = {
257 let stream = self
258 .execute_stream
259 .as_mut()
260 .ok_or_else(|| Error::protocol("ExecuteQuery stream not initialized"))?;
261 stream.message().await.map_err(from_grpc_status)?
262 };
263
264 match response {
265 Some(response) => {
266 self.process_execute_response(response)?;
267 }
268 None => {
269 // Server closed the stream. Where we go next is determined
270 // purely by transfer mode, mirroring the C++
271 // `GrpcQueryExecutor`:
272 // - SYNC: all data is inline, we're done.
273 // - ASYNC / ADAPTIVE: go to the GetQueryInfo/
274 // GetQueryResult state machine. A `QueryStatus` of
275 // `Finished` here means *query execution* is finished,
276 // NOT that all chunks have been streamed — server-side
277 // chunks 1..N still need to be fetched for ADAPTIVE
278 // (and 0..N for ASYNC).
279 match self.transfer_mode {
280 TransferMode::Sync | TransferMode::Unspecified => {
281 debug!(
282 query_id = ?self.query_id,
283 "ExecuteQuery stream closed; SYNC mode complete",
284 );
285 self.state = ExecutorState::Finished;
286 }
287 TransferMode::Async | TransferMode::Adaptive => {
288 debug!(
289 query_id = ?self.query_id,
290 mode = ?self.transfer_mode,
291 next_chunk = self.next_server_chunk_id,
292 "ExecuteQuery stream closed; fetching remaining chunks",
293 );
294 self.state = ExecutorState::RequestStatus;
295 }
296 }
297 }
298 }
299
300 Ok(())
301 }
302
303 /// Processes an `ExecuteQueryResponse` message.
304 fn process_execute_response(&mut self, response: ExecuteQueryResponse) -> Result<()> {
305 use super::proto::hyper_service::execute_query_response::Result as ResponsePayload;
306 use super::proto::hyper_service::query_info::Content as QueryInfoContent;
307 use super::proto::hyper_service::query_result_header::Header;
308
309 match response.result {
310 Some(ResponsePayload::Header(header)) => match header.header {
311 Some(Header::Schema(schema)) => {
312 debug!(columns = schema.columns.len(), "Received schema");
313 self.result.schema = Some(schema);
314 }
315 Some(Header::Command(cmd)) => {
316 use super::proto::hyper_service::query_command_ok::CommandReturn;
317 let rows = match cmd.command_return {
318 Some(CommandReturn::AffectedRows(n)) => Some(n),
319 Some(CommandReturn::Empty(())) | None => None,
320 };
321 debug!(rows_affected = ?rows, "Command OK");
322 self.result.rows_affected = rows;
323 self.state = ExecutorState::Finished;
324 }
325 None => {
326 warn!("Received empty QueryResultHeader");
327 }
328 },
329 Some(ResponsePayload::BinaryPart(data)) => {
330 debug!(bytes = data.data.len(), "Received binary result part");
331 let chunk = GrpcResultChunk::new(self.next_local_chunk_id, data.data);
332 self.next_local_chunk_id += 1;
333 self.result.chunks.push_back(chunk);
334 }
335 Some(ResponsePayload::StringPart(data)) => {
336 debug!(len = data.data.len(), "Received string result part");
337 let chunk = GrpcResultChunk::new(
338 self.next_local_chunk_id,
339 Bytes::from(data.data.into_bytes()),
340 );
341 self.next_local_chunk_id += 1;
342 self.result.chunks.push_back(chunk);
343 }
344 Some(ResponsePayload::QueryInfo(info)) => {
345 match info.content {
346 Some(QueryInfoContent::QueryStatus(status)) => {
347 self.process_query_status(status);
348 }
349 Some(QueryInfoContent::BinarySchema(data)) => {
350 debug!(bytes = data.data.len(), "Received binary schema");
351 // Schema in binary form - store as a chunk
352 let chunk = GrpcResultChunk::new(self.next_local_chunk_id, data.data);
353 self.next_local_chunk_id += 1;
354 self.result.chunks.push_back(chunk);
355 }
356 Some(QueryInfoContent::StringSchema(data)) => {
357 debug!(len = data.data.len(), "Received string schema");
358 // Schema in string form - for JSON format
359 let chunk = GrpcResultChunk::new(
360 self.next_local_chunk_id,
361 Bytes::from(data.data.into_bytes()),
362 );
363 self.next_local_chunk_id += 1;
364 self.result.chunks.push_back(chunk);
365 }
366 None => {}
367 }
368 }
369 Some(ResponsePayload::QueryResult(query_result)) => {
370 self.process_query_result(query_result)?;
371 }
372 None => {
373 warn!("Received empty ExecuteQueryResponse");
374 }
375 }
376 Ok(())
377 }
378
379 #[expect(
380 clippy::unnecessary_wraps,
381 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
382 )]
383 /// Processes a `QueryResult` message.
384 ///
385 /// Note: a single `GetQueryResult` RPC returns *multiple* `QueryResult`
386 /// messages for one server-side chunk (schema + N binary parts). Only
387 /// the local `GrpcResultChunk` label is bumped here — the server-side
388 /// chunk ID (`next_server_chunk_id`) is advanced by exactly 1 after the
389 /// RPC stream has been fully drained; see `read_results`.
390 fn process_query_result(&mut self, result: QueryResult) -> Result<()> {
391 // Extract data payload
392 if let Some(payload) = result.result {
393 let chunk = match payload {
394 QueryResultPayload::BinaryPart(data) => {
395 debug!(bytes = data.data.len(), "Received binary result chunk");
396 GrpcResultChunk::new(self.next_local_chunk_id, data.data)
397 }
398 QueryResultPayload::StringPart(data) => {
399 // Convert string data to bytes
400 debug!(len = data.data.len(), "Received string result chunk");
401 GrpcResultChunk::new(
402 self.next_local_chunk_id,
403 Bytes::from(data.data.into_bytes()),
404 )
405 }
406 };
407 self.next_local_chunk_id += 1;
408 self.result.chunks.push_back(chunk);
409 }
410
411 Ok(())
412 }
413
414 /// Processes a `QueryStatus` message.
415 fn process_query_status(&mut self, status: QueryStatus) {
416 debug!(
417 query_id = %status.query_id,
418 completion_status = ?CompletionStatus::try_from(status.completion_status),
419 "Received query status"
420 );
421
422 self.query_id = Some(status.query_id.clone());
423 self.result.query_id = Some(status.query_id.clone());
424 self.query_status = Some(status);
425 }
426
427 /// Requests query status via `GetQueryInfo`.
428 async fn request_status(&mut self) -> Result<()> {
429 let query_id = self
430 .query_id
431 .clone()
432 .ok_or_else(|| Error::protocol("No query ID for status request"))?;
433
434 debug!(query_id = %query_id, "Requesting query status");
435
436 let param = QueryInfoParam {
437 query_id: query_id.clone(),
438 streaming: true, // Enable streaming to get continuous updates
439 schema_output_format: 0, // OUTPUT_FORMAT_UNSPECIFIED - we don't need schema here
440 };
441
442 let mut request = tonic::Request::new(param);
443 // Add the required x-hyperdb-query-id header
444 if let Ok(value) = query_id.parse() {
445 request.metadata_mut().insert("x-hyperdb-query-id", value);
446 }
447 for (key, value) in &self.headers {
448 if let (Ok(key), Ok(value)) = (
449 key.parse::<tonic::metadata::MetadataKey<_>>(),
450 value.parse(),
451 ) {
452 request.metadata_mut().insert(key, value);
453 }
454 }
455
456 let response = self
457 .client
458 .get_query_info(request)
459 .await
460 .map_err(from_grpc_status)?;
461
462 self.query_info_stream = Some(response.into_inner());
463 self.state = ExecutorState::ReadStatus;
464 Ok(())
465 }
466
467 /// Reads query status from `GetQueryInfo` stream.
468 async fn read_status(&mut self) -> Result<()> {
469 use super::proto::hyper_service::query_info::Content as QueryInfoContent;
470
471 let stream = self
472 .query_info_stream
473 .as_mut()
474 .ok_or_else(|| Error::protocol("QueryInfo stream not initialized"))?;
475
476 if let Some(info) = stream.message().await.map_err(from_grpc_status)? {
477 match info.content {
478 Some(QueryInfoContent::QueryStatus(status)) => {
479 self.process_query_status(status.clone());
480
481 match CompletionStatus::try_from(status.completion_status)
482 .unwrap_or(CompletionStatus::RunningOrUnspecified)
483 {
484 CompletionStatus::Finished | CompletionStatus::ResultsProduced => {
485 debug!("Query finished, requesting results");
486 self.state = ExecutorState::RequestResults;
487 }
488 CompletionStatus::RunningOrUnspecified => {
489 // Keep polling
490 self.state = ExecutorState::RequestStatus;
491 }
492 }
493 }
494 Some(QueryInfoContent::BinarySchema(_) | QueryInfoContent::StringSchema(_)) => {
495 // Schema received - just continue polling
496 self.state = ExecutorState::RequestStatus;
497 }
498 None => {
499 self.state = ExecutorState::RequestStatus;
500 }
501 }
502 }
503
504 Ok(())
505 }
506
507 /// Requests result chunks via `GetQueryResult`.
508 async fn request_results(&mut self) -> Result<()> {
509 use super::proto::hyper_service::query_result_param::RequestedData;
510
511 // Short-circuit when we've already fetched every chunk the server
512 // reported. This matches C++ `GrpcQueryExecutor`: don't emit a
513 // `GetQueryResult(chunk_id=k)` when `k >= chunk_count`. Otherwise
514 // the server returns error code 22023 ("chunk id out of range").
515 //
516 // Common case: ADAPTIVE with a small result that fit entirely in
517 // chunk 0 (delivered inline). `next_server_chunk_id` starts at 1
518 // and `chunk_count` is 1, so we skip straight to Finished.
519 if let Some(ref status) = self.query_status
520 && status.chunk_count > 0
521 && self.next_server_chunk_id >= status.chunk_count
522 {
523 debug!(
524 total_chunks = status.chunk_count,
525 next_chunk = self.next_server_chunk_id,
526 "No more chunks to fetch",
527 );
528 self.state = ExecutorState::Finished;
529 return Ok(());
530 }
531
532 let query_id = self
533 .query_id
534 .clone()
535 .ok_or_else(|| Error::protocol("No query ID for result request"))?;
536
537 debug!(
538 query_id = %query_id,
539 chunk_id = self.next_server_chunk_id,
540 "Requesting result chunks"
541 );
542
543 let param = QueryResultParam {
544 query_id: query_id.clone(),
545 output_format: super::proto::OutputFormat::ArrowIpc.into(),
546 requested_data: Some(RequestedData::ChunkId(self.next_server_chunk_id)),
547 // The schema is delivered inline on the initial `ExecuteQuery`
548 // stream for both ASYNC and ADAPTIVE (hyperd sends a
549 // `QueryInfo.binary_schema` message before closing that
550 // stream). Asking for it again on every `GetQueryResult`
551 // would emit extra schema frames that confuse an incremental
552 // Arrow IPC decoder. C++ `GrpcQueryExecutor` also sets this
553 // to `true`.
554 omit_schema: true,
555 };
556
557 let mut request = tonic::Request::new(param);
558 // Add the required x-hyperdb-query-id header
559 if let Ok(value) = query_id.parse() {
560 request.metadata_mut().insert("x-hyperdb-query-id", value);
561 }
562 for (key, value) in &self.headers {
563 if let (Ok(key), Ok(value)) = (
564 key.parse::<tonic::metadata::MetadataKey<_>>(),
565 value.parse(),
566 ) {
567 request.metadata_mut().insert(key, value);
568 }
569 }
570
571 let response = self
572 .client
573 .get_query_result(request)
574 .await
575 .map_err(from_grpc_status)?;
576
577 self.query_result_stream = Some(response.into_inner());
578 self.state = ExecutorState::ReadResults;
579 Ok(())
580 }
581
582 /// Reads result chunks from `GetQueryResult` stream.
583 async fn read_results(&mut self) -> Result<()> {
584 loop {
585 let result = {
586 let stream = self
587 .query_result_stream
588 .as_mut()
589 .ok_or_else(|| Error::protocol("QueryResult stream not initialized"))?;
590 stream.message().await.map_err(from_grpc_status)?
591 };
592
593 match result {
594 Some(result) => {
595 self.process_query_result(result)?;
596 }
597 None => break,
598 }
599 }
600
601 // One GetQueryResult RPC corresponds to exactly one server-side
602 // chunk, regardless of how many QueryResult messages it contained
603 // on the wire. Advance by 1 (matches C++ GrpcQueryExecutor).
604 self.next_server_chunk_id += 1;
605
606 // Check if there are more chunks
607 if let Some(ref status) = self.query_status {
608 let total_chunks = status.chunk_count;
609 if self.next_server_chunk_id >= total_chunks {
610 debug!(total_chunks, "All chunks received");
611 self.state = ExecutorState::Finished;
612 } else {
613 debug!(
614 next_chunk = self.next_server_chunk_id,
615 total_chunks, "More chunks available"
616 );
617 self.state = ExecutorState::RequestResults;
618 }
619 } else {
620 self.state = ExecutorState::Finished;
621 }
622
623 Ok(())
624 }
625}
626
627// ============================================================================
628// Streaming chunk producer
629// ============================================================================
630
631/// Streaming producer of Arrow IPC byte chunks from a gRPC query.
632///
633/// Unlike [`GrpcClient::execute_query`][`super::client::GrpcClient::execute_query`],
634/// which drains every result chunk into a single [`GrpcQueryResult`] before
635/// returning, this type yields one [`Bytes`] chunk at a time. The caller can
636/// decode each chunk (e.g. via `arrow_ipc::reader::StreamDecoder`) and drop
637/// it before fetching the next, so memory stays bounded by roughly one
638/// message (capped at the tonic `max_decoding_message_size`, default 64 MB)
639/// regardless of total result size.
640///
641/// Built by [`GrpcClient::execute_query_stream`][`super::client::GrpcClient::execute_query_stream`]
642/// and the `AuthenticatedGrpcClient` variant.
643pub struct GrpcChunkStream {
644 executor: GrpcQueryExecutor<tonic::transport::Channel>,
645 pending: std::collections::VecDeque<bytes::Bytes>,
646 schema: Option<super::proto::QueryResultSchema>,
647 query_id: Option<String>,
648 rows_affected: Option<u64>,
649 done: bool,
650}
651
652impl std::fmt::Debug for GrpcChunkStream {
653 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
654 f.debug_struct("GrpcChunkStream")
655 .field("pending_chunks", &self.pending.len())
656 .field("query_id", &self.query_id)
657 .field("rows_affected", &self.rows_affected)
658 .field("done", &self.done)
659 .finish_non_exhaustive()
660 }
661}
662
663impl GrpcChunkStream {
664 pub(crate) fn new(executor: GrpcQueryExecutor<tonic::transport::Channel>) -> Self {
665 GrpcChunkStream {
666 executor,
667 pending: std::collections::VecDeque::new(),
668 schema: None,
669 query_id: None,
670 rows_affected: None,
671 done: false,
672 }
673 }
674
675 /// Returns the next Arrow IPC byte chunk from the stream, or `None` when
676 /// the server has signalled that the stream is complete.
677 ///
678 /// # Errors
679 ///
680 /// Propagates any error from the underlying executor's
681 /// `next_result` call — typically [`tonic::Status`] errors wrapped
682 /// as [`Error`] (server-side query failure, auth expiry, or
683 /// transport-level gRPC errors).
684 pub async fn next_chunk(&mut self) -> Result<Option<bytes::Bytes>> {
685 loop {
686 if let Some(b) = self.pending.pop_front() {
687 return Ok(Some(b));
688 }
689 if self.done {
690 return Ok(None);
691 }
692 match self.executor.next_result().await? {
693 Some(mut partial) => {
694 if self.schema.is_none() {
695 self.schema = partial.schema.take();
696 }
697 if self.query_id.is_none() {
698 self.query_id = partial.query_id.take();
699 }
700 if partial.rows_affected.is_some() {
701 self.rows_affected = partial.rows_affected;
702 }
703 while let Some(chunk) = partial.take_chunk() {
704 self.pending.push_back(chunk.data);
705 }
706 if partial.is_complete {
707 self.done = true;
708 }
709 }
710 None => {
711 self.done = true;
712 }
713 }
714 }
715 }
716
717 /// Returns the schema reported by the server for this query, if one has
718 /// been received yet.
719 ///
720 /// The schema is typically delivered as the first message on the stream,
721 /// so it is usually available after the first `next_chunk()` call.
722 pub fn schema(&self) -> Option<&super::proto::QueryResultSchema> {
723 self.schema.as_ref()
724 }
725
726 /// Returns the server-assigned query ID, if one has been received.
727 pub fn query_id(&self) -> Option<&str> {
728 self.query_id.as_deref()
729 }
730
731 /// Returns the affected row count for DML queries, if reported.
732 pub fn rows_affected(&self) -> Option<u64> {
733 self.rows_affected
734 }
735}