Skip to main content

waypoint_core/
preflight.rs

1//! Pre-flight health checks run before migrations.
2//!
3//! Checks database health metrics like recovery mode, active connections,
4//! long-running queries, replication lag, and lock contention.
5
6use serde::Serialize;
7
8#[cfg(feature = "postgres")]
9use tokio_postgres::Client;
10
11use crate::db::DbClient;
12use crate::dialect::DialectKind;
13use crate::error::Result;
14#[cfg(any(not(feature = "postgres"), not(feature = "mysql")))]
15use crate::error::WaypointError;
16
17/// Result of a single pre-flight check.
18#[derive(Debug, Clone, Serialize)]
19pub struct PreflightCheck {
20    /// Human-readable name of the check (e.g. "Recovery Mode").
21    pub name: String,
22    /// Whether the check passed, warned, or failed.
23    pub status: CheckStatus,
24    /// Descriptive detail about the check result.
25    pub detail: String,
26}
27
28/// Status of a pre-flight check.
29#[derive(Debug, Clone, Serialize, PartialEq)]
30pub enum CheckStatus {
31    /// The check passed successfully.
32    Pass,
33    /// The check produced a non-blocking warning.
34    Warn,
35    /// The check failed and should block migration.
36    Fail,
37}
38
39impl std::fmt::Display for CheckStatus {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            CheckStatus::Pass => write!(f, "PASS"),
43            CheckStatus::Warn => write!(f, "WARN"),
44            CheckStatus::Fail => write!(f, "FAIL"),
45        }
46    }
47}
48
49/// Aggregate report of all pre-flight checks.
50#[derive(Debug, Serialize)]
51pub struct PreflightReport {
52    /// Individual check results.
53    pub checks: Vec<PreflightCheck>,
54    /// Whether all checks passed (no failures).
55    pub passed: bool,
56}
57
58/// Configuration for pre-flight checks.
59///
60/// Replication-lag thresholds are engine-specific because the natural unit
61/// differs: PostgreSQL measures WAL lag in bytes, MySQL measures replica lag
62/// in seconds. Configure whichever applies to your deployment.
63#[derive(Debug, Clone)]
64pub struct PreflightConfig {
65    /// Whether pre-flight checks are enabled before migrations.
66    pub enabled: bool,
67    /// PostgreSQL only: maximum acceptable WAL replication lag in megabytes
68    /// before warning (compared against `pg_wal_lsn_diff`).
69    pub max_replication_lag_mb: i64,
70    /// MySQL only: maximum acceptable replica lag in seconds before warning
71    /// (compared against `Seconds_Behind_Source` from `SHOW REPLICA STATUS`).
72    pub max_replication_lag_secs: i64,
73    /// Threshold in seconds for detecting long-running queries.
74    pub long_query_threshold_secs: i64,
75}
76
77impl Default for PreflightConfig {
78    fn default() -> Self {
79        Self {
80            enabled: true,
81            max_replication_lag_mb: 100,
82            max_replication_lag_secs: 30,
83            long_query_threshold_secs: 300,
84        }
85    }
86}
87
88/// Run all pre-flight checks against the database (PostgreSQL legacy entry).
89#[cfg(feature = "postgres")]
90pub async fn run_preflight(client: &Client, config: &PreflightConfig) -> Result<PreflightReport> {
91    let mut checks = Vec::new();
92
93    checks.push(check_recovery_mode(client).await);
94    checks.push(check_active_connections(client).await);
95    checks.push(check_long_running_queries(client, config.long_query_threshold_secs).await);
96    checks.push(check_replication_lag(client, config.max_replication_lag_mb).await);
97    checks.push(check_database_size(client).await);
98    checks.push(check_lock_contention(client).await);
99
100    let passed = !checks.iter().any(|c| c.status == CheckStatus::Fail);
101
102    Ok(PreflightReport { checks, passed })
103}
104
105/// Run all pre-flight checks against the database (dialect-aware entry).
106pub async fn run_preflight_db(
107    client: &DbClient,
108    config: &PreflightConfig,
109) -> Result<PreflightReport> {
110    match client.dialect_kind() {
111        #[cfg(feature = "postgres")]
112        DialectKind::Postgres => run_preflight(client.as_postgres()?, config).await,
113        #[cfg(not(feature = "postgres"))]
114        DialectKind::Postgres => Err(WaypointError::ConfigError(
115            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
116        )),
117        #[cfg(feature = "mysql")]
118        DialectKind::Mysql => run_preflight_mysql(client, config).await,
119        #[cfg(not(feature = "mysql"))]
120        DialectKind::Mysql => Err(WaypointError::ConfigError(
121            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
122        )),
123    }
124}
125
126#[cfg(feature = "postgres")]
127async fn check_recovery_mode(client: &Client) -> PreflightCheck {
128    match client.query_one("SELECT pg_is_in_recovery()", &[]).await {
129        Ok(row) => {
130            let in_recovery: bool = row.get(0);
131            if in_recovery {
132                PreflightCheck {
133                    name: "Recovery Mode".to_string(),
134                    status: CheckStatus::Fail,
135                    detail: "Database is in recovery mode (read-only replica)".to_string(),
136                }
137            } else {
138                PreflightCheck {
139                    name: "Recovery Mode".to_string(),
140                    status: CheckStatus::Pass,
141                    detail: "Not in recovery mode".to_string(),
142                }
143            }
144        }
145        Err(e) => PreflightCheck {
146            name: "Recovery Mode".to_string(),
147            status: CheckStatus::Warn,
148            detail: format!("Could not check: {}", e),
149        },
150    }
151}
152
153#[cfg(feature = "postgres")]
154async fn check_active_connections(client: &Client) -> PreflightCheck {
155    let query = "SELECT count(*)::int as active,
156                        (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') as max_conn
157                 FROM pg_stat_activity";
158    match client.query_one(query, &[]).await {
159        Ok(row) => {
160            let active: i32 = row.get(0);
161            let max_conn: i32 = row.get(1);
162            let pct = (active as f64 / max_conn as f64) * 100.0;
163            let status = if pct >= 80.0 {
164                CheckStatus::Warn
165            } else {
166                CheckStatus::Pass
167            };
168            PreflightCheck {
169                name: "Active Connections".to_string(),
170                status,
171                detail: format!("{}/{} ({:.0}%)", active, max_conn, pct),
172            }
173        }
174        Err(e) => PreflightCheck {
175            name: "Active Connections".to_string(),
176            status: CheckStatus::Warn,
177            detail: format!("Could not check: {}", e),
178        },
179    }
180}
181
182#[cfg(feature = "postgres")]
183async fn check_long_running_queries(client: &Client, threshold_secs: i64) -> PreflightCheck {
184    let query = format!(
185        "SELECT count(*)::int FROM pg_stat_activity
186         WHERE state = 'active' AND now() - query_start > interval '{} seconds'",
187        threshold_secs
188    );
189    match client.query_one(&query, &[]).await {
190        Ok(row) => {
191            let count: i32 = row.get(0);
192            if count > 0 {
193                PreflightCheck {
194                    name: "Long-Running Queries".to_string(),
195                    status: CheckStatus::Warn,
196                    detail: format!(
197                        "{} query(ies) running longer than {}s",
198                        count, threshold_secs
199                    ),
200                }
201            } else {
202                PreflightCheck {
203                    name: "Long-Running Queries".to_string(),
204                    status: CheckStatus::Pass,
205                    detail: format!("No queries running longer than {}s", threshold_secs),
206                }
207            }
208        }
209        Err(e) => PreflightCheck {
210            name: "Long-Running Queries".to_string(),
211            status: CheckStatus::Warn,
212            detail: format!("Could not check: {}", e),
213        },
214    }
215}
216
217#[cfg(feature = "postgres")]
218async fn check_replication_lag(client: &Client, max_lag_mb: i64) -> PreflightCheck {
219    let query = "SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
220                 FROM pg_stat_replication
221                 ORDER BY replay_lsn ASC LIMIT 1";
222    match client.query_opt(query, &[]).await {
223        Ok(Some(row)) => {
224            let lag_bytes: Option<i64> = row.get(0);
225            let lag_mb = lag_bytes.unwrap_or(0) / (1024 * 1024);
226            let status = if lag_mb > max_lag_mb {
227                CheckStatus::Warn
228            } else {
229                CheckStatus::Pass
230            };
231            PreflightCheck {
232                name: "Replication Lag".to_string(),
233                status,
234                detail: format!("{}MB (threshold: {}MB)", lag_mb, max_lag_mb),
235            }
236        }
237        Ok(None) => PreflightCheck {
238            name: "Replication Lag".to_string(),
239            status: CheckStatus::Pass,
240            detail: "No replicas connected".to_string(),
241        },
242        Err(_) => PreflightCheck {
243            name: "Replication Lag".to_string(),
244            status: CheckStatus::Pass,
245            detail: "Not a primary or no replication configured".to_string(),
246        },
247    }
248}
249
250#[cfg(feature = "postgres")]
251async fn check_database_size(client: &Client) -> PreflightCheck {
252    match client
253        .query_one("SELECT pg_database_size(current_database())", &[])
254        .await
255    {
256        Ok(row) => {
257            let size_bytes: i64 = row.get(0);
258            let size_mb = size_bytes / (1024 * 1024);
259            let detail = if size_mb > 1024 {
260                format!("{:.1}GB", size_mb as f64 / 1024.0)
261            } else {
262                format!("{}MB", size_mb)
263            };
264            PreflightCheck {
265                name: "Database Size".to_string(),
266                status: CheckStatus::Pass,
267                detail,
268            }
269        }
270        Err(e) => PreflightCheck {
271            name: "Database Size".to_string(),
272            status: CheckStatus::Warn,
273            detail: format!("Could not check: {}", e),
274        },
275    }
276}
277
278#[cfg(feature = "postgres")]
279async fn check_lock_contention(client: &Client) -> PreflightCheck {
280    match client
281        .query_one("SELECT count(*)::int FROM pg_locks WHERE NOT granted", &[])
282        .await
283    {
284        Ok(row) => {
285            let blocked: i32 = row.get(0);
286            if blocked > 0 {
287                PreflightCheck {
288                    name: "Lock Contention".to_string(),
289                    status: CheckStatus::Warn,
290                    detail: format!("{} blocked lock request(s)", blocked),
291                }
292            } else {
293                PreflightCheck {
294                    name: "Lock Contention".to_string(),
295                    status: CheckStatus::Pass,
296                    detail: "No blocked locks".to_string(),
297                }
298            }
299        }
300        Err(e) => PreflightCheck {
301            name: "Lock Contention".to_string(),
302            status: CheckStatus::Warn,
303            detail: format!("Could not check: {}", e),
304        },
305    }
306}
307
308// ── MySQL pre-flight checks ───────────────────────────────────────────────────
309
310#[cfg(feature = "mysql")]
311async fn run_preflight_mysql(
312    client: &DbClient,
313    config: &PreflightConfig,
314) -> Result<PreflightReport> {
315    let mut checks = Vec::new();
316    checks.push(check_read_only_mysql(client).await);
317    checks.push(check_active_connections_mysql(client).await);
318    checks.push(check_long_running_queries_mysql(client, config.long_query_threshold_secs).await);
319    checks.push(check_replication_lag_mysql(client, config.max_replication_lag_secs).await);
320    checks.push(check_database_size_mysql(client).await);
321    checks.push(check_lock_contention_mysql(client).await);
322
323    let passed = !checks.iter().any(|c| c.status == CheckStatus::Fail);
324    Ok(PreflightReport { checks, passed })
325}
326
327#[cfg(feature = "mysql")]
328async fn check_read_only_mysql(client: &DbClient) -> PreflightCheck {
329    use mysql_async::prelude::*;
330    let pool = match client.as_mysql() {
331        Ok(p) => p,
332        Err(e) => {
333            return PreflightCheck {
334                name: "Read-only".into(),
335                status: CheckStatus::Warn,
336                detail: format!("Could not check: {}", e),
337            };
338        }
339    };
340    let mut conn = match pool.get_conn().await {
341        Ok(c) => c,
342        Err(e) => {
343            return PreflightCheck {
344                name: "Read-only".into(),
345                status: CheckStatus::Warn,
346                detail: format!("Could not check: {}", e),
347            };
348        }
349    };
350    // Treat @@read_only as the canonical signal that this is a replica or
351    // intentionally locked down. super_read_only is even stricter (8.0+).
352    match conn
353        .query_first::<(i64, i64), _>("SELECT @@read_only, @@super_read_only")
354        .await
355    {
356        Ok(Some((read_only, super_read_only))) => {
357            if read_only != 0 || super_read_only != 0 {
358                PreflightCheck {
359                    name: "Read-only".into(),
360                    status: CheckStatus::Fail,
361                    detail: format!(
362                        "Server is read-only (read_only={}, super_read_only={})",
363                        read_only, super_read_only
364                    ),
365                }
366            } else {
367                PreflightCheck {
368                    name: "Read-only".into(),
369                    status: CheckStatus::Pass,
370                    detail: "Server accepts writes".into(),
371                }
372            }
373        }
374        Ok(None) | Err(_) => PreflightCheck {
375            name: "Read-only".into(),
376            status: CheckStatus::Warn,
377            detail: "Could not determine read-only state".into(),
378        },
379    }
380}
381
382#[cfg(feature = "mysql")]
383async fn check_active_connections_mysql(client: &DbClient) -> PreflightCheck {
384    use mysql_async::prelude::*;
385    let pool = client.as_mysql().expect("mysql pool");
386    let mut conn = match pool.get_conn().await {
387        Ok(c) => c,
388        Err(e) => {
389            return PreflightCheck {
390                name: "Active Connections".into(),
391                status: CheckStatus::Warn,
392                detail: format!("Could not check: {}", e),
393            };
394        }
395    };
396    // performance_schema.global_status / global_variables expose these without
397    // SUPER privilege on most installs; fall back to SHOW STATUS if needed.
398    let active: Option<i64> = conn
399        .query_first(
400            "SELECT VARIABLE_VALUE + 0 FROM performance_schema.global_status \
401             WHERE VARIABLE_NAME = 'Threads_connected'",
402        )
403        .await
404        .unwrap_or(None);
405    let max_conn: Option<i64> = conn
406        .query_first("SELECT @@max_connections")
407        .await
408        .unwrap_or(None);
409    match (active, max_conn) {
410        (Some(a), Some(m)) if m > 0 => {
411            let pct = (a as f64 / m as f64) * 100.0;
412            let status = if pct >= 80.0 {
413                CheckStatus::Warn
414            } else {
415                CheckStatus::Pass
416            };
417            PreflightCheck {
418                name: "Active Connections".into(),
419                status,
420                detail: format!("{}/{} ({:.0}%)", a, m, pct),
421            }
422        }
423        _ => PreflightCheck {
424            name: "Active Connections".into(),
425            status: CheckStatus::Warn,
426            detail: "Could not read connection stats".into(),
427        },
428    }
429}
430
431#[cfg(feature = "mysql")]
432async fn check_long_running_queries_mysql(
433    client: &DbClient,
434    threshold_secs: i64,
435) -> PreflightCheck {
436    use mysql_async::prelude::*;
437    let pool = client.as_mysql().expect("mysql pool");
438    let mut conn = match pool.get_conn().await {
439        Ok(c) => c,
440        Err(e) => {
441            return PreflightCheck {
442                name: "Long-Running Queries".into(),
443                status: CheckStatus::Warn,
444                detail: format!("Could not check: {}", e),
445            };
446        }
447    };
448    // information_schema.PROCESSLIST.TIME is "seconds since the thread entered
449    // its current state". Sleeping threads aren't running queries.
450    let count: Option<i64> = conn
451        .exec_first(
452            "SELECT COUNT(*) FROM information_schema.PROCESSLIST \
453             WHERE COMMAND <> 'Sleep' AND TIME > ?",
454            (threshold_secs,),
455        )
456        .await
457        .unwrap_or(None);
458    match count {
459        Some(c) if c > 0 => PreflightCheck {
460            name: "Long-Running Queries".into(),
461            status: CheckStatus::Warn,
462            detail: format!("{} query(ies) running longer than {}s", c, threshold_secs),
463        },
464        Some(_) => PreflightCheck {
465            name: "Long-Running Queries".into(),
466            status: CheckStatus::Pass,
467            detail: format!("No queries running longer than {}s", threshold_secs),
468        },
469        None => PreflightCheck {
470            name: "Long-Running Queries".into(),
471            status: CheckStatus::Warn,
472            detail: "Could not read PROCESSLIST".into(),
473        },
474    }
475}
476
477#[cfg(feature = "mysql")]
478async fn check_replication_lag_mysql(client: &DbClient, max_lag_secs: i64) -> PreflightCheck {
479    use mysql_async::prelude::*;
480    let pool = client.as_mysql().expect("mysql pool");
481    let mut conn = match pool.get_conn().await {
482        Ok(c) => c,
483        Err(_) => {
484            return PreflightCheck {
485                name: "Replication Lag".into(),
486                status: CheckStatus::Pass,
487                detail: "Could not check (treating as primary)".into(),
488            };
489        }
490    };
491    // SHOW REPLICA STATUS requires REPLICATION CLIENT. We try, and on error
492    // assume this is a primary or non-replica.
493    let row: Option<mysql_async::Row> = conn
494        .query_first("SHOW REPLICA STATUS")
495        .await
496        .unwrap_or(None);
497    match row {
498        None => PreflightCheck {
499            name: "Replication Lag".into(),
500            status: CheckStatus::Pass,
501            detail: "Not a replica".into(),
502        },
503        Some(mut r) => {
504            // Seconds_Behind_Source is NULL when replication isn't running.
505            let lag: Option<i64> = r.take("Seconds_Behind_Source").unwrap_or(None);
506            match lag {
507                Some(secs) => {
508                    let status = if secs > max_lag_secs {
509                        CheckStatus::Warn
510                    } else {
511                        CheckStatus::Pass
512                    };
513                    PreflightCheck {
514                        name: "Replication Lag".into(),
515                        status,
516                        detail: format!("{}s (threshold: {}s)", secs, max_lag_secs),
517                    }
518                }
519                None => PreflightCheck {
520                    name: "Replication Lag".into(),
521                    status: CheckStatus::Warn,
522                    detail: "Replication thread not running".into(),
523                },
524            }
525        }
526    }
527}
528
529#[cfg(feature = "mysql")]
530async fn check_database_size_mysql(client: &DbClient) -> PreflightCheck {
531    use mysql_async::prelude::*;
532    let pool = client.as_mysql().expect("mysql pool");
533    let mut conn = match pool.get_conn().await {
534        Ok(c) => c,
535        Err(e) => {
536            return PreflightCheck {
537                name: "Database Size".into(),
538                status: CheckStatus::Warn,
539                detail: format!("Could not check: {}", e),
540            };
541        }
542    };
543    let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await.unwrap_or(None);
544    let db = match db.flatten() {
545        Some(d) => d,
546        None => {
547            return PreflightCheck {
548                name: "Database Size".into(),
549                status: CheckStatus::Warn,
550                detail: "No current database selected".into(),
551            };
552        }
553    };
554    let size: Option<i64> = conn
555        .exec_first(
556            "SELECT IFNULL(SUM(data_length + index_length), 0) \
557             FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?",
558            (db.as_str(),),
559        )
560        .await
561        .unwrap_or(None);
562    match size {
563        Some(bytes) => {
564            let mb = bytes / (1024 * 1024);
565            let detail = if mb > 1024 {
566                format!("{:.1}GB", mb as f64 / 1024.0)
567            } else {
568                format!("{}MB", mb)
569            };
570            PreflightCheck {
571                name: "Database Size".into(),
572                status: CheckStatus::Pass,
573                detail,
574            }
575        }
576        None => PreflightCheck {
577            name: "Database Size".into(),
578            status: CheckStatus::Warn,
579            detail: "Could not compute size".into(),
580        },
581    }
582}
583
584#[cfg(feature = "mysql")]
585async fn check_lock_contention_mysql(client: &DbClient) -> PreflightCheck {
586    use mysql_async::prelude::*;
587    let pool = client.as_mysql().expect("mysql pool");
588    let mut conn = match pool.get_conn().await {
589        Ok(c) => c,
590        Err(e) => {
591            return PreflightCheck {
592                name: "Lock Contention".into(),
593                status: CheckStatus::Warn,
594                detail: format!("Could not check: {}", e),
595            };
596        }
597    };
598    // performance_schema.metadata_locks needs performance_schema enabled (on
599    // by default in MySQL 8.0). PENDING rows indicate waiters.
600    let pending: Option<i64> = conn
601        .query_first(
602            "SELECT COUNT(*) FROM performance_schema.metadata_locks \
603             WHERE LOCK_STATUS = 'PENDING'",
604        )
605        .await
606        .unwrap_or(None);
607    match pending {
608        Some(p) if p > 0 => PreflightCheck {
609            name: "Lock Contention".into(),
610            status: CheckStatus::Warn,
611            detail: format!("{} pending metadata lock(s)", p),
612        },
613        Some(_) => PreflightCheck {
614            name: "Lock Contention".into(),
615            status: CheckStatus::Pass,
616            detail: "No pending locks".into(),
617        },
618        None => PreflightCheck {
619            name: "Lock Contention".into(),
620            status: CheckStatus::Warn,
621            detail: "Could not query metadata_locks".into(),
622        },
623    }
624}