Skip to main content

cf_system_sdk_directory/grpc/
client.rs

1//! gRPC client implementation of Directory API
2//!
3//! This client allows remote gears to discover and resolve services via gRPC.
4
5use anyhow::Result;
6use async_trait::async_trait;
7use tonic::transport::Channel;
8
9use crate::api::{DirectoryClient, RegisterInstanceInfo, ServiceEndpoint, ServiceInstanceInfo};
10use toolkit_transport_grpc::client::{GrpcClientConfig, connect_with_retry};
11
12use crate::{
13    DeregisterInstanceRequest, DirectoryServiceClient, GrpcServiceEndpoint, HeartbeatRequest,
14    ListInstancesRequest, RegisterInstanceRequest, ResolveGrpcServiceRequest,
15};
16
17/// gRPC client for Directory API
18///
19/// This client connects to a remote `DirectoryService` via gRPC and provides
20/// typed access to service discovery functionality. It includes:
21/// - Configurable timeouts and retries via transport stack
22/// - Automatic proto ↔ domain type conversions
23/// - Distributed tracing and metrics
24pub struct DirectoryGrpcClient {
25    inner: DirectoryServiceClient<Channel>,
26}
27
28impl DirectoryGrpcClient {
29    /// Connect to a directory service using default configuration with retries.
30    ///
31    /// Uses exponential backoff retry logic for reliable connection establishment.
32    /// This is the recommended method for `OoP` gears connecting to the master host.
33    ///
34    /// # Errors
35    /// It will return an error when it fails
36    pub async fn connect(uri: impl Into<String>) -> Result<Self> {
37        let cfg = GrpcClientConfig::new("directory");
38        Self::connect_with_retry(uri, &cfg).await
39    }
40
41    /// Connect to a directory service with custom configuration and retry logic.
42    ///
43    /// Uses exponential backoff based on `cfg.max_retries`, `cfg.base_backoff`,
44    /// and `cfg.max_backoff` settings.
45    ///
46    /// # Errors
47    /// It will return an error when it fails
48    pub async fn connect_with_retry(
49        uri: impl Into<String>,
50        cfg: &GrpcClientConfig,
51    ) -> Result<Self> {
52        let channel: Channel = connect_with_retry(uri, cfg).await?;
53        Ok(Self {
54            inner: DirectoryServiceClient::new(channel),
55        })
56    }
57
58    /// Connect to a directory service without retry logic.
59    ///
60    /// This method attempts a single connection. Use `connect` or `connect_with_retry`
61    /// for production scenarios where the directory service may not be immediately available.
62    ///
63    /// # Errors
64    /// It will return an error when it fails
65    pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
66        let uri_string = uri.into();
67
68        // Create endpoint with timeouts from config
69        let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
70            .connect_timeout(cfg.connect_timeout)
71            .timeout(cfg.rpc_timeout);
72
73        // Connect to the service
74        let channel = endpoint.connect().await?;
75
76        if cfg.enable_tracing {
77            tracing::debug!(
78                service_name = cfg.service_name,
79                connect_timeout_ms = cfg.connect_timeout.as_millis(),
80                rpc_timeout_ms = cfg.rpc_timeout.as_millis(),
81                "directory gRPC client connected"
82            );
83        }
84
85        Ok(Self {
86            inner: DirectoryServiceClient::new(channel),
87        })
88    }
89
90    /// Create from an existing channel (useful for testing or custom setup)
91    #[must_use]
92    pub fn from_channel(channel: Channel) -> Self {
93        Self {
94            inner: DirectoryServiceClient::new(channel),
95        }
96    }
97}
98
99#[async_trait]
100impl DirectoryClient for DirectoryGrpcClient {
101    async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
102        let mut client = self.inner.clone();
103        let request = tonic::Request::new(ResolveGrpcServiceRequest {
104            service_name: service_name.to_owned(),
105        });
106
107        let response = client
108            .resolve_grpc_service(request)
109            .await
110            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
111
112        let proto_response = response.into_inner();
113        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
114    }
115
116    async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
117        let mut client = self.inner.clone();
118        let request = tonic::Request::new(ListInstancesRequest {
119            gear_name: gear.to_owned(),
120        });
121
122        let response = client
123            .list_instances(request)
124            .await
125            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
126
127        let proto_response = response.into_inner();
128
129        // Convert proto instances to domain types
130        let instances = proto_response
131            .instances
132            .into_iter()
133            .map(|proto_inst| ServiceInstanceInfo {
134                gear: proto_inst.gear_name,
135                instance_id: proto_inst.instance_id,
136                endpoint: ServiceEndpoint::new(proto_inst.endpoint_uri),
137                version: if proto_inst.version.is_empty() {
138                    None
139                } else {
140                    Some(proto_inst.version)
141                },
142            })
143            .collect();
144
145        Ok(instances)
146    }
147
148    async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()> {
149        let mut client = self.inner.clone();
150
151        // Convert gRPC service endpoints
152        let grpc_services = info
153            .grpc_services
154            .into_iter()
155            .map(|(name, ep)| GrpcServiceEndpoint {
156                service_name: name,
157                endpoint_uri: ep.uri,
158            })
159            .collect();
160
161        let req = RegisterInstanceRequest {
162            gear_name: info.gear,
163            instance_id: info.instance_id,
164            grpc_services,
165            version: info.version.unwrap_or_default(),
166        };
167
168        client
169            .register_instance(tonic::Request::new(req))
170            .await
171            .map_err(|e| anyhow::anyhow!("gRPC register_instance failed: {e}"))?;
172
173        Ok(())
174    }
175
176    async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()> {
177        let mut client = self.inner.clone();
178
179        let req = DeregisterInstanceRequest {
180            gear_name: gear.to_owned(),
181            instance_id: instance_id.to_owned(),
182        };
183
184        client
185            .deregister_instance(tonic::Request::new(req))
186            .await
187            .map_err(|e| anyhow::anyhow!("gRPC deregister_instance failed: {e}"))?;
188
189        Ok(())
190    }
191
192    async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()> {
193        let mut client = self.inner.clone();
194
195        let req = HeartbeatRequest {
196            gear_name: gear.to_owned(),
197            instance_id: instance_id.to_owned(),
198        };
199
200        client
201            .heartbeat(tonic::Request::new(req))
202            .await
203            .map_err(|e| anyhow::anyhow!("gRPC heartbeat failed: {e}"))?;
204
205        Ok(())
206    }
207}
208
209#[cfg(test)]
210#[cfg_attr(coverage_nightly, coverage(off))]
211mod tests {
212    use super::*;
213
214    #[tokio::test]
215    async fn test_grpc_client_can_be_constructed() {
216        // Smoke test to ensure types compile and connect
217        let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
218
219        // We can't actually connect without a server, but we can construct the client type
220        // This ensures the API is correct
221        let channel_result = endpoint.connect().await;
222
223        // It's expected to fail since there's no server, but if it does somehow succeed:
224        if let Ok(channel) = channel_result {
225            let _client = DirectoryGrpcClient::from_channel(channel);
226        }
227    }
228}