1use 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#[derive(Debug, Clone, Serialize)]
19pub struct PreflightCheck {
20 pub name: String,
22 pub status: CheckStatus,
24 pub detail: String,
26}
27
28#[derive(Debug, Clone, Serialize, PartialEq)]
30pub enum CheckStatus {
31 Pass,
33 Warn,
35 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#[derive(Debug, Serialize)]
51pub struct PreflightReport {
52 pub checks: Vec<PreflightCheck>,
54 pub passed: bool,
56}
57
58#[derive(Debug, Clone)]
64pub struct PreflightConfig {
65 pub enabled: bool,
67 pub max_replication_lag_mb: i64,
70 pub max_replication_lag_secs: i64,
73 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#[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
105pub 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)::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 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#[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 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#[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 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 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 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 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 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}