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    // pg_wal_lsn_diff returns numeric, which tokio-postgres does not
220    // deserialize into i64, so cast in SQL. The row only exists where
221    // replication is configured (managed providers always have it,
222    // local Postgres never does), which is why this path went
223    // unexercised before 0.7.1.
224    let query = "SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint
225                 FROM pg_stat_replication
226                 ORDER BY replay_lsn ASC LIMIT 1";
227    match client.query_opt(query, &[]).await {
228        Ok(Some(row)) => {
229            // Preflight is advisory: a decode failure degrades to Warn,
230            // it must never panic the migration run.
231            let lag_bytes: Option<i64> = match row.try_get(0) {
232                Ok(value) => value,
233                Err(e) => {
234                    return PreflightCheck {
235                        name: "Replication Lag".to_string(),
236                        status: CheckStatus::Warn,
237                        detail: format!("Could not decode lag value: {}", e),
238                    };
239                }
240            };
241            let lag_mb = lag_bytes.unwrap_or(0) / (1024 * 1024);
242            let status = if lag_mb > max_lag_mb {
243                CheckStatus::Warn
244            } else {
245                CheckStatus::Pass
246            };
247            PreflightCheck {
248                name: "Replication Lag".to_string(),
249                status,
250                detail: format!("{}MB (threshold: {}MB)", lag_mb, max_lag_mb),
251            }
252        }
253        Ok(None) => PreflightCheck {
254            name: "Replication Lag".to_string(),
255            status: CheckStatus::Pass,
256            detail: "No replicas connected".to_string(),
257        },
258        Err(_) => PreflightCheck {
259            name: "Replication Lag".to_string(),
260            status: CheckStatus::Pass,
261            detail: "Not a primary or no replication configured".to_string(),
262        },
263    }
264}
265
266#[cfg(feature = "postgres")]
267async fn check_database_size(client: &Client) -> PreflightCheck {
268    match client
269        .query_one("SELECT pg_database_size(current_database())", &[])
270        .await
271    {
272        Ok(row) => {
273            let size_bytes: i64 = row.get(0);
274            let size_mb = size_bytes / (1024 * 1024);
275            let detail = if size_mb > 1024 {
276                format!("{:.1}GB", size_mb as f64 / 1024.0)
277            } else {
278                format!("{}MB", size_mb)
279            };
280            PreflightCheck {
281                name: "Database Size".to_string(),
282                status: CheckStatus::Pass,
283                detail,
284            }
285        }
286        Err(e) => PreflightCheck {
287            name: "Database Size".to_string(),
288            status: CheckStatus::Warn,
289            detail: format!("Could not check: {}", e),
290        },
291    }
292}
293
294#[cfg(feature = "postgres")]
295async fn check_lock_contention(client: &Client) -> PreflightCheck {
296    match client
297        .query_one("SELECT count(*)::int FROM pg_locks WHERE NOT granted", &[])
298        .await
299    {
300        Ok(row) => {
301            let blocked: i32 = row.get(0);
302            if blocked > 0 {
303                PreflightCheck {
304                    name: "Lock Contention".to_string(),
305                    status: CheckStatus::Warn,
306                    detail: format!("{} blocked lock request(s)", blocked),
307                }
308            } else {
309                PreflightCheck {
310                    name: "Lock Contention".to_string(),
311                    status: CheckStatus::Pass,
312                    detail: "No blocked locks".to_string(),
313                }
314            }
315        }
316        Err(e) => PreflightCheck {
317            name: "Lock Contention".to_string(),
318            status: CheckStatus::Warn,
319            detail: format!("Could not check: {}", e),
320        },
321    }
322}
323
324// ── MySQL pre-flight checks ───────────────────────────────────────────────────
325
326#[cfg(feature = "mysql")]
327async fn run_preflight_mysql(
328    client: &DbClient,
329    config: &PreflightConfig,
330) -> Result<PreflightReport> {
331    let mut checks = Vec::new();
332    checks.push(check_read_only_mysql(client).await);
333    checks.push(check_active_connections_mysql(client).await);
334    checks.push(check_long_running_queries_mysql(client, config.long_query_threshold_secs).await);
335    checks.push(check_replication_lag_mysql(client, config.max_replication_lag_secs).await);
336    checks.push(check_database_size_mysql(client).await);
337    checks.push(check_lock_contention_mysql(client).await);
338
339    let passed = !checks.iter().any(|c| c.status == CheckStatus::Fail);
340    Ok(PreflightReport { checks, passed })
341}
342
343#[cfg(feature = "mysql")]
344async fn check_read_only_mysql(client: &DbClient) -> PreflightCheck {
345    use mysql_async::prelude::*;
346    let pool = match client.as_mysql() {
347        Ok(p) => p,
348        Err(e) => {
349            return PreflightCheck {
350                name: "Read-only".into(),
351                status: CheckStatus::Warn,
352                detail: format!("Could not check: {}", e),
353            };
354        }
355    };
356    let mut conn = match pool.get_conn().await {
357        Ok(c) => c,
358        Err(e) => {
359            return PreflightCheck {
360                name: "Read-only".into(),
361                status: CheckStatus::Warn,
362                detail: format!("Could not check: {}", e),
363            };
364        }
365    };
366    // Treat @@read_only as the canonical signal that this is a replica or
367    // intentionally locked down. super_read_only is even stricter (8.0+).
368    match conn
369        .query_first::<(i64, i64), _>("SELECT @@read_only, @@super_read_only")
370        .await
371    {
372        Ok(Some((read_only, super_read_only))) => {
373            if read_only != 0 || super_read_only != 0 {
374                PreflightCheck {
375                    name: "Read-only".into(),
376                    status: CheckStatus::Fail,
377                    detail: format!(
378                        "Server is read-only (read_only={}, super_read_only={})",
379                        read_only, super_read_only
380                    ),
381                }
382            } else {
383                PreflightCheck {
384                    name: "Read-only".into(),
385                    status: CheckStatus::Pass,
386                    detail: "Server accepts writes".into(),
387                }
388            }
389        }
390        Ok(None) | Err(_) => PreflightCheck {
391            name: "Read-only".into(),
392            status: CheckStatus::Warn,
393            detail: "Could not determine read-only state".into(),
394        },
395    }
396}
397
398/// Extract the MySQL pool for a check, or produce a Warn result.
399///
400/// The MySQL checks return a `PreflightCheck` rather than a `Result`, so there
401/// is nowhere to propagate a wrong-engine error to. Callers only reach these
402/// through `run_preflight_db`'s `DialectKind::Mysql` arm, so the `Err` branch
403/// is unreachable in practice — but a preflight *health check* is the last
404/// place that should abort the process, so it degrades to a warning instead of
405/// `.expect()`-ing.
406#[cfg(feature = "mysql")]
407macro_rules! mysql_pool_or_warn {
408    ($client:expr, $name:expr) => {
409        match $client.as_mysql() {
410            Ok(pool) => pool,
411            Err(e) => {
412                return PreflightCheck {
413                    name: $name.into(),
414                    status: CheckStatus::Warn,
415                    detail: format!("Could not check: {}", e),
416                };
417            }
418        }
419    };
420}
421
422#[cfg(feature = "mysql")]
423async fn check_active_connections_mysql(client: &DbClient) -> PreflightCheck {
424    use mysql_async::prelude::*;
425    let pool = mysql_pool_or_warn!(client, "Active Connections");
426    let mut conn = match pool.get_conn().await {
427        Ok(c) => c,
428        Err(e) => {
429            return PreflightCheck {
430                name: "Active Connections".into(),
431                status: CheckStatus::Warn,
432                detail: format!("Could not check: {}", e),
433            };
434        }
435    };
436    // performance_schema.global_status / global_variables expose these without
437    // SUPER privilege on most installs; fall back to SHOW STATUS if needed.
438    let active: Option<i64> = conn
439        .query_first(
440            "SELECT VARIABLE_VALUE + 0 FROM performance_schema.global_status \
441             WHERE VARIABLE_NAME = 'Threads_connected'",
442        )
443        .await
444        .unwrap_or(None);
445    let max_conn: Option<i64> = conn
446        .query_first("SELECT @@max_connections")
447        .await
448        .unwrap_or(None);
449    match (active, max_conn) {
450        (Some(a), Some(m)) if m > 0 => {
451            let pct = (a as f64 / m as f64) * 100.0;
452            let status = if pct >= 80.0 {
453                CheckStatus::Warn
454            } else {
455                CheckStatus::Pass
456            };
457            PreflightCheck {
458                name: "Active Connections".into(),
459                status,
460                detail: format!("{}/{} ({:.0}%)", a, m, pct),
461            }
462        }
463        _ => PreflightCheck {
464            name: "Active Connections".into(),
465            status: CheckStatus::Warn,
466            detail: "Could not read connection stats".into(),
467        },
468    }
469}
470
471#[cfg(feature = "mysql")]
472async fn check_long_running_queries_mysql(
473    client: &DbClient,
474    threshold_secs: i64,
475) -> PreflightCheck {
476    use mysql_async::prelude::*;
477    let pool = mysql_pool_or_warn!(client, "Long-Running Queries");
478    let mut conn = match pool.get_conn().await {
479        Ok(c) => c,
480        Err(e) => {
481            return PreflightCheck {
482                name: "Long-Running Queries".into(),
483                status: CheckStatus::Warn,
484                detail: format!("Could not check: {}", e),
485            };
486        }
487    };
488    // information_schema.PROCESSLIST.TIME is "seconds since the thread entered
489    // its current state". Sleeping threads aren't running queries.
490    let count: Option<i64> = conn
491        .exec_first(
492            "SELECT COUNT(*) FROM information_schema.PROCESSLIST \
493             WHERE COMMAND <> 'Sleep' AND TIME > ?",
494            (threshold_secs,),
495        )
496        .await
497        .unwrap_or(None);
498    match count {
499        Some(c) if c > 0 => PreflightCheck {
500            name: "Long-Running Queries".into(),
501            status: CheckStatus::Warn,
502            detail: format!("{} query(ies) running longer than {}s", c, threshold_secs),
503        },
504        Some(_) => PreflightCheck {
505            name: "Long-Running Queries".into(),
506            status: CheckStatus::Pass,
507            detail: format!("No queries running longer than {}s", threshold_secs),
508        },
509        None => PreflightCheck {
510            name: "Long-Running Queries".into(),
511            status: CheckStatus::Warn,
512            detail: "Could not read PROCESSLIST".into(),
513        },
514    }
515}
516
517#[cfg(feature = "mysql")]
518async fn check_replication_lag_mysql(client: &DbClient, max_lag_secs: i64) -> PreflightCheck {
519    use mysql_async::prelude::*;
520    let pool = mysql_pool_or_warn!(client, "Replication Lag");
521    let mut conn = match pool.get_conn().await {
522        Ok(c) => c,
523        Err(_) => {
524            return PreflightCheck {
525                name: "Replication Lag".into(),
526                status: CheckStatus::Pass,
527                detail: "Could not check (treating as primary)".into(),
528            };
529        }
530    };
531    // SHOW REPLICA STATUS requires REPLICATION CLIENT. We try, and on error
532    // assume this is a primary or non-replica.
533    let row: Option<mysql_async::Row> = conn
534        .query_first("SHOW REPLICA STATUS")
535        .await
536        .unwrap_or(None);
537    match row {
538        None => PreflightCheck {
539            name: "Replication Lag".into(),
540            status: CheckStatus::Pass,
541            detail: "Not a replica".into(),
542        },
543        Some(mut r) => {
544            // Seconds_Behind_Source is NULL when replication isn't running.
545            let lag: Option<i64> = r.take("Seconds_Behind_Source").unwrap_or(None);
546            match lag {
547                Some(secs) => {
548                    let status = if secs > max_lag_secs {
549                        CheckStatus::Warn
550                    } else {
551                        CheckStatus::Pass
552                    };
553                    PreflightCheck {
554                        name: "Replication Lag".into(),
555                        status,
556                        detail: format!("{}s (threshold: {}s)", secs, max_lag_secs),
557                    }
558                }
559                None => PreflightCheck {
560                    name: "Replication Lag".into(),
561                    status: CheckStatus::Warn,
562                    detail: "Replication thread not running".into(),
563                },
564            }
565        }
566    }
567}
568
569#[cfg(feature = "mysql")]
570async fn check_database_size_mysql(client: &DbClient) -> PreflightCheck {
571    use mysql_async::prelude::*;
572    let pool = mysql_pool_or_warn!(client, "Database Size");
573    let mut conn = match pool.get_conn().await {
574        Ok(c) => c,
575        Err(e) => {
576            return PreflightCheck {
577                name: "Database Size".into(),
578                status: CheckStatus::Warn,
579                detail: format!("Could not check: {}", e),
580            };
581        }
582    };
583    let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await.unwrap_or(None);
584    let db = match db.flatten() {
585        Some(d) => d,
586        None => {
587            return PreflightCheck {
588                name: "Database Size".into(),
589                status: CheckStatus::Warn,
590                detail: "No current database selected".into(),
591            };
592        }
593    };
594    let size: Option<i64> = conn
595        .exec_first(
596            "SELECT IFNULL(SUM(data_length + index_length), 0) \
597             FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?",
598            (db.as_str(),),
599        )
600        .await
601        .unwrap_or(None);
602    match size {
603        Some(bytes) => {
604            let mb = bytes / (1024 * 1024);
605            let detail = if mb > 1024 {
606                format!("{:.1}GB", mb as f64 / 1024.0)
607            } else {
608                format!("{}MB", mb)
609            };
610            PreflightCheck {
611                name: "Database Size".into(),
612                status: CheckStatus::Pass,
613                detail,
614            }
615        }
616        None => PreflightCheck {
617            name: "Database Size".into(),
618            status: CheckStatus::Warn,
619            detail: "Could not compute size".into(),
620        },
621    }
622}
623
624#[cfg(feature = "mysql")]
625async fn check_lock_contention_mysql(client: &DbClient) -> PreflightCheck {
626    use mysql_async::prelude::*;
627    let pool = mysql_pool_or_warn!(client, "Lock Contention");
628    let mut conn = match pool.get_conn().await {
629        Ok(c) => c,
630        Err(e) => {
631            return PreflightCheck {
632                name: "Lock Contention".into(),
633                status: CheckStatus::Warn,
634                detail: format!("Could not check: {}", e),
635            };
636        }
637    };
638    // performance_schema.metadata_locks needs performance_schema enabled (on
639    // by default in MySQL 8.0). PENDING rows indicate waiters.
640    let pending: Option<i64> = conn
641        .query_first(
642            "SELECT COUNT(*) FROM performance_schema.metadata_locks \
643             WHERE LOCK_STATUS = 'PENDING'",
644        )
645        .await
646        .unwrap_or(None);
647    match pending {
648        Some(p) if p > 0 => PreflightCheck {
649            name: "Lock Contention".into(),
650            status: CheckStatus::Warn,
651            detail: format!("{} pending metadata lock(s)", p),
652        },
653        Some(_) => PreflightCheck {
654            name: "Lock Contention".into(),
655            status: CheckStatus::Pass,
656            detail: "No pending locks".into(),
657        },
658        None => PreflightCheck {
659            name: "Lock Contention".into(),
660            status: CheckStatus::Warn,
661            detail: "Could not query metadata_locks".into(),
662        },
663    }
664}