Skip to main content

hyperdb_api_core/client/grpc/
client.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! gRPC client for Hyper database.
5//!
6//! This module provides the [`GrpcClient`] struct for executing queries against
7//! Hyper servers via gRPC.
8
9use std::sync::Arc;
10
11use tonic::transport::{Channel, Endpoint};
12use tracing::{debug, info, warn};
13
14use crate::client::error::{Error, Result};
15
16use super::config::GrpcConfig;
17use super::error::from_grpc_status;
18use super::executor::{GrpcChunkStream, GrpcQueryExecutor};
19use super::params::{ParameterStyle, QueryParameters};
20use super::proto::hyper_service::query_param::TransferMode;
21use super::proto::{
22    AttachedDatabase, CancelQueryParam, HyperServiceClient, OutputFormat, QueryParam,
23};
24use super::result::GrpcQueryResult;
25
26/// Async gRPC client for Hyper database.
27///
28/// `GrpcClient` provides query-only access to Hyper databases via gRPC.
29/// Results are returned in Apache Arrow IPC format.
30///
31/// gRPC transport is always available - no feature flags required.
32///
33/// # Limitations
34///
35/// The gRPC interface is **read-only**:
36/// - Only SELECT queries are supported
37/// - No INSERT, UPDATE, DELETE, or DDL operations
38/// - No COPY protocol for bulk data insertion
39///
40/// For write operations, use the standard TCP [`Client`](crate::client::Client).
41///
42/// # Example
43///
44/// ```no_run
45/// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig};
46///
47/// #[tokio::main]
48/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
49///     let config = GrpcConfig::new("http://localhost:7484")
50///         .database("my_database.hyper");
51///
52///     let mut client = GrpcClient::connect(config).await?;
53///
54///     // Execute a query
55///     let result = client.execute_query("SELECT * FROM users").await?;
56///     let arrow_data = result.arrow_data();
57///
58///     // Process arrow_data with arrow crate...
59///
60///     client.close().await?;
61///     Ok(())
62/// }
63/// ```
64#[derive(Debug)]
65pub struct GrpcClient {
66    /// The underlying gRPC channel
67    channel: Channel,
68    /// Client configuration
69    config: GrpcConfig,
70}
71
72impl GrpcClient {
73    /// Connects to a Hyper server via gRPC.
74    ///
75    /// # Example
76    ///
77    /// ```no_run
78    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig};
79    ///
80    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
81    /// let config = GrpcConfig::new("http://localhost:7484")
82    ///     .database("test.hyper");
83    ///
84    /// let client = GrpcClient::connect(config).await?;
85    /// # Ok(())
86    /// # }
87    /// ```
88    ///
89    /// # Errors
90    ///
91    /// - Returns [`Error::Config`] if `config.endpoint` is not a
92    ///   well-formed URI, or if TLS configuration fails.
93    /// - Returns [`Error::Connection`] if the gRPC transport
94    ///   cannot establish a channel to the endpoint.
95    pub async fn connect(config: GrpcConfig) -> Result<Self> {
96        info!(endpoint = %config.endpoint, "Connecting to Hyper via gRPC");
97
98        let endpoint = Endpoint::from_shared(config.endpoint.clone())
99            .map_err(|e| Error::config(format!("Invalid gRPC endpoint: {e}")))?;
100
101        // Configure timeouts
102        let endpoint = endpoint
103            .connect_timeout(config.connect_timeout)
104            .timeout(config.request_timeout);
105
106        // Configure TLS if needed
107        let endpoint = if config.use_tls {
108            // Use system root certificates for TLS validation
109            let tls_config = tonic::transport::ClientTlsConfig::new().with_enabled_roots();
110
111            endpoint
112                .tls_config(tls_config)
113                .map_err(|e| Error::config(format!("TLS configuration error: {e}")))?
114        } else {
115            endpoint
116        };
117
118        // Connect
119        let channel = endpoint.connect().await.map_err(|e| {
120            debug!("gRPC connection error details: {:?}", e);
121            Error::connection(format!(
122                "Failed to connect to gRPC endpoint: {e} (details: {e:?})"
123            ))
124        })?;
125
126        debug!("gRPC channel established");
127
128        Ok(GrpcClient { channel, config })
129    }
130
131    /// Returns the underlying gRPC channel.
132    ///
133    /// This can be used for advanced use cases like channel cloning or
134    /// direct stub access.
135    #[must_use]
136    pub fn channel(&self) -> &Channel {
137        &self.channel
138    }
139
140    /// Returns the client configuration.
141    pub fn config(&self) -> &GrpcConfig {
142        &self.config
143    }
144
145    /// Executes a SQL query and returns the result.
146    ///
147    /// Results are returned in Apache Arrow IPC format. Use the `arrow_data()`
148    /// method on the result to get the raw Arrow bytes.
149    ///
150    /// # Example
151    ///
152    /// ```no_run
153    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig};
154    ///
155    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
156    /// # let config = GrpcConfig::new("http://localhost:7484");
157    /// # let mut client = GrpcClient::connect(config).await?;
158    /// let result = client.execute_query("SELECT * FROM users LIMIT 10").await?;
159    /// let arrow_bytes = result.arrow_data();
160    /// # Ok(())
161    /// # }
162    /// ```
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if:
167    /// - The query syntax is invalid
168    /// - The referenced tables/columns don't exist
169    /// - A non-SELECT query is executed (gRPC is read-only)
170    /// - The connection is lost
171    pub async fn execute_query(&mut self, sql: &str) -> Result<GrpcQueryResult> {
172        self.execute_query_with_options(sql, OutputFormat::ArrowIpc, self.config.transfer_mode)
173            .await
174    }
175
176    /// Executes a query and returns raw Arrow IPC bytes.
177    ///
178    /// This is a convenience method that extracts the Arrow data from the result.
179    ///
180    /// # Example
181    ///
182    /// ```no_run
183    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig};
184    ///
185    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
186    /// # let config = GrpcConfig::new("http://localhost:7484");
187    /// # let mut client = GrpcClient::connect(config).await?;
188    /// let arrow_bytes = client.execute_query_to_arrow("SELECT * FROM users").await?;
189    /// // Parse with arrow crate...
190    /// # Ok(())
191    /// # }
192    /// ```
193    ///
194    /// # Errors
195    ///
196    /// Same failure modes as [`Self::execute_query`] (invalid SQL,
197    /// missing tables/columns, non-SELECT mutation attempts, or
198    /// connection loss).
199    pub async fn execute_query_to_arrow(&mut self, sql: &str) -> Result<bytes::Bytes> {
200        let result = self.execute_query(sql).await?;
201        Ok(result.into_arrow_data())
202    }
203
204    /// Executes a parameterized SQL query.
205    ///
206    /// This provides SQL injection prevention and type safety by separating
207    /// the query from its parameters.
208    ///
209    /// # Arguments
210    ///
211    /// * `sql` - SQL query with parameter placeholders
212    /// * `params` - Query parameters (JSON or Arrow encoded)
213    /// * `style` - Parameter style used in the query
214    ///
215    /// # Example
216    ///
217    /// ```no_run
218    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig, QueryParameters, ParameterStyle};
219    ///
220    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
221    /// # let config = GrpcConfig::new("http://localhost:7484");
222    /// # let mut client = GrpcClient::connect(config).await?;
223    /// // Dollar-numbered parameters (mixed types use from_json_value)
224    /// let params = QueryParameters::from_json_value(&serde_json::json!([42, "Alice"]))?;
225    /// let result = client.execute_query_with_params(
226    ///     "SELECT * FROM users WHERE id = $1 AND name = $2",
227    ///     params,
228    ///     ParameterStyle::DollarNumbered,
229    /// ).await?;
230    ///
231    /// // Named parameters
232    /// let params = QueryParameters::json_named()
233    ///     .add("min_age", &18)?
234    ///     .build();
235    /// let result = client.execute_query_with_params(
236    ///     "SELECT * FROM users WHERE age >= :min_age",
237    ///     params,
238    ///     ParameterStyle::Named,
239    /// ).await?;
240    /// # Ok(())
241    /// # }
242    /// ```
243    ///
244    /// # Errors
245    ///
246    /// Same failure modes as [`Self::execute_query`], plus any
247    /// parameter-related error reported by the server (unknown
248    /// placeholder, type coercion failure, shape mismatch between the
249    /// SQL placeholders and the supplied parameter set).
250    pub async fn execute_query_with_params(
251        &mut self,
252        sql: &str,
253        params: QueryParameters,
254        style: ParameterStyle,
255    ) -> Result<GrpcQueryResult> {
256        self.execute_query_with_params_and_options(
257            sql,
258            params,
259            style,
260            OutputFormat::ArrowIpc,
261            self.config.transfer_mode,
262        )
263        .await
264    }
265
266    /// Executes a parameterized query and returns raw Arrow IPC bytes.
267    ///
268    /// # Example
269    ///
270    /// ```no_run
271    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig, QueryParameters, ParameterStyle};
272    ///
273    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
274    /// # let config = GrpcConfig::new("http://localhost:7484");
275    /// # let mut client = GrpcClient::connect(config).await?;
276    /// let params = QueryParameters::json_positional(&[&42i64])?;
277    /// let arrow_bytes = client.execute_query_with_params_to_arrow(
278    ///     "SELECT * FROM users WHERE id = $1",
279    ///     params,
280    ///     ParameterStyle::DollarNumbered,
281    /// ).await?;
282    /// # Ok(())
283    /// # }
284    /// ```
285    ///
286    /// # Errors
287    ///
288    /// Same failure modes as [`Self::execute_query_with_params`].
289    pub async fn execute_query_with_params_to_arrow(
290        &mut self,
291        sql: &str,
292        params: QueryParameters,
293        style: ParameterStyle,
294    ) -> Result<bytes::Bytes> {
295        let result = self.execute_query_with_params(sql, params, style).await?;
296        Ok(result.into_arrow_data())
297    }
298
299    /// Executes a parameterized query with specific options.
300    ///
301    /// This allows full control over the output format and transfer mode.
302    ///
303    /// # Errors
304    ///
305    /// - Returns [`Error::Protocol`] if the server returns no
306    ///   result chunks and does not signal completion.
307    /// - Propagates any error from the underlying
308    ///   `GrpcQueryExecutor` — auth failure, transport error, or
309    ///   server-side SQL error surfaced as [`tonic::Status`].
310    pub async fn execute_query_with_params_and_options(
311        &mut self,
312        sql: &str,
313        params: QueryParameters,
314        style: ParameterStyle,
315        output_format: OutputFormat,
316        transfer_mode: TransferMode,
317    ) -> Result<GrpcQueryResult> {
318        debug!(
319            sql = %sql,
320            param_style = ?style,
321            format = ?output_format,
322            mode = ?transfer_mode,
323            "Executing parameterized query"
324        );
325
326        // Build the query parameter
327        let query_param = QueryParam {
328            query: sql.to_string(),
329            databases: self.build_attached_databases(),
330            output_format: output_format.into(),
331            settings: self.config.settings.clone(),
332            transfer_mode: transfer_mode.into(),
333            param_style: i32::from(style),
334            parameters: Some(params.into_proto()),
335            result_range: None,
336            query_row_limit: None,
337        };
338
339        // Build headers for authentication and routing
340        let headers = self.build_headers();
341
342        // Create executor with configured message size limits
343        let client = HyperServiceClient::new(self.channel.clone())
344            .max_decoding_message_size(self.config.max_decoding_message_size)
345            .max_encoding_message_size(self.config.max_encoding_message_size);
346        let mut executor = GrpcQueryExecutor::new(client, headers, transfer_mode);
347
348        // Execute the query
349        executor.execute(query_param).await?;
350
351        // Collect all result chunks
352        let mut final_result = GrpcQueryResult::default();
353
354        loop {
355            if let Some(mut partial_result) = executor.next_result().await? {
356                // Merge chunks
357                while let Some(chunk) = partial_result.take_chunk() {
358                    final_result.chunks.push_back(chunk);
359                }
360                // Copy metadata from last result
361                if partial_result.query_id.is_some() {
362                    final_result.query_id = partial_result.query_id;
363                }
364                if partial_result.schema.is_some() {
365                    final_result.schema = partial_result.schema;
366                }
367                if partial_result.rows_affected.is_some() {
368                    final_result.rows_affected = partial_result.rows_affected;
369                }
370                // Check if complete
371                if partial_result.is_complete {
372                    final_result.is_complete = true;
373                    break;
374                }
375            } else {
376                // No more results
377                final_result.is_complete = true;
378                break;
379            }
380        }
381
382        if final_result.chunks.is_empty() && !final_result.is_complete {
383            return Err(Error::protocol("No result from query"));
384        }
385
386        Ok(final_result)
387    }
388
389    /// Executes a query with specific options.
390    ///
391    /// This allows control over the output format and transfer mode.
392    ///
393    /// # Errors
394    ///
395    /// - Returns [`Error::Protocol`] if the server returns no
396    ///   result chunks and does not signal completion.
397    /// - Propagates any error from the underlying
398    ///   `GrpcQueryExecutor` — auth failure, transport error, or
399    ///   server-side SQL error surfaced as [`tonic::Status`].
400    pub async fn execute_query_with_options(
401        &mut self,
402        sql: &str,
403        output_format: OutputFormat,
404        transfer_mode: TransferMode,
405    ) -> Result<GrpcQueryResult> {
406        debug!(sql = %sql, format = ?output_format, mode = ?transfer_mode, "Executing query");
407
408        // Build the query parameter
409        let query_param = QueryParam {
410            query: sql.to_string(),
411            databases: self.build_attached_databases(),
412            output_format: output_format.into(),
413            settings: self.config.settings.clone(),
414            transfer_mode: transfer_mode.into(),
415            param_style: 0, // Default
416            parameters: None,
417            result_range: None,
418            query_row_limit: None,
419        };
420
421        // Build headers for authentication and routing
422        let headers = self.build_headers();
423
424        // Create executor with configured message size limits
425        let client = HyperServiceClient::new(self.channel.clone())
426            .max_decoding_message_size(self.config.max_decoding_message_size)
427            .max_encoding_message_size(self.config.max_encoding_message_size);
428        let mut executor = GrpcQueryExecutor::new(client, headers, transfer_mode);
429
430        // Execute the query
431        executor.execute(query_param).await?;
432
433        // Collect all result chunks
434        let mut final_result = GrpcQueryResult::default();
435
436        loop {
437            if let Some(mut partial_result) = executor.next_result().await? {
438                // Merge chunks
439                while let Some(chunk) = partial_result.take_chunk() {
440                    final_result.chunks.push_back(chunk);
441                }
442                // Copy metadata from last result
443                if partial_result.query_id.is_some() {
444                    final_result.query_id = partial_result.query_id;
445                }
446                if partial_result.schema.is_some() {
447                    final_result.schema = partial_result.schema;
448                }
449                if partial_result.rows_affected.is_some() {
450                    final_result.rows_affected = partial_result.rows_affected;
451                }
452                // Check if complete
453                if partial_result.is_complete {
454                    final_result.is_complete = true;
455                    break;
456                }
457            } else {
458                // No more results
459                final_result.is_complete = true;
460                break;
461            }
462        }
463
464        if final_result.chunks.is_empty() && !final_result.is_complete {
465            return Err(Error::protocol("No result from query"));
466        }
467
468        Ok(final_result)
469    }
470
471    /// Executes a query and returns a streaming chunk producer.
472    ///
473    /// Unlike [`execute_query`](Self::execute_query), which drains every
474    /// result chunk into a single [`GrpcQueryResult`] before returning, this
475    /// method yields chunks lazily: each call to
476    /// [`GrpcChunkStream::next_chunk`] pulls just enough from the HTTP/2
477    /// stream to produce one Arrow IPC byte chunk. For very large result
478    /// sets (hundreds of MB to GB) this keeps client memory bounded by a
479    /// single gRPC message (capped at the tonic
480    /// `max_decoding_message_size`, default 64 MB) rather than growing to
481    /// the full result size.
482    ///
483    /// Pair this with
484    /// [`hyperdb_api::ArrowRowset::from_stream`][arrow_rowset_from_stream] to
485    /// decode batches incrementally and keep peak memory constant regardless
486    /// of total row count.
487    ///
488    /// [arrow_rowset_from_stream]: https://docs.rs/hyperdb-api/latest/hyperdb_api/struct.ArrowRowset.html#method.from_stream
489    ///
490    /// # Errors
491    ///
492    /// Same failure modes as
493    /// [`Self::execute_query_stream_with_options`] — invalid SQL,
494    /// auth failure, transport error, etc.
495    pub async fn execute_query_stream(&mut self, sql: &str) -> Result<GrpcChunkStream> {
496        self.execute_query_stream_with_options(
497            sql,
498            OutputFormat::ArrowIpc,
499            self.config.transfer_mode,
500        )
501        .await
502    }
503
504    /// Streaming variant of [`execute_query_with_options`](Self::execute_query_with_options).
505    ///
506    /// # Errors
507    ///
508    /// Propagates any error from the initial
509    /// `GrpcQueryExecutor::execute` call — server-side SQL error,
510    /// auth failure, or transport-level gRPC error.
511    pub async fn execute_query_stream_with_options(
512        &mut self,
513        sql: &str,
514        output_format: OutputFormat,
515        transfer_mode: TransferMode,
516    ) -> Result<GrpcChunkStream> {
517        debug!(sql = %sql, format = ?output_format, mode = ?transfer_mode, "Executing streaming query");
518
519        let query_param = QueryParam {
520            query: sql.to_string(),
521            databases: self.build_attached_databases(),
522            output_format: output_format.into(),
523            settings: self.config.settings.clone(),
524            transfer_mode: transfer_mode.into(),
525            param_style: 0,
526            parameters: None,
527            result_range: None,
528            query_row_limit: None,
529        };
530
531        let headers = self.build_headers();
532
533        let client = HyperServiceClient::new(self.channel.clone())
534            .max_decoding_message_size(self.config.max_decoding_message_size)
535            .max_encoding_message_size(self.config.max_encoding_message_size);
536        let mut executor = GrpcQueryExecutor::new(client, headers, transfer_mode);
537
538        executor.execute(query_param).await?;
539
540        Ok(GrpcChunkStream::new(executor))
541    }
542
543    /// Cancels an in-flight gRPC query by its `query_id`.
544    ///
545    /// This is the gRPC analogue of the PG wire `CancelRequest` packet: it
546    /// tells the server to stop executing a previously-started query. Unlike
547    /// PG wire (where the cancel travels on a *fresh* connection), gRPC
548    /// cancels travel as a regular RPC multiplexed over the existing HTTP/2
549    /// channel — that's why this call shares `self.channel` with normal
550    /// query traffic.
551    ///
552    /// # When do you have a `query_id`?
553    ///
554    /// The server assigns a `query_id` for queries started in
555    /// [`TransferMode::Async`](super::proto::hyper_service::query_param::TransferMode)
556    /// (long-running queries that the client polls). Grab it from
557    /// [`GrpcQueryResult::query_id`](super::result::GrpcQueryResult::query_id)
558    /// after `execute_query_with_options(..., TransferMode::Async)` returns.
559    /// SYNC-mode queries typically complete before the client needs a
560    /// cancel — for those, just drop the in-flight future.
561    ///
562    /// # Query-id lifecycle
563    ///
564    /// Query ids are stable for the lifetime of a query and are
565    /// server-assigned — a given id is never silently re-used for a
566    /// different query (Hyper generates them as UUID-like opaque tokens,
567    /// not sequential counters). The only race a caller needs to
568    /// consider is between obtaining the id and calling `cancel_query`:
569    ///
570    /// - If the query is still running, the cancel lands and the server
571    ///   aborts it.
572    /// - If the query has already completed normally between "obtain id"
573    ///   and "cancel", the server sees a cancel for an unknown /
574    ///   completed query and handles it gracefully (the exact shape
575    ///   depends on server build — see the tests in
576    ///   `hyperdb-api/tests/grpc_cancel_tests.rs` for details). Either way
577    ///   the channel stays healthy.
578    ///
579    /// There is no scenario where a stale id causes a cancel to target
580    /// the *wrong* query, because ids are not reassigned.
581    ///
582    /// # Errors
583    ///
584    /// Propagates transport-level errors. A successful cancel returns
585    /// `Ok(())` even if the query had already completed on the server;
586    /// cancellation is best-effort by design.
587    ///
588    /// # Relation to the [`Cancellable`](crate::client::cancel::Cancellable) trait
589    ///
590    /// This is the **fallible user-facing cancel API**: it returns a
591    /// `Result<()>` so explicit callers can observe transport-level
592    /// failures and react accordingly.
593    ///
594    /// It is *not* an implementation of the
595    /// [`Cancellable`](crate::client::cancel::Cancellable) trait — and cannot
596    /// be, because `Cancellable::cancel(&self)` takes no arguments while
597    /// gRPC cancels need a per-query `query_id`. A `GrpcClient` can have
598    /// many concurrent queries in flight; there is no single "the"
599    /// query on it the way there is on a PG wire connection. A future
600    /// gRPC streaming result type (when one is introduced) would carry
601    /// its `query_id` in a dedicated handle like
602    /// `GrpcCancelHandle { client, query_id }`, and *that* handle
603    /// would `impl Cancellable` by wrapping this method and swallowing
604    /// errors — same shape as
605    /// [`impl Cancellable for Client`](crate::client::cancel::Cancellable).
606    /// See the [`Cancellable`](crate::client::cancel::Cancellable) trait docs
607    /// for the full wrapper pattern.
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// use hyperdb_api_core::client::grpc::{GrpcClient, GrpcConfig, OutputFormat, TransferMode};
613    ///
614    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
615    /// # let config = GrpcConfig::new("http://localhost:7484");
616    /// # let mut client = GrpcClient::connect(config).await?;
617    /// let result = client
618    ///     .execute_query_with_options(
619    ///         "SELECT * FROM very_large_table",
620    ///         OutputFormat::ArrowIpc,
621    ///         TransferMode::Async,
622    ///     )
623    ///     .await?;
624    ///
625    /// if let Some(query_id) = result.query_id() {
626    ///     // Some time later, decide to abort:
627    ///     client.cancel_query(query_id).await?;
628    /// }
629    /// # Ok(())
630    /// # }
631    /// ```
632    pub async fn cancel_query(&mut self, query_id: &str) -> Result<()> {
633        debug!(query_id = %query_id, "Cancelling gRPC query");
634
635        let param = CancelQueryParam {
636            query_id: query_id.to_string(),
637        };
638        let mut request = tonic::Request::new(param);
639
640        // Apply the client's standard headers (database routing, custom
641        // headers). Matches what the query path does so that any server-side
642        // routing based on headers lands the cancel on the same backend as
643        // the query it's trying to cancel.
644        //
645        // Header parse failures are logged at `warn!` and then skipped —
646        // the cancel goes out without that particular header rather than
647        // failing the whole operation. A missing custom header is strictly
648        // better than a cancel we never send. The warn! is the only
649        // operational signal that routing-critical headers (e.g. the
650        // database selector) were dropped, so don't silence it.
651        for (key, value) in self.build_headers() {
652            match (
653                key.parse::<tonic::metadata::MetadataKey<_>>(),
654                value.parse(),
655            ) {
656                (Ok(k), Ok(v)) => {
657                    request.metadata_mut().insert(k, v);
658                }
659                (key_res, value_res) => {
660                    warn!(
661                        target: "hyperdb_api_core::client",
662                        query_id = %query_id,
663                        header_key = %key,
664                        key_parse_ok = key_res.is_ok(),
665                        value_parse_ok = value_res.is_ok(),
666                        "cancel: header parse failed, dropping header from cancel request",
667                    );
668                }
669            }
670        }
671        // Also set the canonical x-hyperdb-query-id metadata — some server
672        // deployments route cancels based on this header rather than the
673        // payload body.
674        match query_id.parse() {
675            Ok(value) => {
676                request.metadata_mut().insert("x-hyperdb-query-id", value);
677            }
678            Err(e) => {
679                warn!(
680                    target: "hyperdb_api_core::client",
681                    query_id = %query_id,
682                    error = %e,
683                    "cancel: x-hyperdb-query-id header parse failed; \
684                     cancel routing may fall back to payload-based lookup",
685                );
686            }
687        }
688
689        let mut client = HyperServiceClient::new(self.channel.clone())
690            .max_decoding_message_size(self.config.max_decoding_message_size)
691            .max_encoding_message_size(self.config.max_encoding_message_size);
692        client
693            .cancel_query(request)
694            .await
695            .map_err(from_grpc_status)?;
696
697        info!(query_id = %query_id, "gRPC query cancelled");
698        Ok(())
699    }
700
701    #[expect(
702        clippy::unused_async,
703        clippy::unused_async_trait_impl,
704        reason = "async fn retained for API symmetry; callers await regardless of whether the current body is synchronous"
705    )]
706    /// Closes the gRPC connection.
707    ///
708    /// This is a no-op as tonic channels are reference-counted and will be
709    /// closed when the last reference is dropped.
710    ///
711    /// # Errors
712    ///
713    /// Currently infallible — always returns `Ok(())`. The `Result`
714    /// return type is preserved for API symmetry with
715    /// [`GrpcClientSync::close`] and for forward compatibility if
716    /// future tonic channels expose a fallible shutdown.
717    pub async fn close(self) -> Result<()> {
718        debug!("Closing gRPC connection");
719        // Channel is dropped automatically
720        Ok(())
721    }
722
723    /// Builds the attached databases list from configuration.
724    fn build_attached_databases(&self) -> Vec<AttachedDatabase> {
725        if let Some(db_path) = &self.config.database {
726            debug!(db_path = %db_path, "Attaching database for query");
727            // Check if it's a JSON array (multiple databases)
728            if db_path.starts_with('[') {
729                // Parse JSON - for now just use as single database
730                // TODO: Implement proper JSON parsing for multiple databases
731                vec![AttachedDatabase {
732                    path: db_path.clone(),
733                    alias: String::new(), // Empty alias means use default
734                }]
735            } else {
736                vec![AttachedDatabase {
737                    path: db_path.clone(),
738                    alias: String::new(), // Empty alias means use default
739                }]
740            }
741        } else {
742            debug!("No database configured on gRPC client — query will run without attachment");
743            vec![]
744        }
745    }
746
747    /// Builds headers for gRPC requests.
748    fn build_headers(&self) -> Vec<(String, String)> {
749        let mut headers: Vec<(String, String)> = self
750            .config
751            .headers
752            .iter()
753            .map(|(k, v)| (k.clone(), v.clone()))
754            .collect();
755
756        // Add database header if configured
757        if let Some(ref db) = self.config.database {
758            headers.push(("x-hyper-database".to_string(), db.clone()));
759        }
760
761        headers
762    }
763}
764
765/// Synchronous wrapper around [`GrpcClient`].
766///
767/// This provides a blocking API by creating a Tokio runtime internally.
768/// For better performance in async contexts, use [`GrpcClient`] directly.
769///
770/// # Example
771///
772/// ```no_run
773/// use hyperdb_api_core::client::grpc::{GrpcClientSync, GrpcConfig};
774///
775/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
776/// let config = GrpcConfig::new("http://localhost:7484")
777///     .database("test.hyper");
778///
779/// let mut client = GrpcClientSync::connect(config)?;
780/// let result = client.execute_query("SELECT * FROM users")?;
781/// let arrow_bytes = result.arrow_data();
782/// # Ok(())
783/// # }
784/// ```
785#[derive(Debug)]
786pub struct GrpcClientSync {
787    /// The async client
788    inner: GrpcClient,
789    /// Tokio runtime for blocking operations.
790    ///
791    /// Wrapped in `Arc` so streaming chunk producers
792    /// ([`GrpcChunkStreamSync`]) can share the same runtime without having to
793    /// borrow the client or create their own.
794    runtime: Arc<tokio::runtime::Runtime>,
795}
796
797impl GrpcClientSync {
798    /// Connects to a Hyper server via gRPC (blocking).
799    ///
800    /// # Errors
801    ///
802    /// - Returns [`Error::Other`] if a current-thread Tokio
803    ///   runtime cannot be built.
804    /// - Propagates any error from [`GrpcClient::connect`] (invalid
805    ///   endpoint, TLS configuration failure, or transport setup
806    ///   failure).
807    pub fn connect(config: GrpcConfig) -> Result<Self> {
808        let runtime = tokio::runtime::Builder::new_current_thread()
809            .enable_all()
810            .build()
811            .map_err(|e| Error::other(format!("Failed to create Tokio runtime: {e}")))?;
812
813        let inner = runtime.block_on(GrpcClient::connect(config))?;
814
815        Ok(GrpcClientSync {
816            inner,
817            runtime: Arc::new(runtime),
818        })
819    }
820
821    /// Executes a SQL query (blocking).
822    ///
823    /// # Errors
824    ///
825    /// Blocking wrapper around [`GrpcClient::execute_query`]; see that
826    /// method for the concrete failure modes.
827    pub fn execute_query(&mut self, sql: &str) -> Result<GrpcQueryResult> {
828        self.runtime.block_on(self.inner.execute_query(sql))
829    }
830
831    /// Executes a query and returns Arrow IPC bytes (blocking).
832    ///
833    /// # Errors
834    ///
835    /// Same failure modes as [`Self::execute_query`].
836    pub fn execute_query_to_arrow(&mut self, sql: &str) -> Result<bytes::Bytes> {
837        self.runtime
838            .block_on(self.inner.execute_query_to_arrow(sql))
839    }
840
841    /// Executes a query and returns a blocking streaming chunk producer.
842    ///
843    /// See [`GrpcClient::execute_query_stream`] for the streaming semantics
844    /// and memory behavior. The returned [`GrpcChunkStreamSync`] lets you
845    /// pull chunks one at a time without buffering the entire result.
846    ///
847    /// # Errors
848    ///
849    /// Same failure modes as [`GrpcClient::execute_query_stream`].
850    pub fn execute_query_stream(&mut self, sql: &str) -> Result<GrpcChunkStreamSync> {
851        let inner = self
852            .runtime
853            .block_on(self.inner.execute_query_stream(sql))?;
854        Ok(GrpcChunkStreamSync {
855            inner,
856            runtime: Arc::clone(&self.runtime),
857        })
858    }
859
860    /// Executes a parameterized SQL query (blocking).
861    ///
862    /// # Example
863    ///
864    /// ```no_run
865    /// use hyperdb_api_core::client::grpc::{GrpcClientSync, GrpcConfig, QueryParameters, ParameterStyle};
866    ///
867    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
868    /// # let config = GrpcConfig::new("http://localhost:7484");
869    /// # let mut client = GrpcClientSync::connect(config)?;
870    /// let params = QueryParameters::json_positional(&[&42i64])?;
871    /// let result = client.execute_query_with_params(
872    ///     "SELECT * FROM users WHERE id = $1",
873    ///     params,
874    ///     ParameterStyle::DollarNumbered,
875    /// )?;
876    /// # Ok(())
877    /// # }
878    /// ```
879    ///
880    /// # Errors
881    ///
882    /// Blocking wrapper around
883    /// [`GrpcClient::execute_query_with_params`]; see that method for
884    /// the concrete failure modes.
885    pub fn execute_query_with_params(
886        &mut self,
887        sql: &str,
888        params: QueryParameters,
889        style: ParameterStyle,
890    ) -> Result<GrpcQueryResult> {
891        self.runtime
892            .block_on(self.inner.execute_query_with_params(sql, params, style))
893    }
894
895    /// Executes a parameterized query and returns Arrow IPC bytes (blocking).
896    ///
897    /// # Errors
898    ///
899    /// Same failure modes as [`Self::execute_query_with_params`].
900    pub fn execute_query_with_params_to_arrow(
901        &mut self,
902        sql: &str,
903        params: QueryParameters,
904        style: ParameterStyle,
905    ) -> Result<bytes::Bytes> {
906        self.runtime.block_on(
907            self.inner
908                .execute_query_with_params_to_arrow(sql, params, style),
909        )
910    }
911
912    /// Cancels an in-flight gRPC query by its `query_id` (blocking).
913    ///
914    /// Blocking wrapper around
915    /// [`GrpcClient::cancel_query`]. See that method's documentation for
916    /// when a `query_id` is available (ASYNC-mode queries), best-effort
917    /// cancel semantics, and the full "Relation to the `Cancellable`
918    /// trait" discussion.
919    ///
920    /// # Fallible by design
921    ///
922    /// The `Result<()>` return is intentional and mirrors the async
923    /// `GrpcClient::cancel_query`. Explicit callers get to observe
924    /// transport-level failures (network errors, channel closed, auth
925    /// expired) so they can record metrics, retry, or surface "cancel
926    /// failed" UX. This is *not* an `impl Cancellable for GrpcClientSync`
927    /// — it cannot be, because `Cancellable::cancel(&self)` takes no
928    /// arguments and has no way to pass the `query_id`.  See the
929    /// [`Cancellable`](crate::client::cancel::Cancellable) trait docs for the
930    /// infallible-wrapper pattern used by `Drop`-path consumers.
931    ///
932    /// # Example
933    ///
934    /// ```no_run
935    /// use hyperdb_api_core::client::grpc::{GrpcClientSync, GrpcConfig};
936    ///
937    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
938    /// # let config = GrpcConfig::new("http://localhost:7484");
939    /// # let mut client = GrpcClientSync::connect(config)?;
940    /// # let query_id = "some-query-id";
941    /// client.cancel_query(query_id)?;
942    /// # Ok(())
943    /// # }
944    /// ```
945    ///
946    /// # Errors
947    ///
948    /// Same failure modes as [`GrpcClient::cancel_query`] —
949    /// transport-level errors bubble up; a cancel for an
950    /// already-completed query returns `Ok(())` by design.
951    pub fn cancel_query(&mut self, query_id: &str) -> Result<()> {
952        self.runtime.block_on(self.inner.cancel_query(query_id))
953    }
954
955    /// Returns the client configuration.
956    pub fn config(&self) -> &GrpcConfig {
957        self.inner.config()
958    }
959
960    /// Closes the connection (blocking).
961    ///
962    /// # Errors
963    ///
964    /// Currently infallible — always returns `Ok(())`. The `Result`
965    /// return type is preserved for API symmetry with async callers.
966    pub fn close(self) -> Result<()> {
967        self.runtime.block_on(self.inner.close())
968    }
969}
970
971/// Blocking wrapper around [`GrpcChunkStream`].
972///
973/// Returned by [`GrpcClientSync::execute_query_stream`] and the
974/// `AuthenticatedGrpcClientSync` equivalent. Yields Arrow IPC byte chunks
975/// one at a time, blocking on the shared Tokio runtime as needed.
976///
977/// Pair with
978/// [`hyperdb_api::ArrowRowset::from_stream`][arrow_rowset_from_stream] to
979/// decode Arrow record batches incrementally with constant client memory.
980///
981/// [arrow_rowset_from_stream]: https://docs.rs/hyperdb-api/latest/hyperdb_api/struct.ArrowRowset.html#method.from_stream
982#[derive(Debug)]
983pub struct GrpcChunkStreamSync {
984    inner: GrpcChunkStream,
985    runtime: Arc<tokio::runtime::Runtime>,
986}
987
988impl GrpcChunkStreamSync {
989    /// Returns the next Arrow IPC byte chunk, or `None` when the stream is
990    /// complete.
991    ///
992    /// # Errors
993    ///
994    /// Same failure modes as [`GrpcChunkStream::next_chunk`] —
995    /// transport errors and server-side query failures surface as
996    /// [`Error`].
997    pub fn next_chunk(&mut self) -> Result<Option<bytes::Bytes>> {
998        self.runtime.block_on(self.inner.next_chunk())
999    }
1000
1001    /// Returns the schema reported by the server, if one has been received yet.
1002    pub fn schema(&self) -> Option<&super::proto::QueryResultSchema> {
1003        self.inner.schema()
1004    }
1005
1006    /// Returns the server-assigned query ID, if one has been received.
1007    pub fn query_id(&self) -> Option<&str> {
1008        self.inner.query_id()
1009    }
1010
1011    /// Returns the affected row count for DML queries, if reported.
1012    pub fn rows_affected(&self) -> Option<u64> {
1013        self.inner.rows_affected()
1014    }
1015}