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}