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