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/// Extract the MySQL pool for a check, or produce a Warn result.
383///
384/// The MySQL checks return a `PreflightCheck` rather than a `Result`, so there
385/// is nowhere to propagate a wrong-engine error to. Callers only reach these
386/// through `run_preflight_db`'s `DialectKind::Mysql` arm, so the `Err` branch
387/// is unreachable in practice — but a preflight *health check* is the last
388/// place that should abort the process, so it degrades to a warning instead of
389/// `.expect()`-ing.
390#[cfg(feature = "mysql")]
391macro_rules! mysql_pool_or_warn {
392    ($client:expr, $name:expr) => {
393        match $client.as_mysql() {
394            Ok(pool) => pool,
395            Err(e) => {
396                return PreflightCheck {
397                    name: $name.into(),
398                    status: CheckStatus::Warn,
399                    detail: format!("Could not check: {}", e),
400                };
401            }
402        }
403    };
404}
405
406#[cfg(feature = "mysql")]
407async fn check_active_connections_mysql(client: &DbClient) -> PreflightCheck {
408    use mysql_async::prelude::*;
409    let pool = mysql_pool_or_warn!(client, "Active Connections");
410    let mut conn = match pool.get_conn().await {
411        Ok(c) => c,
412        Err(e) => {
413            return PreflightCheck {
414                name: "Active Connections".into(),
415                status: CheckStatus::Warn,
416                detail: format!("Could not check: {}", e),
417            };
418        }
419    };
420    // performance_schema.global_status / global_variables expose these without
421    // SUPER privilege on most installs; fall back to SHOW STATUS if needed.
422    let active: Option<i64> = conn
423        .query_first(
424            "SELECT VARIABLE_VALUE + 0 FROM performance_schema.global_status \
425             WHERE VARIABLE_NAME = 'Threads_connected'",
426        )
427        .await
428        .unwrap_or(None);
429    let max_conn: Option<i64> = conn
430        .query_first("SELECT @@max_connections")
431        .await
432        .unwrap_or(None);
433    match (active, max_conn) {
434        (Some(a), Some(m)) if m > 0 => {
435            let pct = (a as f64 / m as f64) * 100.0;
436            let status = if pct >= 80.0 {
437                CheckStatus::Warn
438            } else {
439                CheckStatus::Pass
440            };
441            PreflightCheck {
442                name: "Active Connections".into(),
443                status,
444                detail: format!("{}/{} ({:.0}%)", a, m, pct),
445            }
446        }
447        _ => PreflightCheck {
448            name: "Active Connections".into(),
449            status: CheckStatus::Warn,
450            detail: "Could not read connection stats".into(),
451        },
452    }
453}
454
455#[cfg(feature = "mysql")]
456async fn check_long_running_queries_mysql(
457    client: &DbClient,
458    threshold_secs: i64,
459) -> PreflightCheck {
460    use mysql_async::prelude::*;
461    let pool = mysql_pool_or_warn!(client, "Long-Running Queries");
462    let mut conn = match pool.get_conn().await {
463        Ok(c) => c,
464        Err(e) => {
465            return PreflightCheck {
466                name: "Long-Running Queries".into(),
467                status: CheckStatus::Warn,
468                detail: format!("Could not check: {}", e),
469            };
470        }
471    };
472    // information_schema.PROCESSLIST.TIME is "seconds since the thread entered
473    // its current state". Sleeping threads aren't running queries.
474    let count: Option<i64> = conn
475        .exec_first(
476            "SELECT COUNT(*) FROM information_schema.PROCESSLIST \
477             WHERE COMMAND <> 'Sleep' AND TIME > ?",
478            (threshold_secs,),
479        )
480        .await
481        .unwrap_or(None);
482    match count {
483        Some(c) if c > 0 => PreflightCheck {
484            name: "Long-Running Queries".into(),
485            status: CheckStatus::Warn,
486            detail: format!("{} query(ies) running longer than {}s", c, threshold_secs),
487        },
488        Some(_) => PreflightCheck {
489            name: "Long-Running Queries".into(),
490            status: CheckStatus::Pass,
491            detail: format!("No queries running longer than {}s", threshold_secs),
492        },
493        None => PreflightCheck {
494            name: "Long-Running Queries".into(),
495            status: CheckStatus::Warn,
496            detail: "Could not read PROCESSLIST".into(),
497        },
498    }
499}
500
501#[cfg(feature = "mysql")]
502async fn check_replication_lag_mysql(client: &DbClient, max_lag_secs: i64) -> PreflightCheck {
503    use mysql_async::prelude::*;
504    let pool = mysql_pool_or_warn!(client, "Replication Lag");
505    let mut conn = match pool.get_conn().await {
506        Ok(c) => c,
507        Err(_) => {
508            return PreflightCheck {
509                name: "Replication Lag".into(),
510                status: CheckStatus::Pass,
511                detail: "Could not check (treating as primary)".into(),
512            };
513        }
514    };
515    // SHOW REPLICA STATUS requires REPLICATION CLIENT. We try, and on error
516    // assume this is a primary or non-replica.
517    let row: Option<mysql_async::Row> = conn
518        .query_first("SHOW REPLICA STATUS")
519        .await
520        .unwrap_or(None);
521    match row {
522        None => PreflightCheck {
523            name: "Replication Lag".into(),
524            status: CheckStatus::Pass,
525            detail: "Not a replica".into(),
526        },
527        Some(mut r) => {
528            // Seconds_Behind_Source is NULL when replication isn't running.
529            let lag: Option<i64> = r.take("Seconds_Behind_Source").unwrap_or(None);
530            match lag {
531                Some(secs) => {
532                    let status = if secs > max_lag_secs {
533                        CheckStatus::Warn
534                    } else {
535                        CheckStatus::Pass
536                    };
537                    PreflightCheck {
538                        name: "Replication Lag".into(),
539                        status,
540                        detail: format!("{}s (threshold: {}s)", secs, max_lag_secs),
541                    }
542                }
543                None => PreflightCheck {
544                    name: "Replication Lag".into(),
545                    status: CheckStatus::Warn,
546                    detail: "Replication thread not running".into(),
547                },
548            }
549        }
550    }
551}
552
553#[cfg(feature = "mysql")]
554async fn check_database_size_mysql(client: &DbClient) -> PreflightCheck {
555    use mysql_async::prelude::*;
556    let pool = mysql_pool_or_warn!(client, "Database Size");
557    let mut conn = match pool.get_conn().await {
558        Ok(c) => c,
559        Err(e) => {
560            return PreflightCheck {
561                name: "Database Size".into(),
562                status: CheckStatus::Warn,
563                detail: format!("Could not check: {}", e),
564            };
565        }
566    };
567    let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await.unwrap_or(None);
568    let db = match db.flatten() {
569        Some(d) => d,
570        None => {
571            return PreflightCheck {
572                name: "Database Size".into(),
573                status: CheckStatus::Warn,
574                detail: "No current database selected".into(),
575            };
576        }
577    };
578    let size: Option<i64> = conn
579        .exec_first(
580            "SELECT IFNULL(SUM(data_length + index_length), 0) \
581             FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?",
582            (db.as_str(),),
583        )
584        .await
585        .unwrap_or(None);
586    match size {
587        Some(bytes) => {
588            let mb = bytes / (1024 * 1024);
589            let detail = if mb > 1024 {
590                format!("{:.1}GB", mb as f64 / 1024.0)
591            } else {
592                format!("{}MB", mb)
593            };
594            PreflightCheck {
595                name: "Database Size".into(),
596                status: CheckStatus::Pass,
597                detail,
598            }
599        }
600        None => PreflightCheck {
601            name: "Database Size".into(),
602            status: CheckStatus::Warn,
603            detail: "Could not compute size".into(),
604        },
605    }
606}
607
608#[cfg(feature = "mysql")]
609async fn check_lock_contention_mysql(client: &DbClient) -> PreflightCheck {
610    use mysql_async::prelude::*;
611    let pool = mysql_pool_or_warn!(client, "Lock Contention");
612    let mut conn = match pool.get_conn().await {
613        Ok(c) => c,
614        Err(e) => {
615            return PreflightCheck {
616                name: "Lock Contention".into(),
617                status: CheckStatus::Warn,
618                detail: format!("Could not check: {}", e),
619            };
620        }
621    };
622    // performance_schema.metadata_locks needs performance_schema enabled (on
623    // by default in MySQL 8.0). PENDING rows indicate waiters.
624    let pending: Option<i64> = conn
625        .query_first(
626            "SELECT COUNT(*) FROM performance_schema.metadata_locks \
627             WHERE LOCK_STATUS = 'PENDING'",
628        )
629        .await
630        .unwrap_or(None);
631    match pending {
632        Some(p) if p > 0 => PreflightCheck {
633            name: "Lock Contention".into(),
634            status: CheckStatus::Warn,
635            detail: format!("{} pending metadata lock(s)", p),
636        },
637        Some(_) => PreflightCheck {
638            name: "Lock Contention".into(),
639            status: CheckStatus::Pass,
640            detail: "No pending locks".into(),
641        },
642        None => PreflightCheck {
643            name: "Lock Contention".into(),
644            status: CheckStatus::Warn,
645            detail: "Could not query metadata_locks".into(),
646        },
647    }
648}