Skip to main content

fraiseql_server/config/
pool_tuning.rs

1//! Connection pool pressure monitoring configuration.
2
3use serde::{Deserialize, Serialize};
4
5/// Configuration for connection pool pressure monitoring with scaling recommendations.
6///
7/// This monitor samples `PoolMetrics` at a configurable interval and emits
8/// scaling recommendations via `fraiseql_pool_tuning_*` Prometheus metrics and
9/// log lines. **It does not resize the pool at runtime** — the underlying
10/// `deadpool-postgres` library does not expose a `resize()` API.
11///
12/// To act on recommendations: adjust `max_connections` in `fraiseql.toml` and
13/// restart the server. Active pool resizing is tracked as future work (migration
14/// to `bb8` with `resize()` support).
15///
16/// # Recommendation mode
17///
18/// All scaling decisions are advisory. When a recommendation fires, the monitor:
19/// - Updates `fraiseql_pool_tuning_adjustments_total` (Prometheus counter)
20/// - Logs the recommendation at `WARN` level
21/// - Updates `recommended_size()` for external inspection
22///
23/// To suppress the `WARN` noise in environments that already tune the pool
24/// manually, set `enabled = false` in `[pool_tuning]`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PoolPressureMonitorConfig {
27    /// Enable adaptive pool sizing.  Default: `false`.
28    #[serde(default)]
29    pub enabled: bool,
30
31    /// Minimum pool size.  The tuner never shrinks below this value.  Default: 5.
32    #[serde(default = "default_min_pool_size")]
33    pub min_pool_size: u32,
34
35    /// Maximum pool size.  The tuner never grows above this value.  Default: 50.
36    #[serde(default = "default_max_pool_size")]
37    pub max_pool_size: u32,
38
39    /// Maximum acceptable queue depth before scaling up.  Default: 3.
40    #[serde(default = "default_target_queue_depth")]
41    pub target_queue_depth: u32,
42
43    /// Connections to add per scale-up step.  Default: 5.
44    #[serde(default = "default_scale_up_step")]
45    pub scale_up_step: u32,
46
47    /// Connections to remove per scale-down step.  Default: 2.
48    #[serde(default = "default_scale_down_step")]
49    pub scale_down_step: u32,
50
51    /// Minimum idle ratio (idle / total) before considering a scale-down.
52    /// Default: 0.5 (50% idle connections triggers potential shrink).
53    #[serde(default = "default_scale_down_idle_ratio")]
54    pub scale_down_idle_ratio: f64,
55
56    /// Polling interval in milliseconds.  Default: 30 000 (30 s).
57    #[serde(default = "default_tuning_interval_ms")]
58    pub tuning_interval_ms: u64,
59
60    /// Consecutive samples above threshold required before acting.  Default: 3.
61    #[serde(default = "default_samples_before_action")]
62    pub samples_before_action: u32,
63}
64
65const fn default_min_pool_size() -> u32 {
66    5
67}
68const fn default_max_pool_size() -> u32 {
69    50
70}
71const fn default_target_queue_depth() -> u32 {
72    3
73}
74const fn default_scale_up_step() -> u32 {
75    5
76}
77const fn default_scale_down_step() -> u32 {
78    2
79}
80const fn default_scale_down_idle_ratio() -> f64 {
81    0.5
82}
83const fn default_tuning_interval_ms() -> u64 {
84    30_000
85}
86const fn default_samples_before_action() -> u32 {
87    3
88}
89
90impl Default for PoolPressureMonitorConfig {
91    fn default() -> Self {
92        Self {
93            enabled:               false,
94            min_pool_size:         default_min_pool_size(),
95            max_pool_size:         default_max_pool_size(),
96            target_queue_depth:    default_target_queue_depth(),
97            scale_up_step:         default_scale_up_step(),
98            scale_down_step:       default_scale_down_step(),
99            scale_down_idle_ratio: default_scale_down_idle_ratio(),
100            tuning_interval_ms:    default_tuning_interval_ms(),
101            samples_before_action: default_samples_before_action(),
102        }
103    }
104}
105
106/// Deprecated alias for [`PoolPressureMonitorConfig`].
107///
108/// This type was renamed in v2.0.1 to clarify that pool monitoring operates in
109/// recommendation mode only — the pool is not resized at runtime.
110/// Use [`PoolPressureMonitorConfig`] in new code.
111#[deprecated(since = "2.0.1", note = "Use PoolPressureMonitorConfig")]
112pub type PoolTuningConfig = PoolPressureMonitorConfig;
113
114impl PoolPressureMonitorConfig {
115    /// Validate configuration invariants.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error string if:
120    /// - `min_pool_size >= max_pool_size`
121    /// - `scale_up_step == 0` or `scale_down_step == 0`
122    /// - `scale_down_idle_ratio` is outside `[0.0, 1.0]`
123    /// - `tuning_interval_ms < 100`
124    pub fn validate(&self) -> Result<(), String> {
125        if self.min_pool_size >= self.max_pool_size {
126            return Err(format!(
127                "pool_tuning: min_pool_size ({}) must be less than max_pool_size ({})",
128                self.min_pool_size, self.max_pool_size
129            ));
130        }
131        if self.scale_up_step == 0 {
132            return Err("pool_tuning: scale_up_step must be > 0".to_string());
133        }
134        if self.scale_down_step == 0 {
135            return Err("pool_tuning: scale_down_step must be > 0".to_string());
136        }
137        if !(0.0..=1.0).contains(&self.scale_down_idle_ratio) {
138            return Err(format!(
139                "pool_tuning: scale_down_idle_ratio ({}) must be in [0.0, 1.0]",
140                self.scale_down_idle_ratio
141            ));
142        }
143        if self.tuning_interval_ms < 100 {
144            return Err(format!(
145                "pool_tuning: tuning_interval_ms ({}) must be >= 100",
146                self.tuning_interval_ms
147            ));
148        }
149        Ok(())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    #[allow(clippy::wildcard_imports)]
156    // Reason: test module — wildcard import keeps test boilerplate minimal
157    use super::*;
158
159    #[test]
160    fn test_default_config_is_disabled() {
161        let cfg = PoolPressureMonitorConfig::default();
162        assert!(!cfg.enabled, "pool pressure monitoring should be off by default");
163    }
164
165    #[test]
166    fn test_default_bounds_are_sensible() {
167        let cfg = PoolPressureMonitorConfig::default();
168        assert!(cfg.min_pool_size < cfg.max_pool_size);
169        assert!(cfg.scale_up_step > 0);
170        assert!(cfg.scale_down_step > 0);
171        assert!(cfg.tuning_interval_ms >= 1000);
172    }
173
174    #[test]
175    fn test_validate_passes_for_defaults() {
176        PoolPressureMonitorConfig::default()
177            .validate()
178            .unwrap_or_else(|e| panic!("default pool monitor config should pass validation: {e}"));
179    }
180
181    #[test]
182    fn test_validate_min_lt_max() {
183        let cfg = PoolPressureMonitorConfig {
184            min_pool_size: 10,
185            max_pool_size: 5,
186            ..Default::default()
187        };
188        assert!(
189            cfg.validate().is_err(),
190            "min >= max should be invalid, got: {:?}",
191            cfg.validate()
192        );
193    }
194
195    #[test]
196    fn test_validate_min_equals_max_is_invalid() {
197        let cfg = PoolPressureMonitorConfig {
198            min_pool_size: 10,
199            max_pool_size: 10,
200            ..Default::default()
201        };
202        assert!(
203            cfg.validate().is_err(),
204            "min == max should be invalid, got: {:?}",
205            cfg.validate()
206        );
207    }
208
209    #[test]
210    fn test_validate_idle_ratio_above_one() {
211        let cfg = PoolPressureMonitorConfig {
212            scale_down_idle_ratio: 1.5,
213            ..Default::default()
214        };
215        assert!(
216            cfg.validate().is_err(),
217            "idle ratio > 1.0 should be invalid, got: {:?}",
218            cfg.validate()
219        );
220    }
221
222    #[test]
223    fn test_validate_idle_ratio_negative() {
224        let cfg = PoolPressureMonitorConfig {
225            scale_down_idle_ratio: -0.1,
226            ..Default::default()
227        };
228        assert!(
229            cfg.validate().is_err(),
230            "idle ratio < 0.0 should be invalid, got: {:?}",
231            cfg.validate()
232        );
233    }
234
235    #[test]
236    fn test_validate_zero_scale_up_step() {
237        let cfg = PoolPressureMonitorConfig {
238            scale_up_step: 0,
239            ..Default::default()
240        };
241        assert!(
242            cfg.validate().is_err(),
243            "scale_up_step == 0 should be invalid, got: {:?}",
244            cfg.validate()
245        );
246    }
247
248    #[test]
249    fn test_validate_zero_scale_down_step() {
250        let cfg = PoolPressureMonitorConfig {
251            scale_down_step: 0,
252            ..Default::default()
253        };
254        assert!(
255            cfg.validate().is_err(),
256            "scale_down_step == 0 should be invalid, got: {:?}",
257            cfg.validate()
258        );
259    }
260
261    #[test]
262    #[allow(deprecated)] // Reason: re-exporting deprecated alias for backward compatibility
263    fn test_pool_tuning_config_alias_works() {
264        // PoolTuningConfig is a deprecated alias for PoolPressureMonitorConfig
265        let _cfg: PoolTuningConfig = PoolTuningConfig::default();
266    }
267
268    #[test]
269    fn test_validate_interval_too_short() {
270        let cfg = PoolPressureMonitorConfig {
271            tuning_interval_ms: 50,
272            ..Default::default()
273        };
274        assert!(
275            cfg.validate().is_err(),
276            "tuning_interval_ms < 100 should be invalid, got: {:?}",
277            cfg.validate()
278        );
279    }
280}