Skip to main content

zentinel_proxy/upstream/
mod.rs

1//! Upstream pool management module for Zentinel proxy
2//!
3//! This module handles upstream server pools, load balancing, health checking,
4//! connection pooling, and retry logic with circuit breakers.
5
6use async_trait::async_trait;
7use pingora::upstreams::peer::HttpPeer;
8use rand::seq::IndexedRandom;
9use std::collections::HashMap;
10use std::net::ToSocketAddrs;
11use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::RwLock;
15use tracing::{debug, error, info, trace, warn};
16
17use zentinel_common::{
18    errors::{ZentinelError, ZentinelResult},
19    types::{CircuitBreakerConfig, LoadBalancingAlgorithm},
20    CircuitBreaker, UpstreamId,
21};
22use zentinel_config::UpstreamConfig;
23
24// ============================================================================
25// Internal Upstream Target Type
26// ============================================================================
27
28/// Internal upstream target representation for load balancers
29///
30/// This is a simplified representation used internally by load balancers,
31/// separate from the user-facing config UpstreamTarget.
32#[derive(Debug, Clone)]
33pub struct UpstreamTarget {
34    /// Target IP address or hostname
35    pub address: String,
36    /// Target port
37    pub port: u16,
38    /// Weight for weighted load balancing
39    pub weight: u32,
40}
41
42impl UpstreamTarget {
43    /// Create a new upstream target
44    pub fn new(address: impl Into<String>, port: u16, weight: u32) -> Self {
45        Self {
46            address: address.into(),
47            port,
48            weight,
49        }
50    }
51
52    /// Create from a "host:port" string with default weight
53    pub fn from_address(addr: &str) -> Option<Self> {
54        let parts: Vec<&str> = addr.rsplitn(2, ':').collect();
55        if parts.len() == 2 {
56            let port = parts[0].parse().ok()?;
57            let address = parts[1].to_string();
58            Some(Self {
59                address,
60                port,
61                weight: 100,
62            })
63        } else {
64            None
65        }
66    }
67
68    /// Convert from config UpstreamTarget
69    pub fn from_config(config: &zentinel_config::UpstreamTarget) -> Option<Self> {
70        Self::from_address(&config.address).map(|mut t| {
71            t.weight = config.weight;
72            t
73        })
74    }
75
76    /// Get the full address string
77    pub fn full_address(&self) -> String {
78        format!("{}:{}", self.address, self.port)
79    }
80}
81
82// ============================================================================
83// Load Balancing
84// ============================================================================
85
86// Load balancing algorithm implementations
87pub mod adaptive;
88pub mod consistent_hash;
89pub mod drain;
90pub mod health;
91pub mod inference_health;
92pub mod least_tokens;
93pub mod locality;
94pub mod maglev;
95pub mod p2c;
96pub mod peak_ewma;
97pub mod sticky_session;
98pub mod subset;
99pub mod weighted_least_conn;
100
101// Re-export commonly used types from sub-modules
102pub use adaptive::{AdaptiveBalancer, AdaptiveConfig};
103pub use consistent_hash::{ConsistentHashBalancer, ConsistentHashConfig};
104pub use health::{ActiveHealthChecker, HealthCheckRunner};
105pub use inference_health::InferenceHealthCheck;
106pub use least_tokens::{
107    LeastTokensQueuedBalancer, LeastTokensQueuedConfig, LeastTokensQueuedTargetStats,
108};
109pub use locality::{LocalityAwareBalancer, LocalityAwareConfig};
110pub use maglev::{MaglevBalancer, MaglevConfig};
111pub use p2c::{P2cBalancer, P2cConfig};
112pub use peak_ewma::{PeakEwmaBalancer, PeakEwmaConfig};
113pub use sticky_session::{StickySessionBalancer, StickySessionRuntimeConfig};
114pub use subset::{SubsetBalancer, SubsetConfig};
115pub use weighted_least_conn::{WeightedLeastConnBalancer, WeightedLeastConnConfig};
116
117/// Request context for load balancer decisions
118#[derive(Debug, Clone)]
119pub struct RequestContext {
120    pub client_ip: Option<std::net::SocketAddr>,
121    pub headers: HashMap<String, String>,
122    pub path: String,
123    pub method: String,
124}
125
126/// Load balancer trait for different algorithms
127#[async_trait]
128pub trait LoadBalancer: Send + Sync {
129    /// Select next upstream target
130    async fn select(&self, context: Option<&RequestContext>) -> ZentinelResult<TargetSelection>;
131
132    /// Report target health status
133    async fn report_health(&self, address: &str, healthy: bool);
134
135    /// Get all healthy targets
136    async fn healthy_targets(&self) -> Vec<String>;
137
138    /// Release connection (for connection tracking)
139    async fn release(&self, _selection: &TargetSelection) {
140        // Default implementation - no-op
141    }
142
143    /// Report request result (for adaptive algorithms)
144    async fn report_result(
145        &self,
146        _selection: &TargetSelection,
147        _success: bool,
148        _latency: Option<Duration>,
149    ) {
150        // Default implementation - no-op
151    }
152
153    /// Report request result by address with latency (for adaptive algorithms)
154    ///
155    /// This method allows reporting results without needing the full TargetSelection,
156    /// which is useful when the selection is not available (e.g., in logging callback).
157    /// The default implementation just calls report_health; adaptive balancers override
158    /// this to update their metrics.
159    async fn report_result_with_latency(
160        &self,
161        address: &str,
162        success: bool,
163        _latency: Option<Duration>,
164    ) {
165        // Default implementation - just report health
166        self.report_health(address, success).await;
167    }
168}
169
170/// Selected upstream target
171#[derive(Debug, Clone)]
172pub struct TargetSelection {
173    /// Target address
174    pub address: String,
175    /// Target weight
176    pub weight: u32,
177    /// Target metadata
178    pub metadata: HashMap<String, String>,
179}
180
181/// Upstream pool managing multiple backend servers
182pub struct UpstreamPool {
183    /// Pool identifier
184    id: UpstreamId,
185    /// Configured targets
186    targets: Vec<UpstreamTarget>,
187    /// Load balancer implementation
188    load_balancer: Arc<dyn LoadBalancer>,
189    /// Connection pool configuration (Pingora handles actual pooling)
190    pool_config: ConnectionPoolConfig,
191    /// HTTP version configuration
192    http_version: HttpVersionOptions,
193    /// Whether TLS is enabled for this upstream
194    tls_enabled: bool,
195    /// SNI for TLS connections
196    tls_sni: Option<String>,
197    /// TLS configuration for upstream mTLS (client certificates)
198    tls_config: Option<zentinel_config::UpstreamTlsConfig>,
199    /// Circuit breakers per target
200    circuit_breakers: Arc<RwLock<HashMap<String, CircuitBreaker>>>,
201    /// Pool statistics
202    stats: Arc<PoolStats>,
203}
204
205// Note: Active health checking is handled by the PassiveHealthChecker in health.rs
206// and via load balancer health reporting. A future enhancement could add active
207// HTTP/TCP health probes here.
208
209/// Connection pool configuration for Pingora's built-in pooling
210///
211/// Note: Actual connection pooling is handled by Pingora internally.
212/// This struct holds configuration that is applied to peer options.
213pub struct ConnectionPoolConfig {
214    /// Maximum connections per target (informational - Pingora manages actual pooling)
215    pub max_connections: usize,
216    /// Maximum idle connections (informational - Pingora manages actual pooling)
217    pub max_idle: usize,
218    /// Maximum idle timeout for pooled connections
219    pub idle_timeout: Duration,
220    /// Maximum connection lifetime (None = unlimited)
221    pub max_lifetime: Option<Duration>,
222    /// Connection timeout
223    pub connection_timeout: Duration,
224    /// Read timeout
225    pub read_timeout: Duration,
226    /// Write timeout
227    pub write_timeout: Duration,
228}
229
230/// HTTP version configuration for upstream connections
231pub struct HttpVersionOptions {
232    /// Minimum HTTP version (1 or 2)
233    pub min_version: u8,
234    /// Maximum HTTP version (1 or 2)
235    pub max_version: u8,
236    /// H2 ping interval (0 to disable)
237    pub h2_ping_interval: Duration,
238    /// Maximum concurrent H2 streams per connection
239    pub max_h2_streams: usize,
240}
241
242impl ConnectionPoolConfig {
243    /// Create from upstream config
244    pub fn from_config(
245        pool_config: &zentinel_config::ConnectionPoolConfig,
246        timeouts: &zentinel_config::UpstreamTimeouts,
247    ) -> Self {
248        Self {
249            max_connections: pool_config.max_connections,
250            max_idle: pool_config.max_idle,
251            idle_timeout: Duration::from_secs(pool_config.idle_timeout_secs),
252            max_lifetime: pool_config.max_lifetime_secs.map(Duration::from_secs),
253            connection_timeout: Duration::from_secs(timeouts.connect_secs),
254            read_timeout: Duration::from_secs(timeouts.read_secs),
255            write_timeout: Duration::from_secs(timeouts.write_secs),
256        }
257    }
258}
259
260// CircuitBreaker is imported from zentinel_common
261
262/// Pool statistics
263#[derive(Default)]
264pub struct PoolStats {
265    /// Total requests
266    pub requests: AtomicU64,
267    /// Successful requests
268    pub successes: AtomicU64,
269    /// Failed requests
270    pub failures: AtomicU64,
271    /// Retried requests
272    pub retries: AtomicU64,
273    /// Circuit breaker trips
274    pub circuit_breaker_trips: AtomicU64,
275    /// Currently active requests (in-flight)
276    pub active_requests: AtomicU64,
277}
278
279/// Target information for shadow traffic
280#[derive(Debug, Clone)]
281pub struct ShadowTarget {
282    /// URL scheme (http or https)
283    pub scheme: String,
284    /// Target host
285    pub host: String,
286    /// Target port
287    pub port: u16,
288    /// SNI for TLS connections
289    pub sni: Option<String>,
290}
291
292impl ShadowTarget {
293    /// Build URL from target info and path
294    pub fn build_url(&self, path: &str) -> String {
295        let port_suffix = match (self.scheme.as_str(), self.port) {
296            ("http", 80) | ("https", 443) => String::new(),
297            _ => format!(":{}", self.port),
298        };
299        format!("{}://{}{}{}", self.scheme, self.host, port_suffix, path)
300    }
301}
302
303/// Snapshot of pool configuration for metrics/debugging
304#[derive(Debug, Clone)]
305pub struct PoolConfigSnapshot {
306    /// Maximum connections per target
307    pub max_connections: usize,
308    /// Maximum idle connections
309    pub max_idle: usize,
310    /// Idle timeout in seconds
311    pub idle_timeout_secs: u64,
312    /// Maximum connection lifetime in seconds (None = unlimited)
313    pub max_lifetime_secs: Option<u64>,
314    /// Connection timeout in seconds
315    pub connection_timeout_secs: u64,
316    /// Read timeout in seconds
317    pub read_timeout_secs: u64,
318    /// Write timeout in seconds
319    pub write_timeout_secs: u64,
320}
321
322/// Round-robin load balancer
323struct RoundRobinBalancer {
324    targets: Vec<UpstreamTarget>,
325    current: AtomicUsize,
326    health_status: Arc<RwLock<HashMap<String, bool>>>,
327}
328
329impl RoundRobinBalancer {
330    fn new(targets: Vec<UpstreamTarget>) -> Self {
331        let mut health_status = HashMap::new();
332        for target in &targets {
333            health_status.insert(target.full_address(), true);
334        }
335
336        Self {
337            targets,
338            current: AtomicUsize::new(0),
339            health_status: Arc::new(RwLock::new(health_status)),
340        }
341    }
342}
343
344#[async_trait]
345impl LoadBalancer for RoundRobinBalancer {
346    async fn select(&self, _context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
347        trace!(
348            total_targets = self.targets.len(),
349            algorithm = "round_robin",
350            "Selecting upstream target"
351        );
352
353        let health = self.health_status.read().await;
354        let healthy_targets: Vec<_> = self
355            .targets
356            .iter()
357            .filter(|t| *health.get(&t.full_address()).unwrap_or(&true))
358            .collect();
359
360        if healthy_targets.is_empty() {
361            warn!(
362                total_targets = self.targets.len(),
363                algorithm = "round_robin",
364                "No healthy upstream targets available"
365            );
366            return Err(ZentinelError::NoHealthyUpstream);
367        }
368
369        let index = self.current.fetch_add(1, Ordering::Relaxed) % healthy_targets.len();
370        let target = healthy_targets[index];
371
372        trace!(
373            selected_target = %target.full_address(),
374            healthy_count = healthy_targets.len(),
375            index = index,
376            algorithm = "round_robin",
377            "Selected target via round robin"
378        );
379
380        Ok(TargetSelection {
381            address: target.full_address(),
382            weight: target.weight,
383            metadata: HashMap::new(),
384        })
385    }
386
387    async fn report_health(&self, address: &str, healthy: bool) {
388        trace!(
389            target = %address,
390            healthy = healthy,
391            algorithm = "round_robin",
392            "Updating target health status"
393        );
394        self.health_status
395            .write()
396            .await
397            .insert(address.to_string(), healthy);
398    }
399
400    async fn healthy_targets(&self) -> Vec<String> {
401        self.health_status
402            .read()
403            .await
404            .iter()
405            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
406            .collect()
407    }
408}
409
410/// Random load balancer - true random selection among healthy targets
411struct RandomBalancer {
412    targets: Vec<UpstreamTarget>,
413    health_status: Arc<RwLock<HashMap<String, bool>>>,
414}
415
416impl RandomBalancer {
417    fn new(targets: Vec<UpstreamTarget>) -> Self {
418        let mut health_status = HashMap::new();
419        for target in &targets {
420            health_status.insert(target.full_address(), true);
421        }
422
423        Self {
424            targets,
425            health_status: Arc::new(RwLock::new(health_status)),
426        }
427    }
428}
429
430#[async_trait]
431impl LoadBalancer for RandomBalancer {
432    async fn select(&self, _context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
433        use rand::seq::SliceRandom;
434
435        trace!(
436            total_targets = self.targets.len(),
437            algorithm = "random",
438            "Selecting upstream target"
439        );
440
441        let health = self.health_status.read().await;
442        let healthy_targets: Vec<_> = self
443            .targets
444            .iter()
445            .filter(|t| *health.get(&t.full_address()).unwrap_or(&true))
446            .collect();
447
448        if healthy_targets.is_empty() {
449            warn!(
450                total_targets = self.targets.len(),
451                algorithm = "random",
452                "No healthy upstream targets available"
453            );
454            return Err(ZentinelError::NoHealthyUpstream);
455        }
456
457        let mut rng = rand::rng();
458        let target = healthy_targets
459            .choose(&mut rng)
460            .ok_or(ZentinelError::NoHealthyUpstream)?;
461
462        trace!(
463            selected_target = %target.full_address(),
464            healthy_count = healthy_targets.len(),
465            algorithm = "random",
466            "Selected target via random selection"
467        );
468
469        Ok(TargetSelection {
470            address: target.full_address(),
471            weight: target.weight,
472            metadata: HashMap::new(),
473        })
474    }
475
476    async fn report_health(&self, address: &str, healthy: bool) {
477        trace!(
478            target = %address,
479            healthy = healthy,
480            algorithm = "random",
481            "Updating target health status"
482        );
483        self.health_status
484            .write()
485            .await
486            .insert(address.to_string(), healthy);
487    }
488
489    async fn healthy_targets(&self) -> Vec<String> {
490        self.health_status
491            .read()
492            .await
493            .iter()
494            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
495            .collect()
496    }
497}
498
499/// Least connections load balancer
500struct LeastConnectionsBalancer {
501    targets: Vec<UpstreamTarget>,
502    connections: Arc<RwLock<HashMap<String, usize>>>,
503    health_status: Arc<RwLock<HashMap<String, bool>>>,
504}
505
506impl LeastConnectionsBalancer {
507    fn new(targets: Vec<UpstreamTarget>) -> Self {
508        let mut health_status = HashMap::new();
509        let mut connections = HashMap::new();
510
511        for target in &targets {
512            let addr = target.full_address();
513            health_status.insert(addr.clone(), true);
514            connections.insert(addr, 0);
515        }
516
517        Self {
518            targets,
519            connections: Arc::new(RwLock::new(connections)),
520            health_status: Arc::new(RwLock::new(health_status)),
521        }
522    }
523}
524
525#[async_trait]
526impl LoadBalancer for LeastConnectionsBalancer {
527    async fn select(&self, _context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
528        trace!(
529            total_targets = self.targets.len(),
530            algorithm = "least_connections",
531            "Selecting upstream target"
532        );
533
534        let health = self.health_status.read().await;
535        let conns = self.connections.read().await;
536
537        let mut best_target = None;
538        let mut min_connections = usize::MAX;
539
540        for target in &self.targets {
541            let addr = target.full_address();
542            if !*health.get(&addr).unwrap_or(&true) {
543                trace!(
544                    target = %addr,
545                    algorithm = "least_connections",
546                    "Skipping unhealthy target"
547                );
548                continue;
549            }
550
551            let conn_count = *conns.get(&addr).unwrap_or(&0);
552            trace!(
553                target = %addr,
554                connections = conn_count,
555                "Evaluating target connection count"
556            );
557            if conn_count < min_connections {
558                min_connections = conn_count;
559                best_target = Some(target);
560            }
561        }
562
563        match best_target {
564            Some(target) => {
565                trace!(
566                    selected_target = %target.full_address(),
567                    connections = min_connections,
568                    algorithm = "least_connections",
569                    "Selected target with fewest connections"
570                );
571                Ok(TargetSelection {
572                    address: target.full_address(),
573                    weight: target.weight,
574                    metadata: HashMap::new(),
575                })
576            }
577            None => {
578                warn!(
579                    total_targets = self.targets.len(),
580                    algorithm = "least_connections",
581                    "No healthy upstream targets available"
582                );
583                Err(ZentinelError::NoHealthyUpstream)
584            }
585        }
586    }
587
588    async fn report_health(&self, address: &str, healthy: bool) {
589        trace!(
590            target = %address,
591            healthy = healthy,
592            algorithm = "least_connections",
593            "Updating target health status"
594        );
595        self.health_status
596            .write()
597            .await
598            .insert(address.to_string(), healthy);
599    }
600
601    async fn healthy_targets(&self) -> Vec<String> {
602        self.health_status
603            .read()
604            .await
605            .iter()
606            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
607            .collect()
608    }
609}
610
611/// Weighted load balancer
612struct WeightedBalancer {
613    targets: Vec<UpstreamTarget>,
614    weights: Vec<u32>,
615    current_index: AtomicUsize,
616    health_status: Arc<RwLock<HashMap<String, bool>>>,
617}
618
619#[async_trait]
620impl LoadBalancer for WeightedBalancer {
621    async fn select(&self, _context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
622        trace!(
623            total_targets = self.targets.len(),
624            algorithm = "weighted",
625            "Selecting upstream target"
626        );
627
628        let health = self.health_status.read().await;
629        let healthy: Vec<_> = self
630            .targets
631            .iter()
632            .enumerate()
633            .filter(|(_, t)| *health.get(&t.full_address()).unwrap_or(&true))
634            .map(|(i, _)| i)
635            .collect();
636
637        if healthy.is_empty() {
638            warn!(
639                total_targets = self.targets.len(),
640                algorithm = "weighted",
641                "No healthy upstream targets available"
642            );
643            return Err(ZentinelError::NoHealthyUpstream);
644        }
645
646        // Weighted round-robin: map request counter to a weighted slot.
647        // E.g. weights [70, 30] → total 100 → slots [0..70) → target 0, [70..100) → target 1
648        let total_weight: u32 = healthy
649            .iter()
650            .map(|&i| self.weights.get(i).copied().unwrap_or(1))
651            .sum();
652
653        if total_weight == 0 {
654            return Err(ZentinelError::NoHealthyUpstream);
655        }
656
657        let slot = (self.current_index.fetch_add(1, Ordering::Relaxed) as u32) % total_weight;
658        let mut cumulative = 0u32;
659        let mut target_idx = healthy[0];
660        for &i in &healthy {
661            let w = self.weights.get(i).copied().unwrap_or(1);
662            cumulative += w;
663            if slot < cumulative {
664                target_idx = i;
665                break;
666            }
667        }
668
669        let target = &self.targets[target_idx];
670        let weight = self.weights.get(target_idx).copied().unwrap_or(1);
671
672        trace!(
673            selected_target = %target.full_address(),
674            weight = weight,
675            healthy_count = healthy.len(),
676            algorithm = "weighted",
677            "Selected target via weighted round robin"
678        );
679
680        Ok(TargetSelection {
681            address: target.full_address(),
682            weight,
683            metadata: HashMap::new(),
684        })
685    }
686
687    async fn report_health(&self, address: &str, healthy: bool) {
688        trace!(
689            target = %address,
690            healthy = healthy,
691            algorithm = "weighted",
692            "Updating target health status"
693        );
694        self.health_status
695            .write()
696            .await
697            .insert(address.to_string(), healthy);
698    }
699
700    async fn healthy_targets(&self) -> Vec<String> {
701        self.health_status
702            .read()
703            .await
704            .iter()
705            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
706            .collect()
707    }
708}
709
710/// IP hash load balancer
711struct IpHashBalancer {
712    targets: Vec<UpstreamTarget>,
713    health_status: Arc<RwLock<HashMap<String, bool>>>,
714}
715
716#[async_trait]
717impl LoadBalancer for IpHashBalancer {
718    async fn select(&self, context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
719        trace!(
720            total_targets = self.targets.len(),
721            algorithm = "ip_hash",
722            "Selecting upstream target"
723        );
724
725        let health = self.health_status.read().await;
726        let healthy_targets: Vec<_> = self
727            .targets
728            .iter()
729            .filter(|t| *health.get(&t.full_address()).unwrap_or(&true))
730            .collect();
731
732        if healthy_targets.is_empty() {
733            warn!(
734                total_targets = self.targets.len(),
735                algorithm = "ip_hash",
736                "No healthy upstream targets available"
737            );
738            return Err(ZentinelError::NoHealthyUpstream);
739        }
740
741        // Hash the client IP to select a target
742        let (hash, client_ip_str) = if let Some(ctx) = context {
743            if let Some(ip) = &ctx.client_ip {
744                use std::hash::{Hash, Hasher};
745                let mut hasher = std::collections::hash_map::DefaultHasher::new();
746                ip.hash(&mut hasher);
747                (hasher.finish(), Some(ip.to_string()))
748            } else {
749                (0, None)
750            }
751        } else {
752            (0, None)
753        };
754
755        let idx = (hash as usize) % healthy_targets.len();
756        let target = healthy_targets[idx];
757
758        trace!(
759            selected_target = %target.full_address(),
760            client_ip = client_ip_str.as_deref().unwrap_or("unknown"),
761            hash = hash,
762            index = idx,
763            healthy_count = healthy_targets.len(),
764            algorithm = "ip_hash",
765            "Selected target via IP hash"
766        );
767
768        Ok(TargetSelection {
769            address: target.full_address(),
770            weight: target.weight,
771            metadata: HashMap::new(),
772        })
773    }
774
775    async fn report_health(&self, address: &str, healthy: bool) {
776        trace!(
777            target = %address,
778            healthy = healthy,
779            algorithm = "ip_hash",
780            "Updating target health status"
781        );
782        self.health_status
783            .write()
784            .await
785            .insert(address.to_string(), healthy);
786    }
787
788    async fn healthy_targets(&self) -> Vec<String> {
789        self.health_status
790            .read()
791            .await
792            .iter()
793            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
794            .collect()
795    }
796}
797
798impl UpstreamPool {
799    /// Create new upstream pool from configuration
800    pub async fn new(config: UpstreamConfig) -> ZentinelResult<Self> {
801        let id = UpstreamId::new(&config.id);
802
803        info!(
804            upstream_id = %config.id,
805            target_count = config.targets.len(),
806            algorithm = ?config.load_balancing,
807            "Creating upstream pool"
808        );
809
810        // Convert config targets to internal targets
811        let targets: Vec<UpstreamTarget> = config
812            .targets
813            .iter()
814            .filter_map(UpstreamTarget::from_config)
815            .collect();
816
817        if targets.is_empty() {
818            error!(
819                upstream_id = %config.id,
820                "No valid upstream targets configured"
821            );
822            return Err(ZentinelError::Config {
823                message: "No valid upstream targets".to_string(),
824                source: None,
825            });
826        }
827
828        for target in &targets {
829            debug!(
830                upstream_id = %config.id,
831                target = %target.full_address(),
832                weight = target.weight,
833                "Registered upstream target"
834            );
835        }
836
837        // Create load balancer
838        debug!(
839            upstream_id = %config.id,
840            algorithm = ?config.load_balancing,
841            "Creating load balancer"
842        );
843        let load_balancer = Self::create_load_balancer(&config.load_balancing, &targets, &config)?;
844
845        // Create connection pool configuration (Pingora handles actual pooling)
846        debug!(
847            upstream_id = %config.id,
848            max_connections = config.connection_pool.max_connections,
849            max_idle = config.connection_pool.max_idle,
850            idle_timeout_secs = config.connection_pool.idle_timeout_secs,
851            connect_timeout_secs = config.timeouts.connect_secs,
852            read_timeout_secs = config.timeouts.read_secs,
853            write_timeout_secs = config.timeouts.write_secs,
854            "Creating connection pool configuration"
855        );
856        let pool_config =
857            ConnectionPoolConfig::from_config(&config.connection_pool, &config.timeouts);
858
859        // Create HTTP version configuration
860        let http_version = HttpVersionOptions {
861            min_version: config.http_version.min_version,
862            max_version: config.http_version.max_version,
863            h2_ping_interval: if config.http_version.h2_ping_interval_secs > 0 {
864                Duration::from_secs(config.http_version.h2_ping_interval_secs)
865            } else {
866                Duration::ZERO
867            },
868            max_h2_streams: config.http_version.max_h2_streams,
869        };
870
871        // TLS configuration
872        let tls_enabled = config.tls.is_some();
873        let tls_sni = config.tls.as_ref().and_then(|t| t.sni.clone());
874        let tls_config = config.tls.clone();
875
876        // Log mTLS configuration if present
877        if let Some(ref tls) = tls_config {
878            if tls.client_cert.is_some() {
879                info!(
880                    upstream_id = %config.id,
881                    "mTLS enabled for upstream (client certificate configured)"
882                );
883            }
884        }
885
886        if http_version.max_version >= 2 && tls_enabled {
887            info!(
888                upstream_id = %config.id,
889                "HTTP/2 enabled for upstream (via ALPN)"
890            );
891        }
892
893        // Initialize circuit breakers for each target
894        let mut circuit_breakers = HashMap::new();
895        for target in &targets {
896            trace!(
897                upstream_id = %config.id,
898                target = %target.full_address(),
899                "Initializing circuit breaker for target"
900            );
901            circuit_breakers.insert(
902                target.full_address(),
903                CircuitBreaker::new(CircuitBreakerConfig::default()),
904            );
905        }
906
907        let pool = Self {
908            id: id.clone(),
909            targets,
910            load_balancer,
911            pool_config,
912            http_version,
913            tls_enabled,
914            tls_sni,
915            tls_config,
916            circuit_breakers: Arc::new(RwLock::new(circuit_breakers)),
917            stats: Arc::new(PoolStats::default()),
918        };
919
920        info!(
921            upstream_id = %id,
922            target_count = pool.targets.len(),
923            "Upstream pool created successfully"
924        );
925
926        Ok(pool)
927    }
928
929    /// Create load balancer based on algorithm
930    fn create_load_balancer(
931        algorithm: &LoadBalancingAlgorithm,
932        targets: &[UpstreamTarget],
933        config: &UpstreamConfig,
934    ) -> ZentinelResult<Arc<dyn LoadBalancer>> {
935        let balancer: Arc<dyn LoadBalancer> = match algorithm {
936            LoadBalancingAlgorithm::RoundRobin => {
937                Arc::new(RoundRobinBalancer::new(targets.to_vec()))
938            }
939            LoadBalancingAlgorithm::LeastConnections => {
940                Arc::new(LeastConnectionsBalancer::new(targets.to_vec()))
941            }
942            LoadBalancingAlgorithm::Weighted => {
943                let weights: Vec<u32> = targets.iter().map(|t| t.weight).collect();
944                Arc::new(WeightedBalancer {
945                    targets: targets.to_vec(),
946                    weights,
947                    current_index: AtomicUsize::new(0),
948                    health_status: Arc::new(RwLock::new(HashMap::new())),
949                })
950            }
951            LoadBalancingAlgorithm::IpHash => Arc::new(IpHashBalancer {
952                targets: targets.to_vec(),
953                health_status: Arc::new(RwLock::new(HashMap::new())),
954            }),
955            LoadBalancingAlgorithm::Random => Arc::new(RandomBalancer::new(targets.to_vec())),
956            LoadBalancingAlgorithm::ConsistentHash => Arc::new(ConsistentHashBalancer::new(
957                targets.to_vec(),
958                ConsistentHashConfig::default(),
959            )),
960            LoadBalancingAlgorithm::PowerOfTwoChoices => {
961                Arc::new(P2cBalancer::new(targets.to_vec(), P2cConfig::default()))
962            }
963            LoadBalancingAlgorithm::Adaptive => Arc::new(AdaptiveBalancer::new(
964                targets.to_vec(),
965                AdaptiveConfig::default(),
966            )),
967            LoadBalancingAlgorithm::LeastTokensQueued => Arc::new(LeastTokensQueuedBalancer::new(
968                targets.to_vec(),
969                LeastTokensQueuedConfig::default(),
970            )),
971            LoadBalancingAlgorithm::Maglev => Arc::new(MaglevBalancer::new(
972                targets.to_vec(),
973                MaglevConfig::default(),
974            )),
975            LoadBalancingAlgorithm::LocalityAware => Arc::new(LocalityAwareBalancer::new(
976                targets.to_vec(),
977                LocalityAwareConfig::default(),
978            )),
979            LoadBalancingAlgorithm::PeakEwma => Arc::new(PeakEwmaBalancer::new(
980                targets.to_vec(),
981                PeakEwmaConfig::default(),
982            )),
983            LoadBalancingAlgorithm::DeterministicSubset => Arc::new(SubsetBalancer::new(
984                targets.to_vec(),
985                SubsetConfig::default(),
986            )),
987            LoadBalancingAlgorithm::WeightedLeastConnections => {
988                Arc::new(WeightedLeastConnBalancer::new(
989                    targets.to_vec(),
990                    WeightedLeastConnConfig::default(),
991                ))
992            }
993            LoadBalancingAlgorithm::Sticky => {
994                // Get sticky session config (required for Sticky algorithm)
995                let sticky_config = config.sticky_session.as_ref().ok_or_else(|| {
996                    ZentinelError::Config {
997                        message: format!(
998                            "Upstream '{}' uses Sticky algorithm but no sticky_session config provided",
999                            config.id
1000                        ),
1001                        source: None,
1002                    }
1003                })?;
1004
1005                // Create runtime config with HMAC key
1006                let runtime_config = StickySessionRuntimeConfig::from_config(sticky_config);
1007
1008                // Create fallback load balancer
1009                let fallback = Self::create_load_balancer_inner(&sticky_config.fallback, targets)?;
1010
1011                info!(
1012                    upstream_id = %config.id,
1013                    cookie_name = %runtime_config.cookie_name,
1014                    cookie_ttl_secs = runtime_config.cookie_ttl_secs,
1015                    fallback_algorithm = ?sticky_config.fallback,
1016                    "Creating sticky session balancer"
1017                );
1018
1019                Arc::new(StickySessionBalancer::new(
1020                    targets.to_vec(),
1021                    runtime_config,
1022                    fallback,
1023                ))
1024            }
1025        };
1026        Ok(balancer)
1027    }
1028
1029    /// Create load balancer without sticky session support (for fallback balancers)
1030    fn create_load_balancer_inner(
1031        algorithm: &LoadBalancingAlgorithm,
1032        targets: &[UpstreamTarget],
1033    ) -> ZentinelResult<Arc<dyn LoadBalancer>> {
1034        let balancer: Arc<dyn LoadBalancer> = match algorithm {
1035            LoadBalancingAlgorithm::RoundRobin => {
1036                Arc::new(RoundRobinBalancer::new(targets.to_vec()))
1037            }
1038            LoadBalancingAlgorithm::LeastConnections => {
1039                Arc::new(LeastConnectionsBalancer::new(targets.to_vec()))
1040            }
1041            LoadBalancingAlgorithm::Weighted => {
1042                let weights: Vec<u32> = targets.iter().map(|t| t.weight).collect();
1043                Arc::new(WeightedBalancer {
1044                    targets: targets.to_vec(),
1045                    weights,
1046                    current_index: AtomicUsize::new(0),
1047                    health_status: Arc::new(RwLock::new(HashMap::new())),
1048                })
1049            }
1050            LoadBalancingAlgorithm::IpHash => Arc::new(IpHashBalancer {
1051                targets: targets.to_vec(),
1052                health_status: Arc::new(RwLock::new(HashMap::new())),
1053            }),
1054            LoadBalancingAlgorithm::Random => Arc::new(RandomBalancer::new(targets.to_vec())),
1055            LoadBalancingAlgorithm::ConsistentHash => Arc::new(ConsistentHashBalancer::new(
1056                targets.to_vec(),
1057                ConsistentHashConfig::default(),
1058            )),
1059            LoadBalancingAlgorithm::PowerOfTwoChoices => {
1060                Arc::new(P2cBalancer::new(targets.to_vec(), P2cConfig::default()))
1061            }
1062            LoadBalancingAlgorithm::Adaptive => Arc::new(AdaptiveBalancer::new(
1063                targets.to_vec(),
1064                AdaptiveConfig::default(),
1065            )),
1066            LoadBalancingAlgorithm::LeastTokensQueued => Arc::new(LeastTokensQueuedBalancer::new(
1067                targets.to_vec(),
1068                LeastTokensQueuedConfig::default(),
1069            )),
1070            LoadBalancingAlgorithm::Maglev => Arc::new(MaglevBalancer::new(
1071                targets.to_vec(),
1072                MaglevConfig::default(),
1073            )),
1074            LoadBalancingAlgorithm::LocalityAware => Arc::new(LocalityAwareBalancer::new(
1075                targets.to_vec(),
1076                LocalityAwareConfig::default(),
1077            )),
1078            LoadBalancingAlgorithm::PeakEwma => Arc::new(PeakEwmaBalancer::new(
1079                targets.to_vec(),
1080                PeakEwmaConfig::default(),
1081            )),
1082            LoadBalancingAlgorithm::DeterministicSubset => Arc::new(SubsetBalancer::new(
1083                targets.to_vec(),
1084                SubsetConfig::default(),
1085            )),
1086            LoadBalancingAlgorithm::WeightedLeastConnections => {
1087                Arc::new(WeightedLeastConnBalancer::new(
1088                    targets.to_vec(),
1089                    WeightedLeastConnConfig::default(),
1090                ))
1091            }
1092            LoadBalancingAlgorithm::Sticky => {
1093                // Sticky cannot be used as fallback (would cause infinite recursion)
1094                return Err(ZentinelError::Config {
1095                    message: "Sticky algorithm cannot be used as fallback for sticky sessions"
1096                        .to_string(),
1097                    source: None,
1098                });
1099            }
1100        };
1101        Ok(balancer)
1102    }
1103
1104    /// Select next upstream peer with selection metadata
1105    ///
1106    /// Returns the selected peer along with optional metadata from the load balancer.
1107    /// The metadata can contain sticky session information that should be passed to
1108    /// the response filter.
1109    pub async fn select_peer_with_metadata(
1110        &self,
1111        context: Option<&RequestContext>,
1112    ) -> ZentinelResult<(HttpPeer, HashMap<String, String>)> {
1113        let request_num = self.stats.requests.fetch_add(1, Ordering::Relaxed) + 1;
1114
1115        trace!(
1116            upstream_id = %self.id,
1117            request_num = request_num,
1118            target_count = self.targets.len(),
1119            "Starting peer selection with metadata"
1120        );
1121
1122        let mut attempts = 0;
1123        let max_attempts = self.targets.len() * 2;
1124
1125        while attempts < max_attempts {
1126            attempts += 1;
1127
1128            trace!(
1129                upstream_id = %self.id,
1130                attempt = attempts,
1131                max_attempts = max_attempts,
1132                "Attempting to select peer"
1133            );
1134
1135            let selection = match self.load_balancer.select(context).await {
1136                Ok(s) => s,
1137                Err(e) => {
1138                    warn!(
1139                        upstream_id = %self.id,
1140                        attempt = attempts,
1141                        error = %e,
1142                        "Load balancer selection failed"
1143                    );
1144                    continue;
1145                }
1146            };
1147
1148            trace!(
1149                upstream_id = %self.id,
1150                target = %selection.address,
1151                attempt = attempts,
1152                "Load balancer selected target"
1153            );
1154
1155            // Check circuit breaker
1156            let breakers = self.circuit_breakers.read().await;
1157            if let Some(breaker) = breakers.get(&selection.address) {
1158                if !breaker.is_closed() {
1159                    debug!(
1160                        upstream_id = %self.id,
1161                        target = %selection.address,
1162                        attempt = attempts,
1163                        "Circuit breaker is open, skipping target"
1164                    );
1165                    self.stats
1166                        .circuit_breaker_trips
1167                        .fetch_add(1, Ordering::Relaxed);
1168                    continue;
1169                }
1170            }
1171
1172            // Create peer with pooling options
1173            trace!(
1174                upstream_id = %self.id,
1175                target = %selection.address,
1176                "Creating peer for upstream (Pingora handles connection reuse)"
1177            );
1178            let peer = self.create_peer(&selection)?;
1179
1180            debug!(
1181                upstream_id = %self.id,
1182                target = %selection.address,
1183                attempt = attempts,
1184                metadata_keys = ?selection.metadata.keys().collect::<Vec<_>>(),
1185                "Selected upstream peer with metadata"
1186            );
1187
1188            self.stats.successes.fetch_add(1, Ordering::Relaxed);
1189            return Ok((peer, selection.metadata));
1190        }
1191
1192        self.stats.failures.fetch_add(1, Ordering::Relaxed);
1193        error!(
1194            upstream_id = %self.id,
1195            attempts = attempts,
1196            max_attempts = max_attempts,
1197            "Failed to select upstream after max attempts"
1198        );
1199        Err(ZentinelError::upstream(
1200            self.id.to_string(),
1201            "Failed to select upstream after max attempts",
1202        ))
1203    }
1204
1205    /// Select next upstream peer
1206    pub async fn select_peer(&self, context: Option<&RequestContext>) -> ZentinelResult<HttpPeer> {
1207        // Delegate to select_peer_with_metadata and discard metadata
1208        self.select_peer_with_metadata(context)
1209            .await
1210            .map(|(peer, _)| peer)
1211    }
1212
1213    /// Create new peer connection with connection pooling options
1214    ///
1215    /// Pingora handles actual connection pooling internally. When idle_timeout
1216    /// is set on the peer options, Pingora will keep the connection alive and
1217    /// reuse it for subsequent requests to the same upstream.
1218    fn create_peer(&self, selection: &TargetSelection) -> ZentinelResult<HttpPeer> {
1219        // Determine SNI hostname for TLS connections
1220        let sni_hostname = self.tls_sni.clone().unwrap_or_else(|| {
1221            // Extract hostname from address (strip port)
1222            selection
1223                .address
1224                .split(':')
1225                .next()
1226                .unwrap_or(&selection.address)
1227                .to_string()
1228        });
1229
1230        // Pre-resolve the address to avoid panics in Pingora's HttpPeer::new
1231        // when DNS resolution fails (e.g., when a container is killed)
1232        let resolved_address = selection
1233            .address
1234            .to_socket_addrs()
1235            .map_err(|e| {
1236                error!(
1237                    upstream = %self.id,
1238                    address = %selection.address,
1239                    error = %e,
1240                    "Failed to resolve upstream address"
1241                );
1242                ZentinelError::Upstream {
1243                    upstream: self.id.to_string(),
1244                    message: format!("DNS resolution failed for {}: {}", selection.address, e),
1245                    retryable: true,
1246                    source: None,
1247                }
1248            })?
1249            .next()
1250            .ok_or_else(|| {
1251                error!(
1252                    upstream = %self.id,
1253                    address = %selection.address,
1254                    "No addresses returned from DNS resolution"
1255                );
1256                ZentinelError::Upstream {
1257                    upstream: self.id.to_string(),
1258                    message: format!("No addresses for {}", selection.address),
1259                    retryable: true,
1260                    source: None,
1261                }
1262            })?;
1263
1264        // Use the resolved IP address to create the peer
1265        let mut peer = HttpPeer::new(resolved_address, self.tls_enabled, sni_hostname.clone());
1266
1267        // Configure connection pooling options for better performance
1268        // idle_timeout enables Pingora's connection pooling - connections are
1269        // kept alive and reused for this duration
1270        peer.options.idle_timeout = Some(self.pool_config.idle_timeout);
1271
1272        // Connection timeouts
1273        peer.options.connection_timeout = Some(self.pool_config.connection_timeout);
1274        peer.options.total_connection_timeout = Some(Duration::from_secs(10));
1275
1276        // Read/write timeouts
1277        peer.options.read_timeout = Some(self.pool_config.read_timeout);
1278        peer.options.write_timeout = Some(self.pool_config.write_timeout);
1279
1280        // Enable TCP keepalive for long-lived connections
1281        peer.options.tcp_keepalive = Some(pingora::protocols::TcpKeepalive {
1282            idle: Duration::from_secs(60),
1283            interval: Duration::from_secs(10),
1284            count: 3,
1285            // user_timeout is Linux-only
1286            #[cfg(target_os = "linux")]
1287            user_timeout: Duration::from_secs(60),
1288        });
1289
1290        // Configure HTTP version and ALPN for TLS connections
1291        if self.tls_enabled {
1292            // Set ALPN protocols based on configured HTTP version range
1293            let alpn = match (self.http_version.min_version, self.http_version.max_version) {
1294                (2, _) => {
1295                    // HTTP/2 only - use h2 ALPN
1296                    pingora::upstreams::peer::ALPN::H2
1297                }
1298                (1, 2) | (_, 2) => {
1299                    // Prefer HTTP/2 but fall back to HTTP/1.1
1300                    pingora::upstreams::peer::ALPN::H2H1
1301                }
1302                _ => {
1303                    // HTTP/1.1 only
1304                    pingora::upstreams::peer::ALPN::H1
1305                }
1306            };
1307            peer.options.alpn = alpn;
1308
1309            // Configure TLS verification options based on upstream config
1310            if let Some(ref tls_config) = self.tls_config {
1311                // Skip certificate verification if configured (DANGEROUS - testing only)
1312                if tls_config.insecure_skip_verify {
1313                    peer.options.verify_cert = false;
1314                    peer.options.verify_hostname = false;
1315                    warn!(
1316                        upstream_id = %self.id,
1317                        target = %selection.address,
1318                        "TLS certificate verification DISABLED (insecure_skip_verify=true)"
1319                    );
1320                }
1321
1322                // Set alternative CN for verification if SNI differs from actual hostname
1323                if let Some(ref sni) = tls_config.sni {
1324                    peer.options.alternative_cn = Some(sni.clone());
1325                    trace!(
1326                        upstream_id = %self.id,
1327                        target = %selection.address,
1328                        alternative_cn = %sni,
1329                        "Set alternative CN for TLS verification"
1330                    );
1331                }
1332
1333                // Configure mTLS client certificate if provided
1334                if let (Some(cert_path), Some(key_path)) =
1335                    (&tls_config.client_cert, &tls_config.client_key)
1336                {
1337                    match crate::tls::load_client_cert_key(cert_path, key_path) {
1338                        Ok(cert_key) => {
1339                            peer.client_cert_key = Some(cert_key);
1340                            info!(
1341                                upstream_id = %self.id,
1342                                target = %selection.address,
1343                                cert_path = ?cert_path,
1344                                "mTLS client certificate configured"
1345                            );
1346                        }
1347                        Err(e) => {
1348                            error!(
1349                                upstream_id = %self.id,
1350                                target = %selection.address,
1351                                error = %e,
1352                                "Failed to load mTLS client certificate"
1353                            );
1354                            return Err(ZentinelError::Tls {
1355                                message: format!("Failed to load client certificate: {}", e),
1356                                source: None,
1357                            });
1358                        }
1359                    }
1360                }
1361            }
1362
1363            trace!(
1364                upstream_id = %self.id,
1365                target = %selection.address,
1366                alpn = ?peer.options.alpn,
1367                min_version = self.http_version.min_version,
1368                max_version = self.http_version.max_version,
1369                verify_cert = peer.options.verify_cert,
1370                verify_hostname = peer.options.verify_hostname,
1371                "Configured ALPN and TLS options for HTTP version negotiation"
1372            );
1373        }
1374
1375        // Configure H2-specific settings when HTTP/2 is enabled
1376        if self.http_version.max_version >= 2 {
1377            // H2 ping interval for connection health monitoring
1378            if !self.http_version.h2_ping_interval.is_zero() {
1379                peer.options.h2_ping_interval = Some(self.http_version.h2_ping_interval);
1380                trace!(
1381                    upstream_id = %self.id,
1382                    target = %selection.address,
1383                    h2_ping_interval_secs = self.http_version.h2_ping_interval.as_secs(),
1384                    "Configured H2 ping interval"
1385                );
1386            }
1387        }
1388
1389        trace!(
1390            upstream_id = %self.id,
1391            target = %selection.address,
1392            tls = self.tls_enabled,
1393            sni = %sni_hostname,
1394            idle_timeout_secs = self.pool_config.idle_timeout.as_secs(),
1395            http_max_version = self.http_version.max_version,
1396            "Created peer with Pingora connection pooling enabled"
1397        );
1398
1399        Ok(peer)
1400    }
1401
1402    /// Report connection result for a target
1403    ///
1404    /// On failure, the circuit breaker records the failure but the load balancer
1405    /// health status is only updated when the circuit breaker transitions to Open.
1406    /// This prevents a single connection error (e.g., a stale pooled connection
1407    /// reset) from permanently removing a target from the healthy pool.
1408    pub async fn report_result(&self, target: &str, success: bool) {
1409        trace!(
1410            upstream_id = %self.id,
1411            target = %target,
1412            success = success,
1413            "Reporting connection result"
1414        );
1415
1416        if success {
1417            if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1418                breaker.record_success();
1419                trace!(
1420                    upstream_id = %self.id,
1421                    target = %target,
1422                    "Recorded success in circuit breaker"
1423                );
1424            }
1425            self.load_balancer.report_health(target, true).await;
1426        } else {
1427            let breaker_opened =
1428                if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1429                    let opened = breaker.record_failure();
1430                    debug!(
1431                        upstream_id = %self.id,
1432                        target = %target,
1433                        circuit_breaker_opened = opened,
1434                        "Recorded failure in circuit breaker"
1435                    );
1436                    opened
1437                } else {
1438                    false
1439                };
1440
1441            // Only mark the target unhealthy in the load balancer when the
1442            // circuit breaker has actually opened (failure threshold reached).
1443            // Individual failures are tracked by the circuit breaker; the
1444            // upstream_peer selection loop already checks breaker state.
1445            if breaker_opened {
1446                self.load_balancer.report_health(target, false).await;
1447            }
1448
1449            self.stats.failures.fetch_add(1, Ordering::Relaxed);
1450            warn!(
1451                upstream_id = %self.id,
1452                target = %target,
1453                circuit_breaker_opened = breaker_opened,
1454                "Connection failure reported for target"
1455            );
1456        }
1457    }
1458
1459    /// Report request result with latency for adaptive load balancing
1460    ///
1461    /// This method passes latency information to the load balancer for
1462    /// adaptive weight adjustment. It updates circuit breakers and health
1463    /// status. On failure, health is only marked down when the circuit
1464    /// breaker transitions to Open, preventing stale connection resets
1465    /// from permanently removing targets.
1466    pub async fn report_result_with_latency(
1467        &self,
1468        target: &str,
1469        success: bool,
1470        latency: Option<Duration>,
1471    ) {
1472        trace!(
1473            upstream_id = %self.id,
1474            target = %target,
1475            success = success,
1476            latency_ms = latency.map(|l| l.as_millis() as u64),
1477            "Reporting result with latency for adaptive LB"
1478        );
1479
1480        // Update circuit breaker
1481        if success {
1482            if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1483                breaker.record_success();
1484            }
1485            // Always report success to the load balancer (restores health + records latency)
1486            self.load_balancer
1487                .report_result_with_latency(target, true, latency)
1488                .await;
1489        } else {
1490            let breaker_opened =
1491                if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1492                    breaker.record_failure()
1493                } else {
1494                    false
1495                };
1496            self.stats.failures.fetch_add(1, Ordering::Relaxed);
1497
1498            // Only propagate failure to the load balancer when the circuit
1499            // breaker has opened. This ensures adaptive LBs record the
1500            // health change and individual failures don't prematurely
1501            // remove targets from the healthy pool.
1502            if breaker_opened {
1503                self.load_balancer
1504                    .report_result_with_latency(target, false, latency)
1505                    .await;
1506            }
1507        }
1508    }
1509
1510    /// Get pool statistics
1511    pub fn stats(&self) -> &PoolStats {
1512        &self.stats
1513    }
1514
1515    /// Get pool ID
1516    pub fn id(&self) -> &UpstreamId {
1517        &self.id
1518    }
1519
1520    /// Get target count
1521    pub fn target_count(&self) -> usize {
1522        self.targets.len()
1523    }
1524
1525    /// Get pool configuration (for metrics/debugging)
1526    pub fn pool_config(&self) -> PoolConfigSnapshot {
1527        PoolConfigSnapshot {
1528            max_connections: self.pool_config.max_connections,
1529            max_idle: self.pool_config.max_idle,
1530            idle_timeout_secs: self.pool_config.idle_timeout.as_secs(),
1531            max_lifetime_secs: self.pool_config.max_lifetime.map(|d| d.as_secs()),
1532            connection_timeout_secs: self.pool_config.connection_timeout.as_secs(),
1533            read_timeout_secs: self.pool_config.read_timeout.as_secs(),
1534            write_timeout_secs: self.pool_config.write_timeout.as_secs(),
1535        }
1536    }
1537
1538    /// Check if the pool has any healthy targets.
1539    ///
1540    /// Returns true if at least one target is healthy, false if all targets are unhealthy.
1541    pub async fn has_healthy_targets(&self) -> bool {
1542        let healthy = self.load_balancer.healthy_targets().await;
1543        !healthy.is_empty()
1544    }
1545
1546    /// Select a target for shadow traffic (returns URL components)
1547    ///
1548    /// This is a simplified selection method for shadow requests that don't need
1549    /// full HttpPeer setup. Returns the target URL scheme, address, and port.
1550    pub async fn select_shadow_target(
1551        &self,
1552        context: Option<&RequestContext>,
1553    ) -> ZentinelResult<ShadowTarget> {
1554        // Use load balancer to select target
1555        let selection = self.load_balancer.select(context).await?;
1556
1557        // Check circuit breaker
1558        let breakers = self.circuit_breakers.read().await;
1559        if let Some(breaker) = breakers.get(&selection.address) {
1560            if !breaker.is_closed() {
1561                return Err(ZentinelError::upstream(
1562                    self.id.to_string(),
1563                    "Circuit breaker is open for shadow target",
1564                ));
1565            }
1566        }
1567
1568        // Parse address to get host and port
1569        let (host, port) = if selection.address.contains(':') {
1570            let parts: Vec<&str> = selection.address.rsplitn(2, ':').collect();
1571            if parts.len() == 2 {
1572                (
1573                    parts[1].to_string(),
1574                    parts[0]
1575                        .parse::<u16>()
1576                        .unwrap_or(if self.tls_enabled { 443 } else { 80 }),
1577                )
1578            } else {
1579                (
1580                    selection.address.clone(),
1581                    if self.tls_enabled { 443 } else { 80 },
1582                )
1583            }
1584        } else {
1585            (
1586                selection.address.clone(),
1587                if self.tls_enabled { 443 } else { 80 },
1588            )
1589        };
1590
1591        Ok(ShadowTarget {
1592            scheme: if self.tls_enabled { "https" } else { "http" }.to_string(),
1593            host,
1594            port,
1595            sni: self.tls_sni.clone(),
1596        })
1597    }
1598
1599    /// Check if TLS is enabled for this upstream
1600    pub fn is_tls_enabled(&self) -> bool {
1601        self.tls_enabled
1602    }
1603
1604    /// Get the number of currently active (in-flight) requests for this pool.
1605    ///
1606    /// Used by the drain tracker to determine when a pool has been fully
1607    /// drained after removal from config.
1608    pub fn active_request_count(&self) -> u64 {
1609        self.stats.active_requests.load(Ordering::Relaxed)
1610    }
1611
1612    /// Increment the active request counter. Called when a request is assigned
1613    /// to this pool.
1614    pub fn increment_active(&self) {
1615        self.stats.active_requests.fetch_add(1, Ordering::Relaxed);
1616    }
1617
1618    /// Decrement the active request counter. Called when a request completes
1619    /// (success or failure).
1620    pub fn decrement_active(&self) {
1621        self.stats.active_requests.fetch_sub(1, Ordering::Relaxed);
1622    }
1623
1624    /// Shutdown the pool
1625    ///
1626    /// Note: Pingora manages connection pooling internally, so we just log stats.
1627    pub async fn shutdown(&self) {
1628        info!(
1629            upstream_id = %self.id,
1630            target_count = self.targets.len(),
1631            total_requests = self.stats.requests.load(Ordering::Relaxed),
1632            total_successes = self.stats.successes.load(Ordering::Relaxed),
1633            total_failures = self.stats.failures.load(Ordering::Relaxed),
1634            "Shutting down upstream pool"
1635        );
1636        // Pingora handles connection cleanup internally
1637        debug!(upstream_id = %self.id, "Upstream pool shutdown complete");
1638    }
1639}