Skip to main content

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}