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
895        // Assigns default CB config if not configured, such as when the stanza is missing
896        // (and None is set for CircuitBreakerConfig)
897        let cb_config = config.circuit_breaker.unwrap_or_default();
898
899        let mut circuit_breakers = HashMap::new();
900        for target in &targets {
901            trace!(
902                upstream_id = %config.id,
903                target = %target.full_address(),
904                "Initializing circuit breaker for target, configuration {:?}",
905                cb_config
906            );
907
908            circuit_breakers.insert(target.full_address(), CircuitBreaker::new(cb_config));
909        }
910
911        let pool = Self {
912            id: id.clone(),
913            targets,
914            load_balancer,
915            pool_config,
916            http_version,
917            tls_enabled,
918            tls_sni,
919            tls_config,
920            circuit_breakers: Arc::new(RwLock::new(circuit_breakers)),
921            stats: Arc::new(PoolStats::default()),
922        };
923
924        info!(
925            upstream_id = %id,
926            target_count = pool.targets.len(),
927            "Upstream pool created successfully"
928        );
929
930        Ok(pool)
931    }
932
933    /// Create load balancer based on algorithm
934    fn create_load_balancer(
935        algorithm: &LoadBalancingAlgorithm,
936        targets: &[UpstreamTarget],
937        config: &UpstreamConfig,
938    ) -> ZentinelResult<Arc<dyn LoadBalancer>> {
939        let balancer: Arc<dyn LoadBalancer> = match algorithm {
940            LoadBalancingAlgorithm::RoundRobin => {
941                Arc::new(RoundRobinBalancer::new(targets.to_vec()))
942            }
943            LoadBalancingAlgorithm::LeastConnections => {
944                Arc::new(LeastConnectionsBalancer::new(targets.to_vec()))
945            }
946            LoadBalancingAlgorithm::Weighted => {
947                let weights: Vec<u32> = targets.iter().map(|t| t.weight).collect();
948                Arc::new(WeightedBalancer {
949                    targets: targets.to_vec(),
950                    weights,
951                    current_index: AtomicUsize::new(0),
952                    health_status: Arc::new(RwLock::new(HashMap::new())),
953                })
954            }
955            LoadBalancingAlgorithm::IpHash => Arc::new(IpHashBalancer {
956                targets: targets.to_vec(),
957                health_status: Arc::new(RwLock::new(HashMap::new())),
958            }),
959            LoadBalancingAlgorithm::Random => Arc::new(RandomBalancer::new(targets.to_vec())),
960            LoadBalancingAlgorithm::ConsistentHash => Arc::new(ConsistentHashBalancer::new(
961                targets.to_vec(),
962                ConsistentHashConfig::default(),
963            )),
964            LoadBalancingAlgorithm::PowerOfTwoChoices => {
965                Arc::new(P2cBalancer::new(targets.to_vec(), P2cConfig::default()))
966            }
967            LoadBalancingAlgorithm::Adaptive => Arc::new(AdaptiveBalancer::new(
968                targets.to_vec(),
969                AdaptiveConfig::default(),
970            )),
971            LoadBalancingAlgorithm::LeastTokensQueued => Arc::new(LeastTokensQueuedBalancer::new(
972                targets.to_vec(),
973                LeastTokensQueuedConfig::default(),
974            )),
975            LoadBalancingAlgorithm::Maglev => Arc::new(MaglevBalancer::new(
976                targets.to_vec(),
977                MaglevConfig::default(),
978            )),
979            LoadBalancingAlgorithm::LocalityAware => Arc::new(LocalityAwareBalancer::new(
980                targets.to_vec(),
981                LocalityAwareConfig::default(),
982            )),
983            LoadBalancingAlgorithm::PeakEwma => Arc::new(PeakEwmaBalancer::new(
984                targets.to_vec(),
985                PeakEwmaConfig::default(),
986            )),
987            LoadBalancingAlgorithm::DeterministicSubset => Arc::new(SubsetBalancer::new(
988                targets.to_vec(),
989                SubsetConfig::default(),
990            )),
991            LoadBalancingAlgorithm::WeightedLeastConnections => {
992                Arc::new(WeightedLeastConnBalancer::new(
993                    targets.to_vec(),
994                    WeightedLeastConnConfig::default(),
995                ))
996            }
997            LoadBalancingAlgorithm::Sticky => {
998                // Get sticky session config (required for Sticky algorithm)
999                let sticky_config = config.sticky_session.as_ref().ok_or_else(|| {
1000                    ZentinelError::Config {
1001                        message: format!(
1002                            "Upstream '{}' uses Sticky algorithm but no sticky_session config provided",
1003                            config.id
1004                        ),
1005                        source: None,
1006                    }
1007                })?;
1008
1009                // Create runtime config with HMAC key
1010                let runtime_config = StickySessionRuntimeConfig::from_config(sticky_config);
1011
1012                // Create fallback load balancer
1013                let fallback = Self::create_load_balancer_inner(&sticky_config.fallback, targets)?;
1014
1015                info!(
1016                    upstream_id = %config.id,
1017                    cookie_name = %runtime_config.cookie_name,
1018                    cookie_ttl_secs = runtime_config.cookie_ttl_secs,
1019                    fallback_algorithm = ?sticky_config.fallback,
1020                    "Creating sticky session balancer"
1021                );
1022
1023                Arc::new(StickySessionBalancer::new(
1024                    targets.to_vec(),
1025                    runtime_config,
1026                    fallback,
1027                ))
1028            }
1029        };
1030        Ok(balancer)
1031    }
1032
1033    /// Create load balancer without sticky session support (for fallback balancers)
1034    fn create_load_balancer_inner(
1035        algorithm: &LoadBalancingAlgorithm,
1036        targets: &[UpstreamTarget],
1037    ) -> ZentinelResult<Arc<dyn LoadBalancer>> {
1038        let balancer: Arc<dyn LoadBalancer> = match algorithm {
1039            LoadBalancingAlgorithm::RoundRobin => {
1040                Arc::new(RoundRobinBalancer::new(targets.to_vec()))
1041            }
1042            LoadBalancingAlgorithm::LeastConnections => {
1043                Arc::new(LeastConnectionsBalancer::new(targets.to_vec()))
1044            }
1045            LoadBalancingAlgorithm::Weighted => {
1046                let weights: Vec<u32> = targets.iter().map(|t| t.weight).collect();
1047                Arc::new(WeightedBalancer {
1048                    targets: targets.to_vec(),
1049                    weights,
1050                    current_index: AtomicUsize::new(0),
1051                    health_status: Arc::new(RwLock::new(HashMap::new())),
1052                })
1053            }
1054            LoadBalancingAlgorithm::IpHash => Arc::new(IpHashBalancer {
1055                targets: targets.to_vec(),
1056                health_status: Arc::new(RwLock::new(HashMap::new())),
1057            }),
1058            LoadBalancingAlgorithm::Random => Arc::new(RandomBalancer::new(targets.to_vec())),
1059            LoadBalancingAlgorithm::ConsistentHash => Arc::new(ConsistentHashBalancer::new(
1060                targets.to_vec(),
1061                ConsistentHashConfig::default(),
1062            )),
1063            LoadBalancingAlgorithm::PowerOfTwoChoices => {
1064                Arc::new(P2cBalancer::new(targets.to_vec(), P2cConfig::default()))
1065            }
1066            LoadBalancingAlgorithm::Adaptive => Arc::new(AdaptiveBalancer::new(
1067                targets.to_vec(),
1068                AdaptiveConfig::default(),
1069            )),
1070            LoadBalancingAlgorithm::LeastTokensQueued => Arc::new(LeastTokensQueuedBalancer::new(
1071                targets.to_vec(),
1072                LeastTokensQueuedConfig::default(),
1073            )),
1074            LoadBalancingAlgorithm::Maglev => Arc::new(MaglevBalancer::new(
1075                targets.to_vec(),
1076                MaglevConfig::default(),
1077            )),
1078            LoadBalancingAlgorithm::LocalityAware => Arc::new(LocalityAwareBalancer::new(
1079                targets.to_vec(),
1080                LocalityAwareConfig::default(),
1081            )),
1082            LoadBalancingAlgorithm::PeakEwma => Arc::new(PeakEwmaBalancer::new(
1083                targets.to_vec(),
1084                PeakEwmaConfig::default(),
1085            )),
1086            LoadBalancingAlgorithm::DeterministicSubset => Arc::new(SubsetBalancer::new(
1087                targets.to_vec(),
1088                SubsetConfig::default(),
1089            )),
1090            LoadBalancingAlgorithm::WeightedLeastConnections => {
1091                Arc::new(WeightedLeastConnBalancer::new(
1092                    targets.to_vec(),
1093                    WeightedLeastConnConfig::default(),
1094                ))
1095            }
1096            LoadBalancingAlgorithm::Sticky => {
1097                // Sticky cannot be used as fallback (would cause infinite recursion)
1098                return Err(ZentinelError::Config {
1099                    message: "Sticky algorithm cannot be used as fallback for sticky sessions"
1100                        .to_string(),
1101                    source: None,
1102                });
1103            }
1104        };
1105        Ok(balancer)
1106    }
1107
1108    /// Select next upstream peer with selection metadata
1109    ///
1110    /// Returns the selected peer along with optional metadata from the load balancer.
1111    /// The metadata can contain sticky session information that should be passed to
1112    /// the response filter.
1113    pub async fn select_peer_with_metadata(
1114        &self,
1115        context: Option<&RequestContext>,
1116    ) -> ZentinelResult<(HttpPeer, HashMap<String, String>)> {
1117        let request_num = self.stats.requests.fetch_add(1, Ordering::Relaxed) + 1;
1118
1119        trace!(
1120            upstream_id = %self.id,
1121            request_num = request_num,
1122            target_count = self.targets.len(),
1123            "Starting peer selection with metadata"
1124        );
1125
1126        let mut attempts = 0;
1127        let max_attempts = self.targets.len() * 2;
1128
1129        while attempts < max_attempts {
1130            attempts += 1;
1131
1132            trace!(
1133                upstream_id = %self.id,
1134                attempt = attempts,
1135                max_attempts = max_attempts,
1136                "Attempting to select peer"
1137            );
1138
1139            let selection = match self.load_balancer.select(context).await {
1140                Ok(s) => s,
1141                Err(e) => {
1142                    warn!(
1143                        upstream_id = %self.id,
1144                        attempt = attempts,
1145                        error = %e,
1146                        "Load balancer selection failed"
1147                    );
1148                    continue;
1149                }
1150            };
1151
1152            trace!(
1153                upstream_id = %self.id,
1154                target = %selection.address,
1155                attempt = attempts,
1156                "Load balancer selected target"
1157            );
1158
1159            // Check circuit breaker
1160            let breakers = self.circuit_breakers.read().await;
1161            if let Some(breaker) = breakers.get(&selection.address) {
1162                if !breaker.is_closed() {
1163                    debug!(
1164                        upstream_id = %self.id,
1165                        target = %selection.address,
1166                        attempt = attempts,
1167                        "Circuit breaker is open, skipping target"
1168                    );
1169                    self.stats
1170                        .circuit_breaker_trips
1171                        .fetch_add(1, Ordering::Relaxed);
1172                    continue;
1173                }
1174            }
1175
1176            // Create peer with pooling options
1177            trace!(
1178                upstream_id = %self.id,
1179                target = %selection.address,
1180                "Creating peer for upstream (Pingora handles connection reuse)"
1181            );
1182            let peer = self.create_peer(&selection)?;
1183
1184            debug!(
1185                upstream_id = %self.id,
1186                target = %selection.address,
1187                attempt = attempts,
1188                metadata_keys = ?selection.metadata.keys().collect::<Vec<_>>(),
1189                "Selected upstream peer with metadata"
1190            );
1191
1192            self.stats.successes.fetch_add(1, Ordering::Relaxed);
1193            return Ok((peer, selection.metadata));
1194        }
1195
1196        self.stats.failures.fetch_add(1, Ordering::Relaxed);
1197        error!(
1198            upstream_id = %self.id,
1199            attempts = attempts,
1200            max_attempts = max_attempts,
1201            "Failed to select upstream after max attempts"
1202        );
1203        Err(ZentinelError::upstream(
1204            self.id.to_string(),
1205            "Failed to select upstream after max attempts",
1206        ))
1207    }
1208
1209    /// Select next upstream peer
1210    pub async fn select_peer(&self, context: Option<&RequestContext>) -> ZentinelResult<HttpPeer> {
1211        // Delegate to select_peer_with_metadata and discard metadata
1212        self.select_peer_with_metadata(context)
1213            .await
1214            .map(|(peer, _)| peer)
1215    }
1216
1217    /// Create new peer connection with connection pooling options
1218    ///
1219    /// Pingora handles actual connection pooling internally. When idle_timeout
1220    /// is set on the peer options, Pingora will keep the connection alive and
1221    /// reuse it for subsequent requests to the same upstream.
1222    fn create_peer(&self, selection: &TargetSelection) -> ZentinelResult<HttpPeer> {
1223        // Determine SNI hostname for TLS connections
1224        let sni_hostname = self.tls_sni.clone().unwrap_or_else(|| {
1225            // Extract hostname from address (strip port)
1226            selection
1227                .address
1228                .split(':')
1229                .next()
1230                .unwrap_or(&selection.address)
1231                .to_string()
1232        });
1233
1234        // Pre-resolve the address to avoid panics in Pingora's HttpPeer::new
1235        // when DNS resolution fails (e.g., when a container is killed)
1236        let resolved_address = selection
1237            .address
1238            .to_socket_addrs()
1239            .map_err(|e| {
1240                error!(
1241                    upstream = %self.id,
1242                    address = %selection.address,
1243                    error = %e,
1244                    "Failed to resolve upstream address"
1245                );
1246                ZentinelError::Upstream {
1247                    upstream: self.id.to_string(),
1248                    message: format!("DNS resolution failed for {}: {}", selection.address, e),
1249                    retryable: true,
1250                    source: None,
1251                }
1252            })?
1253            .next()
1254            .ok_or_else(|| {
1255                error!(
1256                    upstream = %self.id,
1257                    address = %selection.address,
1258                    "No addresses returned from DNS resolution"
1259                );
1260                ZentinelError::Upstream {
1261                    upstream: self.id.to_string(),
1262                    message: format!("No addresses for {}", selection.address),
1263                    retryable: true,
1264                    source: None,
1265                }
1266            })?;
1267
1268        // Use the resolved IP address to create the peer
1269        let mut peer = HttpPeer::new(resolved_address, self.tls_enabled, sni_hostname.clone());
1270
1271        // Configure connection pooling options for better performance
1272        // idle_timeout enables Pingora's connection pooling - connections are
1273        // kept alive and reused for this duration
1274        peer.options.idle_timeout = Some(self.pool_config.idle_timeout);
1275
1276        // Connection timeouts
1277        peer.options.connection_timeout = Some(self.pool_config.connection_timeout);
1278        peer.options.total_connection_timeout = Some(Duration::from_secs(10));
1279
1280        // Read/write timeouts
1281        peer.options.read_timeout = Some(self.pool_config.read_timeout);
1282        peer.options.write_timeout = Some(self.pool_config.write_timeout);
1283
1284        // Enable TCP keepalive for long-lived connections
1285        peer.options.tcp_keepalive = Some(pingora::protocols::TcpKeepalive {
1286            idle: Duration::from_secs(60),
1287            interval: Duration::from_secs(10),
1288            count: 3,
1289            // user_timeout is Linux-only
1290            #[cfg(target_os = "linux")]
1291            user_timeout: Duration::from_secs(60),
1292        });
1293
1294        // Configure HTTP version and ALPN for TLS connections
1295        if self.tls_enabled {
1296            // Set ALPN protocols based on configured HTTP version range
1297            let alpn = match (self.http_version.min_version, self.http_version.max_version) {
1298                (2, _) => {
1299                    // HTTP/2 only - use h2 ALPN
1300                    pingora::upstreams::peer::ALPN::H2
1301                }
1302                (1, 2) | (_, 2) => {
1303                    // Prefer HTTP/2 but fall back to HTTP/1.1
1304                    pingora::upstreams::peer::ALPN::H2H1
1305                }
1306                _ => {
1307                    // HTTP/1.1 only
1308                    pingora::upstreams::peer::ALPN::H1
1309                }
1310            };
1311            peer.options.alpn = alpn;
1312
1313            // Configure TLS verification options based on upstream config
1314            if let Some(ref tls_config) = self.tls_config {
1315                // Skip certificate verification if configured (DANGEROUS - testing only)
1316                if tls_config.insecure_skip_verify {
1317                    peer.options.verify_cert = false;
1318                    peer.options.verify_hostname = false;
1319                    warn!(
1320                        upstream_id = %self.id,
1321                        target = %selection.address,
1322                        "TLS certificate verification DISABLED (insecure_skip_verify=true)"
1323                    );
1324                }
1325
1326                // Set alternative CN for verification if SNI differs from actual hostname
1327                if let Some(ref sni) = tls_config.sni {
1328                    peer.options.alternative_cn = Some(sni.clone());
1329                    trace!(
1330                        upstream_id = %self.id,
1331                        target = %selection.address,
1332                        alternative_cn = %sni,
1333                        "Set alternative CN for TLS verification"
1334                    );
1335                }
1336
1337                // Configure mTLS client certificate if provided
1338                if let (Some(cert_path), Some(key_path)) =
1339                    (&tls_config.client_cert, &tls_config.client_key)
1340                {
1341                    match crate::tls::load_client_cert_key(cert_path, key_path) {
1342                        Ok(cert_key) => {
1343                            peer.client_cert_key = Some(cert_key);
1344                            info!(
1345                                upstream_id = %self.id,
1346                                target = %selection.address,
1347                                cert_path = ?cert_path,
1348                                "mTLS client certificate configured"
1349                            );
1350                        }
1351                        Err(e) => {
1352                            error!(
1353                                upstream_id = %self.id,
1354                                target = %selection.address,
1355                                error = %e,
1356                                "Failed to load mTLS client certificate"
1357                            );
1358                            return Err(ZentinelError::Tls {
1359                                message: format!("Failed to load client certificate: {}", e),
1360                                source: None,
1361                            });
1362                        }
1363                    }
1364                }
1365            }
1366
1367            trace!(
1368                upstream_id = %self.id,
1369                target = %selection.address,
1370                alpn = ?peer.options.alpn,
1371                min_version = self.http_version.min_version,
1372                max_version = self.http_version.max_version,
1373                verify_cert = peer.options.verify_cert,
1374                verify_hostname = peer.options.verify_hostname,
1375                "Configured ALPN and TLS options for HTTP version negotiation"
1376            );
1377        }
1378
1379        // Configure H2-specific settings when HTTP/2 is enabled
1380        if self.http_version.max_version >= 2 {
1381            // H2 ping interval for connection health monitoring
1382            if !self.http_version.h2_ping_interval.is_zero() {
1383                peer.options.h2_ping_interval = Some(self.http_version.h2_ping_interval);
1384                trace!(
1385                    upstream_id = %self.id,
1386                    target = %selection.address,
1387                    h2_ping_interval_secs = self.http_version.h2_ping_interval.as_secs(),
1388                    "Configured H2 ping interval"
1389                );
1390            }
1391        }
1392
1393        trace!(
1394            upstream_id = %self.id,
1395            target = %selection.address,
1396            tls = self.tls_enabled,
1397            sni = %sni_hostname,
1398            idle_timeout_secs = self.pool_config.idle_timeout.as_secs(),
1399            http_max_version = self.http_version.max_version,
1400            "Created peer with Pingora connection pooling enabled"
1401        );
1402
1403        Ok(peer)
1404    }
1405
1406    /// Report connection result for a target
1407    ///
1408    /// On failure, the circuit breaker records the failure but the load balancer
1409    /// health status is only updated when the circuit breaker transitions to Open.
1410    /// This prevents a single connection error (e.g., a stale pooled connection
1411    /// reset) from permanently removing a target from the healthy pool.
1412    pub async fn report_result(&self, target: &str, success: bool) {
1413        trace!(
1414            upstream_id = %self.id,
1415            target = %target,
1416            success = success,
1417            "Reporting connection result"
1418        );
1419
1420        if success {
1421            if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1422                breaker.record_success();
1423                trace!(
1424                    upstream_id = %self.id,
1425                    target = %target,
1426                    "Recorded success in circuit breaker"
1427                );
1428            }
1429            self.load_balancer.report_health(target, true).await;
1430        } else {
1431            let breaker_opened =
1432                if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1433                    let opened = breaker.record_failure();
1434                    debug!(
1435                        upstream_id = %self.id,
1436                        target = %target,
1437                        circuit_breaker_opened = opened,
1438                        "Recorded failure in circuit breaker"
1439                    );
1440                    opened
1441                } else {
1442                    false
1443                };
1444
1445            // Do NOT mark the target down in the load balancer when the breaker
1446            // opens. The circuit breaker is the single availability gate — the
1447            // upstream_peer selection loop checks `is_closed()`, which also runs
1448            // the timed Open->HalfOpen transition and lets a probe through after
1449            // `timeout_seconds`. Removing the target from the load balancer here
1450            // would prevent it from ever being selected again, so that probe
1451            // would never run and the target could not recover (#261).
1452
1453            self.stats.failures.fetch_add(1, Ordering::Relaxed);
1454            warn!(
1455                upstream_id = %self.id,
1456                target = %target,
1457                circuit_breaker_opened = breaker_opened,
1458                "Connection failure reported for target"
1459            );
1460        }
1461    }
1462
1463    /// Report request result with latency for adaptive load balancing
1464    ///
1465    /// This method passes latency information to the load balancer for
1466    /// adaptive weight adjustment. It updates circuit breakers and health
1467    /// status. On failure, health is only marked down when the circuit
1468    /// breaker transitions to Open, preventing stale connection resets
1469    /// from permanently removing targets.
1470    pub async fn report_result_with_latency(
1471        &self,
1472        target: &str,
1473        success: bool,
1474        latency: Option<Duration>,
1475    ) {
1476        trace!(
1477            upstream_id = %self.id,
1478            target = %target,
1479            success = success,
1480            latency_ms = latency.map(|l| l.as_millis() as u64),
1481            "Reporting result with latency for adaptive LB"
1482        );
1483
1484        // Update circuit breaker
1485        if success {
1486            if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1487                breaker.record_success();
1488            }
1489            // Always report success to the load balancer (restores health + records latency)
1490            self.load_balancer
1491                .report_result_with_latency(target, true, latency)
1492                .await;
1493        } else {
1494            // Record the failure in the circuit breaker (this may open it). We
1495            // do NOT propagate a health-down to the load balancer on open: the
1496            // selection loop's `is_closed()` check is the sole availability gate
1497            // and runs the timed half-open recovery probe. Marking the target
1498            // down here would remove it from selection and block recovery (#261).
1499            if let Some(breaker) = self.circuit_breakers.read().await.get(target) {
1500                breaker.record_failure();
1501            }
1502            self.stats.failures.fetch_add(1, Ordering::Relaxed);
1503        }
1504    }
1505
1506    /// Get pool statistics
1507    pub fn stats(&self) -> &PoolStats {
1508        &self.stats
1509    }
1510
1511    /// Get pool ID
1512    pub fn id(&self) -> &UpstreamId {
1513        &self.id
1514    }
1515
1516    /// Get target count
1517    pub fn target_count(&self) -> usize {
1518        self.targets.len()
1519    }
1520
1521    /// Get pool configuration (for metrics/debugging)
1522    pub fn pool_config(&self) -> PoolConfigSnapshot {
1523        PoolConfigSnapshot {
1524            max_connections: self.pool_config.max_connections,
1525            max_idle: self.pool_config.max_idle,
1526            idle_timeout_secs: self.pool_config.idle_timeout.as_secs(),
1527            max_lifetime_secs: self.pool_config.max_lifetime.map(|d| d.as_secs()),
1528            connection_timeout_secs: self.pool_config.connection_timeout.as_secs(),
1529            read_timeout_secs: self.pool_config.read_timeout.as_secs(),
1530            write_timeout_secs: self.pool_config.write_timeout.as_secs(),
1531        }
1532    }
1533
1534    /// Check if the pool has any healthy targets.
1535    ///
1536    /// Returns true if at least one target is healthy, false if all targets are unhealthy.
1537    pub async fn has_healthy_targets(&self) -> bool {
1538        let healthy = self.load_balancer.healthy_targets().await;
1539        !healthy.is_empty()
1540    }
1541
1542    /// Select a target for shadow traffic (returns URL components)
1543    ///
1544    /// This is a simplified selection method for shadow requests that don't need
1545    /// full HttpPeer setup. Returns the target URL scheme, address, and port.
1546    pub async fn select_shadow_target(
1547        &self,
1548        context: Option<&RequestContext>,
1549    ) -> ZentinelResult<ShadowTarget> {
1550        // Use load balancer to select target
1551        let selection = self.load_balancer.select(context).await?;
1552
1553        // Check circuit breaker
1554        let breakers = self.circuit_breakers.read().await;
1555        if let Some(breaker) = breakers.get(&selection.address) {
1556            if !breaker.is_closed() {
1557                return Err(ZentinelError::upstream(
1558                    self.id.to_string(),
1559                    "Circuit breaker is open for shadow target",
1560                ));
1561            }
1562        }
1563
1564        // Parse address to get host and port
1565        let (host, port) = if selection.address.contains(':') {
1566            let parts: Vec<&str> = selection.address.rsplitn(2, ':').collect();
1567            if parts.len() == 2 {
1568                (
1569                    parts[1].to_string(),
1570                    parts[0]
1571                        .parse::<u16>()
1572                        .unwrap_or(if self.tls_enabled { 443 } else { 80 }),
1573                )
1574            } else {
1575                (
1576                    selection.address.clone(),
1577                    if self.tls_enabled { 443 } else { 80 },
1578                )
1579            }
1580        } else {
1581            (
1582                selection.address.clone(),
1583                if self.tls_enabled { 443 } else { 80 },
1584            )
1585        };
1586
1587        Ok(ShadowTarget {
1588            scheme: if self.tls_enabled { "https" } else { "http" }.to_string(),
1589            host,
1590            port,
1591            sni: self.tls_sni.clone(),
1592        })
1593    }
1594
1595    /// Check if TLS is enabled for this upstream
1596    pub fn is_tls_enabled(&self) -> bool {
1597        self.tls_enabled
1598    }
1599
1600    /// Get the number of currently active (in-flight) requests for this pool.
1601    ///
1602    /// Used by the drain tracker to determine when a pool has been fully
1603    /// drained after removal from config.
1604    pub fn active_request_count(&self) -> u64 {
1605        self.stats.active_requests.load(Ordering::Relaxed)
1606    }
1607
1608    /// Increment the active request counter. Called when a request is assigned
1609    /// to this pool.
1610    pub fn increment_active(&self) {
1611        self.stats.active_requests.fetch_add(1, Ordering::Relaxed);
1612    }
1613
1614    /// Decrement the active request counter. Called when a request completes
1615    /// (success or failure).
1616    pub fn decrement_active(&self) {
1617        let prev = self.stats.active_requests.fetch_sub(1, Ordering::Relaxed);
1618        if prev == 0 {
1619            self.stats.active_requests.fetch_add(1, Ordering::Relaxed);
1620            warn!("Attempted to decrement active request count below zero");
1621        }
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}