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)
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#[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 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")]
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 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 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 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 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 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}