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}