Skip to main content

zentinel_proxy/agents/
agent_v2.rs

1//! Protocol v2 agent implementation.
2//!
3//! This module provides v2 agent support using the bidirectional streaming
4//! protocol with capabilities, health reporting, and metrics export.
5
6use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use tracing::{debug, error, info, trace, warn};
11use zentinel_agent_protocol::v2::{
12    AgentCapabilities, AgentPool, AgentPoolConfig as ProtocolPoolConfig, AgentPoolStats,
13    CancelReason, ConfigPusher, ConfigUpdateType, LoadBalanceStrategy as ProtocolLBStrategy,
14    MetricsCollector,
15};
16use zentinel_agent_protocol::{
17    AgentResponse, EventType, GuardrailInspectEvent, RequestBodyChunkEvent, RequestHeadersEvent,
18    ResponseBodyChunkEvent, ResponseHeadersEvent,
19};
20use zentinel_common::{
21    errors::{ZentinelError, ZentinelResult},
22    CircuitBreaker,
23};
24use zentinel_config::{AgentConfig, AgentEvent, FailureMode, LoadBalanceStrategy};
25
26use super::metrics::AgentMetrics;
27
28/// Zentinel value indicating no timestamp recorded
29const NO_TIMESTAMP: u64 = 0;
30
31/// Protocol v2 agent with connection pooling and bidirectional streaming.
32pub struct AgentV2 {
33    /// Agent configuration
34    config: AgentConfig,
35    /// V2 connection pool
36    pool: Arc<AgentPool>,
37    /// Circuit breaker
38    circuit_breaker: Arc<CircuitBreaker>,
39    /// Agent-specific metrics
40    metrics: Arc<AgentMetrics>,
41    /// Base instant for timestamp calculations
42    base_instant: Instant,
43    /// Last successful call (nanoseconds since base_instant, 0 = never)
44    last_success_ns: AtomicU64,
45    /// Consecutive failures
46    consecutive_failures: AtomicU32,
47    /// Background pool maintenance task (health checks, reconnection,
48    /// affinity/session cleanup). Spawned by `initialize`, aborted by `shutdown`.
49    maintenance_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
50}
51
52impl AgentV2 {
53    /// Create a new v2 agent.
54    pub fn new(config: AgentConfig, circuit_breaker: Arc<CircuitBreaker>) -> Self {
55        trace!(
56            agent_id = %config.id,
57            agent_type = ?config.agent_type,
58            timeout_ms = config.timeout_ms,
59            events = ?config.events,
60            "Creating v2 agent instance"
61        );
62
63        // Convert config pool settings to protocol pool config
64        let pool_config = config
65            .pool
66            .as_ref()
67            .map(|p| ProtocolPoolConfig {
68                connections_per_agent: p.connections_per_agent,
69                load_balance_strategy: convert_lb_strategy(p.load_balance_strategy),
70                connect_timeout: Duration::from_millis(p.connect_timeout_ms),
71                request_timeout: Duration::from_millis(config.timeout_ms),
72                reconnect_interval: Duration::from_millis(p.reconnect_interval_ms),
73                max_reconnect_attempts: p.max_reconnect_attempts,
74                drain_timeout: Duration::from_millis(p.drain_timeout_ms),
75                max_concurrent_per_connection: p.max_concurrent_per_connection,
76                health_check_interval: Duration::from_millis(p.health_check_interval_ms),
77                ..Default::default()
78            })
79            .unwrap_or_default();
80
81        let pool = Arc::new(AgentPool::with_config(pool_config));
82
83        Self {
84            config,
85            pool,
86            circuit_breaker,
87            metrics: Arc::new(AgentMetrics::default()),
88            base_instant: Instant::now(),
89            last_success_ns: AtomicU64::new(NO_TIMESTAMP),
90            consecutive_failures: AtomicU32::new(0),
91            maintenance_handle: std::sync::Mutex::new(None),
92        }
93    }
94
95    /// Get the agent ID.
96    pub fn id(&self) -> &str {
97        &self.config.id
98    }
99
100    /// Get the agent's circuit breaker.
101    pub fn circuit_breaker(&self) -> &CircuitBreaker {
102        &self.circuit_breaker
103    }
104
105    /// Get the agent's failure mode.
106    pub fn failure_mode(&self) -> FailureMode {
107        self.config.failure_mode
108    }
109
110    /// Get the agent's timeout in milliseconds.
111    pub fn timeout_ms(&self) -> u64 {
112        self.config.timeout_ms
113    }
114
115    /// Maximum request body size (bytes) this agent will inspect.
116    pub fn max_request_body_bytes(&self) -> usize {
117        self.config
118            .max_request_body_bytes
119            .unwrap_or(super::DEFAULT_AGENT_MAX_BODY_BYTES)
120    }
121
122    /// Maximum response body size (bytes) this agent will inspect.
123    pub fn max_response_body_bytes(&self) -> usize {
124        self.config
125            .max_response_body_bytes
126            .unwrap_or(super::DEFAULT_AGENT_MAX_BODY_BYTES)
127    }
128
129    /// Get the agent's metrics.
130    pub fn metrics(&self) -> &AgentMetrics {
131        &self.metrics
132    }
133
134    /// Check if agent handles a specific event type.
135    pub fn handles_event(&self, event_type: EventType) -> bool {
136        self.config.events.iter().any(|e| match (e, event_type) {
137            (AgentEvent::RequestHeaders, EventType::RequestHeaders) => true,
138            (AgentEvent::RequestBody, EventType::RequestBodyChunk) => true,
139            (AgentEvent::ResponseHeaders, EventType::ResponseHeaders) => true,
140            (AgentEvent::ResponseBody, EventType::ResponseBodyChunk) => true,
141            (AgentEvent::Log, EventType::RequestComplete) => true,
142            (AgentEvent::WebSocketFrame, EventType::WebSocketFrame) => true,
143            (AgentEvent::Guardrail, EventType::GuardrailInspect) => true,
144            _ => false,
145        })
146    }
147
148    /// Initialize agent connection(s).
149    pub async fn initialize(&self) -> ZentinelResult<()> {
150        let endpoint = self.get_endpoint()?;
151
152        debug!(
153            agent_id = %self.config.id,
154            endpoint = %endpoint,
155            "Initializing v2 agent pool"
156        );
157
158        let start = Instant::now();
159
160        // Add agent to pool - pool will establish connections
161        self.pool
162            .add_agent(&self.config.id, &endpoint)
163            .await
164            .map_err(|e| {
165                error!(
166                    agent_id = %self.config.id,
167                    endpoint = %endpoint,
168                    error = %e,
169                    "Failed to add agent to v2 pool"
170                );
171                ZentinelError::Agent {
172                    agent: self.config.id.clone(),
173                    message: format!("Failed to initialize v2 agent: {}", e),
174                    event: "initialize".to_string(),
175                    source: None,
176                }
177            })?;
178
179        info!(
180            agent_id = %self.config.id,
181            endpoint = %endpoint,
182            connect_time_ms = start.elapsed().as_millis(),
183            "V2 agent pool initialized"
184        );
185
186        // Spawn pool maintenance: periodic health checks (with recovery of
187        // connections marked unhealthy), reconnection of failed connections,
188        // and cleanup of expired sticky sessions / correlation affinities.
189        // Without this, a crashed-and-restarted agent would stay unreachable.
190        {
191            let pool = Arc::clone(&self.pool);
192            let handle = tokio::spawn(async move { pool.run_maintenance().await });
193            let mut guard = self
194                .maintenance_handle
195                .lock()
196                .unwrap_or_else(std::sync::PoisonError::into_inner);
197            if let Some(old) = guard.replace(handle) {
198                old.abort();
199            }
200        }
201
202        // Send configuration if present
203        if let Some(config_value) = &self.config.config {
204            self.send_configure(config_value.clone()).await?;
205        }
206
207        Ok(())
208    }
209
210    /// Clear the connection affinity for a completed request.
211    pub fn clear_correlation_affinity(&self, correlation_id: &str) {
212        self.pool.clear_correlation_affinity(correlation_id);
213    }
214
215    /// Get endpoint from transport config.
216    fn get_endpoint(&self) -> ZentinelResult<String> {
217        use zentinel_config::AgentTransport;
218        match &self.config.transport {
219            AgentTransport::Grpc { address, .. } => Ok(address.clone()),
220            AgentTransport::UnixSocket { path } => {
221                // For UDS, format as unix:path
222                Ok(format!("unix:{}", path.display()))
223            }
224            AgentTransport::Http { url, .. } => {
225                // V2 doesn't support HTTP transport
226                Err(ZentinelError::Agent {
227                    agent: self.config.id.clone(),
228                    message: "HTTP transport not supported for v2 protocol".to_string(),
229                    event: "initialize".to_string(),
230                    source: None,
231                })
232            }
233        }
234    }
235
236    /// Send configuration to the agent via the pool's config push mechanism.
237    async fn send_configure(&self, _config: serde_json::Value) -> ZentinelResult<()> {
238        use zentinel_agent_protocol::v2::ConfigUpdateType;
239
240        if let Some(push_id) = self
241            .pool
242            .push_config_to_agent(&self.config.id, ConfigUpdateType::RequestReload)
243        {
244            info!(
245                agent_id = %self.config.id,
246                push_id = %push_id,
247                "Configuration push sent to agent"
248            );
249            Ok(())
250        } else {
251            debug!(
252                agent_id = %self.config.id,
253                "Agent does not support config push, config will be sent on next connection"
254            );
255            Ok(())
256        }
257    }
258
259    /// Call agent with request headers event.
260    pub async fn call_request_headers(
261        &self,
262        event: &RequestHeadersEvent,
263    ) -> ZentinelResult<AgentResponse> {
264        let call_num = self.metrics.calls_total.fetch_add(1, Ordering::Relaxed) + 1;
265
266        // Get correlation_id from event metadata
267        let correlation_id = &event.metadata.correlation_id;
268
269        trace!(
270            agent_id = %self.config.id,
271            call_num = call_num,
272            correlation_id = %correlation_id,
273            "Sending request headers to v2 agent"
274        );
275
276        self.pool
277            .send_request_headers(&self.config.id, correlation_id, event)
278            .await
279            .map_err(|e| {
280                error!(
281                    agent_id = %self.config.id,
282                    correlation_id = %correlation_id,
283                    error = %e,
284                    "V2 agent request headers call failed"
285                );
286                ZentinelError::Agent {
287                    agent: self.config.id.clone(),
288                    message: e.to_string(),
289                    event: "request_headers".to_string(),
290                    source: None,
291                }
292            })
293    }
294
295    /// Call agent with request body chunk event.
296    ///
297    /// For streaming body inspection, chunks are sent sequentially with
298    /// increasing `chunk_index`. The agent responds after processing each chunk.
299    pub async fn call_request_body_chunk(
300        &self,
301        event: &RequestBodyChunkEvent,
302    ) -> ZentinelResult<AgentResponse> {
303        let correlation_id = &event.correlation_id;
304
305        trace!(
306            agent_id = %self.config.id,
307            correlation_id = %correlation_id,
308            chunk_index = event.chunk_index,
309            is_last = event.is_last,
310            "Sending request body chunk to v2 agent"
311        );
312
313        self.pool
314            .send_request_body_chunk(&self.config.id, correlation_id, event)
315            .await
316            .map_err(|e| {
317                error!(
318                    agent_id = %self.config.id,
319                    correlation_id = %correlation_id,
320                    error = %e,
321                    "V2 agent request body chunk call failed"
322                );
323                ZentinelError::Agent {
324                    agent: self.config.id.clone(),
325                    message: e.to_string(),
326                    event: "request_body_chunk".to_string(),
327                    source: None,
328                }
329            })
330    }
331
332    /// Call agent with response headers event.
333    ///
334    /// Called when upstream response headers are received, allowing the agent
335    /// to inspect/modify response headers before they're sent to the client.
336    pub async fn call_response_headers(
337        &self,
338        event: &ResponseHeadersEvent,
339    ) -> ZentinelResult<AgentResponse> {
340        let correlation_id = &event.correlation_id;
341
342        trace!(
343            agent_id = %self.config.id,
344            correlation_id = %correlation_id,
345            status = event.status,
346            "Sending response headers to v2 agent"
347        );
348
349        self.pool
350            .send_response_headers(&self.config.id, correlation_id, event)
351            .await
352            .map_err(|e| {
353                error!(
354                    agent_id = %self.config.id,
355                    correlation_id = %correlation_id,
356                    error = %e,
357                    "V2 agent response headers call failed"
358                );
359                ZentinelError::Agent {
360                    agent: self.config.id.clone(),
361                    message: e.to_string(),
362                    event: "response_headers".to_string(),
363                    source: None,
364                }
365            })
366    }
367
368    /// Call agent with response body chunk event.
369    ///
370    /// For streaming response body inspection, chunks are sent sequentially.
371    /// The agent can inspect and optionally modify response body data.
372    pub async fn call_response_body_chunk(
373        &self,
374        event: &ResponseBodyChunkEvent,
375    ) -> ZentinelResult<AgentResponse> {
376        let correlation_id = &event.correlation_id;
377
378        trace!(
379            agent_id = %self.config.id,
380            correlation_id = %correlation_id,
381            chunk_index = event.chunk_index,
382            is_last = event.is_last,
383            "Sending response body chunk to v2 agent"
384        );
385
386        self.pool
387            .send_response_body_chunk(&self.config.id, correlation_id, event)
388            .await
389            .map_err(|e| {
390                error!(
391                    agent_id = %self.config.id,
392                    correlation_id = %correlation_id,
393                    error = %e,
394                    "V2 agent response body chunk call failed"
395                );
396                ZentinelError::Agent {
397                    agent: self.config.id.clone(),
398                    message: e.to_string(),
399                    event: "response_body_chunk".to_string(),
400                    source: None,
401                }
402            })
403    }
404
405    /// Call agent with guardrail inspect event.
406    pub async fn call_guardrail_inspect(
407        &self,
408        event: &GuardrailInspectEvent,
409    ) -> ZentinelResult<AgentResponse> {
410        let call_num = self.metrics.calls_total.fetch_add(1, Ordering::Relaxed) + 1;
411
412        let correlation_id = &event.correlation_id;
413
414        trace!(
415            agent_id = %self.config.id,
416            call_num = call_num,
417            correlation_id = %correlation_id,
418            inspection_type = ?event.inspection_type,
419            "Sending guardrail inspect to v2 agent"
420        );
421
422        self.pool
423            .send_guardrail_inspect(&self.config.id, correlation_id, event)
424            .await
425            .map_err(|e| {
426                error!(
427                    agent_id = %self.config.id,
428                    correlation_id = %correlation_id,
429                    error = %e,
430                    "V2 agent guardrail inspect call failed"
431                );
432                ZentinelError::Agent {
433                    agent: self.config.id.clone(),
434                    message: e.to_string(),
435                    event: "guardrail_inspect".to_string(),
436                    source: None,
437                }
438            })
439    }
440
441    /// Call agent with a generic event, dispatching to the appropriate typed method.
442    ///
443    /// The event is serialized and deserialized to convert between the generic
444    /// type and the specific event struct expected by each typed method.
445    pub async fn call_event<T: serde::Serialize>(
446        &self,
447        event_type: EventType,
448        event: &T,
449    ) -> ZentinelResult<AgentResponse> {
450        let json = serde_json::to_value(event).map_err(|e| ZentinelError::Agent {
451            agent: self.config.id.clone(),
452            message: format!("Failed to serialize event: {}", e),
453            event: format!("{:?}", event_type),
454            source: None,
455        })?;
456
457        match event_type {
458            EventType::RequestHeaders => {
459                let typed: RequestHeadersEvent =
460                    serde_json::from_value(json).map_err(|e| ZentinelError::Agent {
461                        agent: self.config.id.clone(),
462                        message: format!("Failed to deserialize RequestHeadersEvent: {}", e),
463                        event: format!("{:?}", event_type),
464                        source: None,
465                    })?;
466                self.call_request_headers(&typed).await
467            }
468            EventType::RequestBodyChunk => {
469                let typed: RequestBodyChunkEvent =
470                    serde_json::from_value(json).map_err(|e| ZentinelError::Agent {
471                        agent: self.config.id.clone(),
472                        message: format!("Failed to deserialize RequestBodyChunkEvent: {}", e),
473                        event: format!("{:?}", event_type),
474                        source: None,
475                    })?;
476                self.call_request_body_chunk(&typed).await
477            }
478            EventType::ResponseHeaders => {
479                let typed: ResponseHeadersEvent =
480                    serde_json::from_value(json).map_err(|e| ZentinelError::Agent {
481                        agent: self.config.id.clone(),
482                        message: format!("Failed to deserialize ResponseHeadersEvent: {}", e),
483                        event: format!("{:?}", event_type),
484                        source: None,
485                    })?;
486                self.call_response_headers(&typed).await
487            }
488            EventType::ResponseBodyChunk => {
489                let typed: ResponseBodyChunkEvent =
490                    serde_json::from_value(json).map_err(|e| ZentinelError::Agent {
491                        agent: self.config.id.clone(),
492                        message: format!("Failed to deserialize ResponseBodyChunkEvent: {}", e),
493                        event: format!("{:?}", event_type),
494                        source: None,
495                    })?;
496                self.call_response_body_chunk(&typed).await
497            }
498            EventType::GuardrailInspect => {
499                let typed: GuardrailInspectEvent =
500                    serde_json::from_value(json).map_err(|e| ZentinelError::Agent {
501                        agent: self.config.id.clone(),
502                        message: format!("Failed to deserialize GuardrailInspectEvent: {}", e),
503                        event: format!("{:?}", event_type),
504                        source: None,
505                    })?;
506                self.call_guardrail_inspect(&typed).await
507            }
508            _ => Err(ZentinelError::Agent {
509                agent: self.config.id.clone(),
510                message: format!("Unsupported event type {:?}", event_type),
511                event: format!("{:?}", event_type),
512                source: None,
513            }),
514        }
515    }
516
517    /// Cancel an in-flight request.
518    pub async fn cancel_request(
519        &self,
520        correlation_id: &str,
521        reason: CancelReason,
522    ) -> ZentinelResult<()> {
523        trace!(
524            agent_id = %self.config.id,
525            correlation_id = %correlation_id,
526            reason = ?reason,
527            "Cancelling request on v2 agent"
528        );
529
530        self.pool
531            .cancel_request(&self.config.id, correlation_id, reason)
532            .await
533            .map_err(|e| {
534                warn!(
535                    agent_id = %self.config.id,
536                    correlation_id = %correlation_id,
537                    error = %e,
538                    "Failed to cancel request on v2 agent"
539                );
540                ZentinelError::Agent {
541                    agent: self.config.id.clone(),
542                    message: format!("Cancel failed: {}", e),
543                    event: "cancel".to_string(),
544                    source: None,
545                }
546            })
547    }
548
549    /// Get agent capabilities.
550    pub async fn capabilities(&self) -> Option<AgentCapabilities> {
551        self.pool.agent_capabilities(&self.config.id).await
552    }
553
554    /// Check if agent is healthy.
555    pub async fn is_healthy(&self) -> bool {
556        self.pool.is_agent_healthy(&self.config.id)
557    }
558
559    /// Record successful call (lock-free).
560    pub fn record_success(&self, duration: Duration) {
561        let success_count = self.metrics.calls_success.fetch_add(1, Ordering::Relaxed) + 1;
562        self.metrics
563            .duration_total_us
564            .fetch_add(duration.as_micros() as u64, Ordering::Relaxed);
565        self.consecutive_failures.store(0, Ordering::Relaxed);
566        self.last_success_ns.store(
567            self.base_instant.elapsed().as_nanos() as u64,
568            Ordering::Relaxed,
569        );
570
571        trace!(
572            agent_id = %self.config.id,
573            duration_ms = duration.as_millis(),
574            total_successes = success_count,
575            "Recorded v2 agent call success"
576        );
577
578        self.circuit_breaker.record_success();
579    }
580
581    /// Get the time since last successful call.
582    #[inline]
583    pub fn time_since_last_success(&self) -> Option<Duration> {
584        let last_ns = self.last_success_ns.load(Ordering::Relaxed);
585        if last_ns == NO_TIMESTAMP {
586            return None;
587        }
588        let current_ns = self.base_instant.elapsed().as_nanos() as u64;
589        Some(Duration::from_nanos(current_ns.saturating_sub(last_ns)))
590    }
591
592    /// Record failed call.
593    pub fn record_failure(&self) {
594        let fail_count = self.metrics.calls_failed.fetch_add(1, Ordering::Relaxed) + 1;
595        let consecutive = self.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1;
596
597        debug!(
598            agent_id = %self.config.id,
599            total_failures = fail_count,
600            consecutive_failures = consecutive,
601            "Recorded v2 agent call failure"
602        );
603
604        self.circuit_breaker.record_failure();
605    }
606
607    /// Record timeout.
608    pub fn record_timeout(&self) {
609        let timeout_count = self.metrics.calls_timeout.fetch_add(1, Ordering::Relaxed) + 1;
610        let consecutive = self.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1;
611
612        debug!(
613            agent_id = %self.config.id,
614            total_timeouts = timeout_count,
615            consecutive_failures = consecutive,
616            timeout_ms = self.config.timeout_ms,
617            "Recorded v2 agent call timeout"
618        );
619
620        self.circuit_breaker.record_failure();
621    }
622
623    /// Get pool statistics.
624    pub async fn pool_stats(&self) -> Option<AgentPoolStats> {
625        self.pool.agent_stats(&self.config.id).await
626    }
627
628    /// Get the pool's metrics collector.
629    ///
630    /// Returns a reference to the shared metrics collector that aggregates
631    /// metrics reports from all agents in this pool.
632    pub fn pool_metrics_collector(&self) -> &MetricsCollector {
633        self.pool.metrics_collector()
634    }
635
636    /// Get an Arc to the pool's metrics collector.
637    ///
638    /// This is useful for registering the collector with a MetricsManager.
639    pub fn pool_metrics_collector_arc(&self) -> Arc<MetricsCollector> {
640        self.pool.metrics_collector_arc()
641    }
642
643    /// Export agent metrics in Prometheus format.
644    ///
645    /// Returns a string containing all metrics collected from agents
646    /// in Prometheus exposition format.
647    pub fn export_prometheus(&self) -> String {
648        self.pool.export_prometheus()
649    }
650
651    /// Get the pool's config pusher.
652    ///
653    /// Returns a reference to the shared config pusher that distributes
654    /// configuration updates to agents.
655    pub fn config_pusher(&self) -> &ConfigPusher {
656        self.pool.config_pusher()
657    }
658
659    /// Push a configuration update to this agent.
660    ///
661    /// Returns the push ID if the agent supports config push, None otherwise.
662    pub fn push_config(&self, update_type: ConfigUpdateType) -> Option<String> {
663        self.pool.push_config_to_agent(&self.config.id, update_type)
664    }
665
666    /// Send a configuration update to this agent via the control stream.
667    ///
668    /// This is a direct config push using the `ConfigureEvent` message.
669    pub async fn send_configuration(&self, config: serde_json::Value) -> ZentinelResult<()> {
670        // Get a connection and send the configure event
671        // For now, we rely on the pool's config push mechanism
672        // which tracks acknowledgments and retries
673        if let Some(push_id) = self.push_config(ConfigUpdateType::RequestReload) {
674            debug!(
675                agent_id = %self.config.id,
676                push_id = %push_id,
677                "Configuration push initiated"
678            );
679            Ok(())
680        } else {
681            warn!(
682                agent_id = %self.config.id,
683                "Agent does not support config push"
684            );
685            Err(ZentinelError::Agent {
686                agent: self.config.id.clone(),
687                message: "Agent does not support config push".to_string(),
688                event: "send_configuration".to_string(),
689                source: None,
690            })
691        }
692    }
693
694    /// Shutdown agent.
695    ///
696    /// This removes the agent from the pool and closes all connections.
697    pub async fn shutdown(&self) {
698        debug!(
699            agent_id = %self.config.id,
700            "Shutting down v2 agent"
701        );
702
703        // Stop background pool maintenance
704        if let Some(handle) = self
705            .maintenance_handle
706            .lock()
707            .unwrap_or_else(std::sync::PoisonError::into_inner)
708            .take()
709        {
710            handle.abort();
711        }
712
713        // Remove from pool - this gracefully closes connections
714        if let Err(e) = self.pool.remove_agent(&self.config.id).await {
715            warn!(
716                agent_id = %self.config.id,
717                error = %e,
718                "Error removing agent from pool during shutdown"
719            );
720        }
721
722        let stats = (
723            self.metrics.calls_total.load(Ordering::Relaxed),
724            self.metrics.calls_success.load(Ordering::Relaxed),
725            self.metrics.calls_failed.load(Ordering::Relaxed),
726            self.metrics.calls_timeout.load(Ordering::Relaxed),
727        );
728
729        info!(
730            agent_id = %self.config.id,
731            total_calls = stats.0,
732            successes = stats.1,
733            failures = stats.2,
734            timeouts = stats.3,
735            "V2 agent shutdown complete"
736        );
737    }
738}
739
740/// Convert config load balance strategy to protocol load balance strategy.
741fn convert_lb_strategy(strategy: LoadBalanceStrategy) -> ProtocolLBStrategy {
742    match strategy {
743        LoadBalanceStrategy::RoundRobin => ProtocolLBStrategy::RoundRobin,
744        LoadBalanceStrategy::LeastConnections => ProtocolLBStrategy::LeastConnections,
745        LoadBalanceStrategy::HealthBased => ProtocolLBStrategy::HealthBased,
746        LoadBalanceStrategy::Random => ProtocolLBStrategy::Random,
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_convert_lb_strategy() {
756        assert_eq!(
757            convert_lb_strategy(LoadBalanceStrategy::RoundRobin),
758            ProtocolLBStrategy::RoundRobin
759        );
760        assert_eq!(
761            convert_lb_strategy(LoadBalanceStrategy::LeastConnections),
762            ProtocolLBStrategy::LeastConnections
763        );
764        assert_eq!(
765            convert_lb_strategy(LoadBalanceStrategy::HealthBased),
766            ProtocolLBStrategy::HealthBased
767        );
768        assert_eq!(
769            convert_lb_strategy(LoadBalanceStrategy::Random),
770            ProtocolLBStrategy::Random
771        );
772    }
773}