Skip to main content

solana_validator_optimizer/
preflight.rs

1use crate::{config::AppConfig, rpc_health, system_health};
2use anyhow::Result;
3use serde::Serialize;
4
5#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
6#[serde(rename_all = "lowercase")]
7pub enum CheckStatus {
8    Pass,
9    Warn,
10    Fail,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct PreflightCheck {
15    pub name: String,
16    pub status: CheckStatus,
17    pub message: String,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct PreflightReport {
22    pub endpoint: String,
23    pub ready: bool,
24    pub checks: Vec<PreflightCheck>,
25    pub warnings: Vec<String>,
26}
27
28pub async fn run(config: &AppConfig) -> Result<PreflightReport> {
29    run_with_expected_genesis(config, None).await
30}
31
32pub async fn run_with_expected_genesis(
33    config: &AppConfig,
34    expected_genesis_hash: Option<&str>,
35) -> Result<PreflightReport> {
36    let rpc = rpc_health::check_rpc_health(config).await?;
37    let system = system_health::check_system_health();
38
39    Ok(evaluate_reports(rpc, system, expected_genesis_hash))
40}
41
42fn evaluate_reports(
43    rpc: rpc_health::RpcHealthReport,
44    system: system_health::SystemHealthReport,
45    expected_genesis_hash: Option<&str>,
46) -> PreflightReport {
47    let mut checks = Vec::new();
48
49    checks.push(PreflightCheck {
50        name: "rpc_health".to_string(),
51        status: if rpc.healthy {
52            CheckStatus::Pass
53        } else {
54            CheckStatus::Fail
55        },
56        message: format!("RPC health status: {}", rpc.health_status),
57    });
58
59    checks.push(match &rpc.solana_version {
60        Some(version) => PreflightCheck {
61            name: "solana_version".to_string(),
62            status: CheckStatus::Pass,
63            message: format!("Detected Solana/Agave version: {version}"),
64        },
65        None => PreflightCheck {
66            name: "solana_version".to_string(),
67            status: CheckStatus::Fail,
68            message: "Unable to determine Solana/Agave version".to_string(),
69        },
70    });
71
72    checks.push(match (&rpc.genesis_hash, expected_genesis_hash) {
73        (Some(observed), Some(expected)) if observed == expected => PreflightCheck {
74            name: "genesis_hash".to_string(),
75            status: CheckStatus::Pass,
76            message: format!("Genesis hash matches expected value: {observed}"),
77        },
78        (Some(observed), Some(expected)) => PreflightCheck {
79            name: "genesis_hash".to_string(),
80            status: CheckStatus::Fail,
81            message: format!("Genesis hash mismatch: expected {expected}, observed {observed}"),
82        },
83        (Some(observed), None) => PreflightCheck {
84            name: "genesis_hash".to_string(),
85            status: CheckStatus::Pass,
86            message: format!("Genesis hash: {observed}"),
87        },
88        (None, _) => PreflightCheck {
89            name: "genesis_hash".to_string(),
90            status: CheckStatus::Fail,
91            message: "Unable to retrieve genesis hash".to_string(),
92        },
93    });
94
95    checks.push(match rpc.slot {
96        Some(slot) => PreflightCheck {
97            name: "current_slot".to_string(),
98            status: CheckStatus::Pass,
99            message: format!("Current slot: {slot}"),
100        },
101        None => PreflightCheck {
102            name: "current_slot".to_string(),
103            status: CheckStatus::Fail,
104            message: "Unable to retrieve current slot".to_string(),
105        },
106    });
107
108    checks.push(match &rpc.latest_blockhash {
109        Some(blockhash) => PreflightCheck {
110            name: "latest_blockhash".to_string(),
111            status: CheckStatus::Pass,
112            message: format!("Latest blockhash available: {blockhash}"),
113        },
114        None => PreflightCheck {
115            name: "latest_blockhash".to_string(),
116            status: CheckStatus::Fail,
117            message: "Unable to retrieve latest blockhash".to_string(),
118        },
119    });
120
121    let latency_values = [
122        ("getHealth", Some(rpc.get_health_latency_ms)),
123        ("getVersion", rpc.get_version_latency_ms),
124        ("getSlot", rpc.get_slot_latency_ms),
125        ("getLatestBlockhash", rpc.get_latest_blockhash_latency_ms),
126    ];
127
128    let slow_calls: Vec<String> = latency_values
129        .iter()
130        .filter_map(|(name, latency)| match latency {
131            Some(ms) if *ms > 1_000 => Some(format!("{name}: {ms} ms")),
132            _ => None,
133        })
134        .collect();
135
136    checks.push(if slow_calls.is_empty() {
137        PreflightCheck {
138            name: "rpc_latency".to_string(),
139            status: CheckStatus::Pass,
140            message: "RPC latency is within the 1000 ms threshold".to_string(),
141        }
142    } else {
143        PreflightCheck {
144            name: "rpc_latency".to_string(),
145            status: CheckStatus::Warn,
146            message: format!("High RPC latency detected: {}", slow_calls.join(", ")),
147        }
148    });
149
150    checks.push(PreflightCheck {
151        name: "cpu".to_string(),
152        status: if system.logical_cpu_count < 2 {
153            CheckStatus::Fail
154        } else if system.logical_cpu_count < 8 {
155            CheckStatus::Warn
156        } else {
157            CheckStatus::Pass
158        },
159        message: format!("Logical CPU count: {}", system.logical_cpu_count),
160    });
161
162    checks.push(PreflightCheck {
163        name: "memory_total".to_string(),
164        status: if system.total_memory_gib() < 4.0 {
165            CheckStatus::Fail
166        } else if system.total_memory_gib() < 16.0 {
167            CheckStatus::Warn
168        } else {
169            CheckStatus::Pass
170        },
171        message: format!("Total memory: {:.1} GiB", system.total_memory_gib()),
172    });
173
174    checks.push(PreflightCheck {
175        name: "memory_available".to_string(),
176        status: if system.available_memory_gib() < 4.0 {
177            CheckStatus::Warn
178        } else {
179            CheckStatus::Pass
180        },
181        message: format!("Available memory: {:.2} GiB", system.available_memory_gib()),
182    });
183
184    checks.push(match system.disk_available_gib() {
185        Some(available) => PreflightCheck {
186            name: "disk_available".to_string(),
187            status: if available < 10.0 {
188                CheckStatus::Fail
189            } else if available < 50.0 {
190                CheckStatus::Warn
191            } else {
192                CheckStatus::Pass
193            },
194            message: match system.disk_total_gib() {
195                Some(total) => {
196                    format!("Disk space: {available:.1} GiB available / {total:.1} GiB total")
197                }
198                None => format!("Disk space available: {available:.1} GiB"),
199            },
200        },
201        None => PreflightCheck {
202            name: "disk_available".to_string(),
203            status: CheckStatus::Warn,
204            message: "Unable to determine disk capacity".to_string(),
205        },
206    });
207
208    let ready = !checks.iter().any(|check| check.status == CheckStatus::Fail);
209
210    PreflightReport {
211        endpoint: rpc.endpoint,
212        ready,
213        checks,
214        warnings: rpc
215            .warnings
216            .into_iter()
217            .filter(|warning| !warning.contains("latency is high"))
218            .collect(),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn healthy_rpc_report() -> rpc_health::RpcHealthReport {
227        rpc_health::RpcHealthReport {
228            endpoint: "http://localhost:8899".to_string(),
229            healthy: true,
230            health_status: "ok".to_string(),
231            solana_version: Some("4.2.0-rc.1".to_string()),
232            genesis_hash: Some("test-genesis-hash".to_string()),
233            slot: Some(438_200_491),
234            latest_blockhash: Some("test-blockhash".to_string()),
235            get_health_latency_ms: 100,
236            get_version_latency_ms: Some(100),
237            get_genesis_hash_latency_ms: Some(100),
238            get_slot_latency_ms: Some(100),
239            get_latest_blockhash_latency_ms: Some(100),
240            warnings: Vec::new(),
241        }
242    }
243
244    fn healthy_system_report() -> system_health::SystemHealthReport {
245        system_health::SystemHealthReport {
246            logical_cpu_count: 16,
247            total_memory_bytes: 64 * 1024 * 1024 * 1024,
248            available_memory_bytes: 32 * 1024 * 1024 * 1024,
249            disk_total_bytes: Some(2 * 1024 * 1024 * 1024 * 1024),
250            disk_available_bytes: Some(500 * 1024 * 1024 * 1024),
251        }
252    }
253
254    #[test]
255    fn healthy_rpc_is_ready() {
256        let report = evaluate_reports(healthy_rpc_report(), healthy_system_report(), None);
257
258        assert!(report.ready);
259        assert!(report
260            .checks
261            .iter()
262            .all(|check| check.status != CheckStatus::Fail));
263    }
264
265    #[test]
266    fn high_latency_warns_but_remains_ready() {
267        let mut rpc = healthy_rpc_report();
268        rpc.get_health_latency_ms = 1_500;
269        rpc.warnings
270            .push("getHealth latency is high: 1500 ms".to_string());
271
272        let report = evaluate_reports(rpc, healthy_system_report(), None);
273
274        assert!(report.ready);
275        assert!(report
276            .checks
277            .iter()
278            .any(|check| { check.name == "rpc_latency" && check.status == CheckStatus::Warn }));
279    }
280
281    #[test]
282    fn unhealthy_rpc_is_not_ready() {
283        let mut rpc = healthy_rpc_report();
284        rpc.healthy = false;
285        rpc.health_status = "unhealthy".to_string();
286
287        let report = evaluate_reports(rpc, healthy_system_report(), None);
288
289        assert!(!report.ready);
290        assert!(report
291            .checks
292            .iter()
293            .any(|check| { check.name == "rpc_health" && check.status == CheckStatus::Fail }));
294    }
295
296    #[test]
297    fn matching_expected_genesis_is_ready() {
298        let rpc = healthy_rpc_report();
299
300        let report = evaluate_reports(rpc, healthy_system_report(), Some("test-genesis-hash"));
301
302        assert!(report.ready);
303        assert!(report
304            .checks
305            .iter()
306            .any(|check| { check.name == "genesis_hash" && check.status == CheckStatus::Pass }));
307    }
308
309    #[test]
310    fn mismatched_expected_genesis_is_not_ready() {
311        let rpc = healthy_rpc_report();
312
313        let report = evaluate_reports(rpc, healthy_system_report(), Some("wrong-genesis-hash"));
314
315        assert!(!report.ready);
316        assert!(report
317            .checks
318            .iter()
319            .any(|check| { check.name == "genesis_hash" && check.status == CheckStatus::Fail }));
320    }
321
322    #[test]
323    fn missing_genesis_hash_is_not_ready() {
324        let mut rpc = healthy_rpc_report();
325        rpc.genesis_hash = None;
326
327        let report = evaluate_reports(rpc, healthy_system_report(), None);
328
329        assert!(!report.ready);
330        assert!(report
331            .checks
332            .iter()
333            .any(|check| { check.name == "genesis_hash" && check.status == CheckStatus::Fail }));
334    }
335
336    #[test]
337    fn missing_slot_is_not_ready() {
338        let mut rpc = healthy_rpc_report();
339        rpc.slot = None;
340
341        let report = evaluate_reports(rpc, healthy_system_report(), None);
342
343        assert!(!report.ready);
344        assert!(report
345            .checks
346            .iter()
347            .any(|check| { check.name == "current_slot" && check.status == CheckStatus::Fail }));
348    }
349    #[test]
350    fn low_memory_is_not_ready() {
351        let mut system = healthy_system_report();
352        system.total_memory_bytes = 2 * 1024 * 1024 * 1024;
353
354        let report = evaluate_reports(healthy_rpc_report(), system, None);
355
356        assert!(!report.ready);
357        assert!(report
358            .checks
359            .iter()
360            .any(|check| { check.name == "memory_total" && check.status == CheckStatus::Fail }));
361    }
362
363    #[test]
364    fn low_available_memory_warns_but_remains_ready() {
365        let mut system = healthy_system_report();
366        system.available_memory_bytes = 512 * 1024 * 1024;
367
368        let report = evaluate_reports(healthy_rpc_report(), system, None);
369
370        assert!(report.ready);
371        assert!(report.checks.iter().any(|check| {
372            check.name == "memory_available" && check.status == CheckStatus::Warn
373        }));
374    }
375
376    #[test]
377    fn low_disk_warns_but_remains_ready() {
378        let mut system = healthy_system_report();
379        system.disk_available_bytes = Some(25 * 1024 * 1024 * 1024);
380
381        let report = evaluate_reports(healthy_rpc_report(), system, None);
382
383        assert!(report.ready);
384        assert!(report
385            .checks
386            .iter()
387            .any(|check| { check.name == "disk_available" && check.status == CheckStatus::Warn }));
388    }
389}