Skip to main content

fraiseql_server/pool/
auto_tuner.rs

1//! Adaptive connection pool auto-tuner.
2//!
3//! Monitors connection pool health via [`PoolMetrics`] and either resizes the
4//! pool or emits a recommended size when the queue depth or idle ratio crosses
5//! configured thresholds.
6
7use std::{
8    sync::{
9        Arc,
10        atomic::{AtomicU32, AtomicU64, Ordering},
11    },
12    time::Duration,
13};
14
15use fraiseql_core::db::{traits::DatabaseAdapter, types::PoolMetrics};
16
17use crate::config::pool_tuning::PoolPressureMonitorConfig;
18
19/// Recommendation produced by [`PoolSizingAdvisor::evaluate`].
20///
21/// `RecommendScaleUp` and `RecommendScaleDown` are *recommendations*, not executed actions.
22/// Whether they are applied depends on whether a `resize_fn` was configured in
23/// [`PoolSizingAdvisor::start`]. Without a `resize_fn`, decisions are logged at INFO level
24/// only — no actual pool resize occurs.
25#[derive(Debug, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum PoolSizingRecommendation {
28    /// Pool size is appropriate. No action needed.
29    Stable,
30    /// Pool should be grown to `new_size` connections.
31    ///
32    /// Applied only if a `resize_fn` was configured; otherwise logged as an advisory.
33    RecommendScaleUp {
34        /// New target pool size.
35        new_size: u32,
36        /// Human-readable reason for the recommendation.
37        reason:   String,
38    },
39    /// Pool should be shrunk to `new_size` connections.
40    ///
41    /// Applied only if a `resize_fn` was configured; otherwise logged as an advisory.
42    RecommendScaleDown {
43        /// New target pool size.
44        new_size: u32,
45        /// Human-readable reason for the recommendation.
46        reason:   String,
47    },
48}
49
50/// Connection pool pressure monitor with scaling recommendations.
51///
52/// Call [`PoolSizingAdvisor::evaluate`] with current [`PoolMetrics`] to get a
53/// [`PoolSizingRecommendation`], or call [`PoolSizingAdvisor::start`] to launch a
54/// background task that polls the adapter automatically.
55///
56/// **Note**: This monitor operates in recommendation mode only. The pool is not
57/// resized at runtime — act on `fraiseql_pool_tuning_*` events by adjusting
58/// `max_connections` in `fraiseql.toml` and restarting the server.
59pub struct PoolSizingAdvisor {
60    /// Pressure monitoring configuration.
61    pub(crate) config:         PoolPressureMonitorConfig,
62    /// Consecutive samples with high queue depth.
63    high_queue_samples:        AtomicU32,
64    /// Consecutive samples with high idle ratio.
65    low_idle_samples:          AtomicU32,
66    /// Total resize operations applied or recommended.
67    adjustments_total:         AtomicU64,
68    /// Current recommended/actual target pool size (0 = not yet sampled).
69    pub(crate) current_target: AtomicU32,
70}
71
72impl PoolSizingAdvisor {
73    /// Create a new pool pressure monitor with the given configuration.
74    #[must_use]
75    pub const fn new(config: PoolPressureMonitorConfig) -> Self {
76        Self {
77            config,
78            high_queue_samples: AtomicU32::new(0),
79            low_idle_samples: AtomicU32::new(0),
80            adjustments_total: AtomicU64::new(0),
81            current_target: AtomicU32::new(0),
82        }
83    }
84
85    /// Evaluate current pool metrics and return a scaling decision.
86    ///
87    /// This method is pure computation — no I/O, no async.  It updates internal
88    /// sample counters so consecutive calls with the same condition accumulate
89    /// toward `samples_before_action`.
90    pub fn evaluate(&self, metrics: &PoolMetrics) -> PoolSizingRecommendation {
91        let current = self.current_size(metrics);
92        let min = self.config.min_pool_size;
93        let max = self.config.max_pool_size;
94
95        // ── Scale-up check ──────────────────────────────────────────────────
96        if metrics.waiting_requests > self.config.target_queue_depth {
97            let count = self.high_queue_samples.fetch_add(1, Ordering::Relaxed) + 1;
98            self.low_idle_samples.store(0, Ordering::Relaxed);
99
100            if count >= self.config.samples_before_action {
101                let desired = (current + self.config.scale_up_step).min(max);
102                if desired > current {
103                    self.high_queue_samples.store(0, Ordering::Relaxed);
104                    self.adjustments_total.fetch_add(1, Ordering::Relaxed);
105                    self.current_target.store(desired, Ordering::Relaxed);
106                    return PoolSizingRecommendation::RecommendScaleUp {
107                        new_size: desired,
108                        reason:   format!(
109                            "{} requests waiting (threshold {}); grown by {}",
110                            metrics.waiting_requests,
111                            self.config.target_queue_depth,
112                            self.config.scale_up_step,
113                        ),
114                    };
115                }
116                // Already at max — reset and stay stable
117                self.high_queue_samples.store(0, Ordering::Relaxed);
118            }
119            return PoolSizingRecommendation::Stable;
120        }
121
122        self.high_queue_samples.store(0, Ordering::Relaxed);
123
124        // ── Scale-down check ─────────────────────────────────────────────────
125        if current > min && metrics.total_connections > 0 {
126            let idle_ratio =
127                f64::from(metrics.idle_connections) / f64::from(metrics.total_connections);
128
129            if idle_ratio > self.config.scale_down_idle_ratio && metrics.waiting_requests == 0 {
130                let count = self.low_idle_samples.fetch_add(1, Ordering::Relaxed) + 1;
131
132                if count >= self.config.samples_before_action {
133                    let desired = current.saturating_sub(self.config.scale_down_step).max(min);
134                    self.low_idle_samples.store(0, Ordering::Relaxed);
135                    self.adjustments_total.fetch_add(1, Ordering::Relaxed);
136                    self.current_target.store(desired, Ordering::Relaxed);
137                    return PoolSizingRecommendation::RecommendScaleDown {
138                        new_size: desired,
139                        reason:   format!(
140                            "idle ratio {:.0}% > {:.0}% threshold; shrunk by {}",
141                            idle_ratio * 100.0,
142                            self.config.scale_down_idle_ratio * 100.0,
143                            self.config.scale_down_step,
144                        ),
145                    };
146                }
147                return PoolSizingRecommendation::Stable;
148            }
149        }
150
151        self.low_idle_samples.store(0, Ordering::Relaxed);
152        PoolSizingRecommendation::Stable
153    }
154
155    /// Total number of resize operations applied or recommended.
156    pub fn adjustments_total(&self) -> u64 {
157        self.adjustments_total.load(Ordering::Relaxed)
158    }
159
160    /// Current recommended pool size (0 = not yet sampled).
161    pub fn recommended_size(&self) -> u32 {
162        self.current_target.load(Ordering::Relaxed)
163    }
164
165    /// Start a background polling task.
166    ///
167    /// The task samples `adapter.pool_metrics()` every `tuning_interval_ms`
168    /// milliseconds and calls [`Self::evaluate`].  If `resize_fn` is
169    /// provided, it is called with the new pool size whenever a resize is
170    /// decided.  If `resize_fn` is `None`, the tuner operates in
171    /// **recommendation mode**: it updates `recommended_size` and logs a
172    /// warning without modifying the pool.
173    ///
174    /// Returns a [`tokio::task::JoinHandle`] that can be aborted for shutdown.
175    pub fn start<A: DatabaseAdapter + 'static>(
176        self: Arc<Self>,
177        adapter: Arc<A>,
178        resize_fn: Option<Arc<dyn Fn(usize) + Send + Sync>>,
179    ) -> tokio::task::JoinHandle<()> {
180        let interval_ms = self.config.tuning_interval_ms;
181
182        tokio::spawn(async move {
183            tracing::debug!(
184                "Pool pressure monitoring enabled (recommendation mode). \
185                 The pool cannot be resized at runtime; act on \
186                 fraiseql_pool_scaling_recommended events by adjusting \
187                 max_connections and restarting."
188            );
189            if resize_fn.is_none() {
190                tracing::debug!(
191                    "No resize_fn configured — pool pressure monitor is in \
192                     pure advisory mode. Recommendations will appear in \
193                     fraiseql_pool_tuning_* metrics and WARN log lines."
194                );
195            }
196
197            let mut ticker = tokio::time::interval(Duration::from_millis(interval_ms.max(1)));
198            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
199
200            loop {
201                ticker.tick().await;
202                let metrics = adapter.pool_metrics();
203
204                match self.evaluate(&metrics) {
205                    PoolSizingRecommendation::Stable => {},
206                    PoolSizingRecommendation::RecommendScaleUp {
207                        new_size,
208                        ref reason,
209                    } => {
210                        if let Some(ref f) = resize_fn {
211                            tracing::info!(
212                                new_size,
213                                reason = reason.as_str(),
214                                "Pool auto-tuner: scaling up"
215                            );
216                            f(new_size as usize);
217                        } else {
218                            tracing::warn!(
219                                new_size,
220                                reason = reason.as_str(),
221                                "Pool auto-tuner recommends scaling up \
222                                 (resize not available — configure resize_fn)"
223                            );
224                        }
225                    },
226                    PoolSizingRecommendation::RecommendScaleDown {
227                        new_size,
228                        ref reason,
229                    } => {
230                        if let Some(ref f) = resize_fn {
231                            tracing::info!(
232                                new_size,
233                                reason = reason.as_str(),
234                                "Pool auto-tuner: scaling down"
235                            );
236                            f(new_size as usize);
237                        } else {
238                            tracing::warn!(
239                                new_size,
240                                reason = reason.as_str(),
241                                "Pool auto-tuner recommends scaling down \
242                                 (resize not available — configure resize_fn)"
243                            );
244                        }
245                    },
246                }
247            }
248        })
249    }
250
251    /// Current pool size from metrics, falling back to `min_pool_size`.
252    fn current_size(&self, metrics: &PoolMetrics) -> u32 {
253        let recorded = self.current_target.load(Ordering::Relaxed);
254        if recorded > 0 {
255            recorded
256        } else if metrics.total_connections > 0 {
257            metrics.total_connections
258        } else {
259            self.config.min_pool_size
260        }
261    }
262}