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