1use std::{
14 io::{self, Seek, Write},
15 path::{Path, PathBuf},
16 time::{Duration, Instant},
17};
18
19use hotshot_types::traits::metrics::Metrics;
20use rand::Rng;
21use tempfile::Builder;
22
23#[cfg(target_os = "linux")]
24const MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
25
26const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
31
32const MAX_SAMPLES: usize = 64;
33const BUDGET: Duration = Duration::from_secs(1);
34
35const FSYNC_ERROR: Duration = Duration::from_millis(500);
41
42#[cfg(target_os = "linux")]
48const NETWORK_FS: &[&str] = &[
49 "nfs",
50 "nfs4",
51 "cifs",
52 "smb3",
53 "smbfs",
54 "9p",
55 "ceph",
56 "glusterfs",
57 "lustre",
58 "afs",
59 "fuse.sshfs",
60 "fuse.s3fs",
61 "fuse.rclone",
62 "fuse.juicefs",
63 "fuse.gcsfuse",
64 "fuse.blobfuse",
65 "fuse.davfs",
66 "fuse.glusterfs",
67 "fuse.cephfs",
68 "fuse.ceph",
69 "virtiofs",
70 "vboxsf",
71 "fuse.grpcfuse",
72 "fuse.osxfs",
73];
74#[cfg(target_os = "linux")]
75const VOLATILE_FS: &[&str] = &["tmpfs", "ramfs"];
76#[cfg(target_os = "linux")]
77const CONTAINER_FS: &[&str] = &["overlay", "aufs"];
78#[cfg(target_os = "linux")]
79const LOCAL_FS: &[&str] = &[
80 "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "bcachefs", "jfs", "reiserfs",
81];
82
83#[derive(Clone, Debug)]
84pub struct StorageProbe {
85 pub fs: Option<FsInfo>,
88 pub fsync: Option<FsyncStats>,
89 fsync_error: Option<String>,
91 pub pragmas: Option<SqlitePragmas>,
93}
94
95#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
99#[derive(Clone, Debug)]
100pub struct FsInfo {
101 pub fs_type: String,
102 pub source: String,
103 pub mount_point: PathBuf,
104 pub class: FsClass,
105}
106
107#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum FsClass {
111 Local,
112 Network,
114 Volatile,
116 Container,
118 Unknown,
120}
121
122#[derive(Clone, Debug)]
125pub struct FsyncStats {
126 pub samples: usize,
128 pub p50: Duration,
129 pub max: Duration,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct SqlitePragmas {
134 pub journal_mode: String,
135 pub synchronous: &'static str,
136 pub auto_vacuum: &'static str,
140 pub page_size: u64,
141}
142
143pub async fn probe(dir: &Path, pragmas: Option<SqlitePragmas>) -> anyhow::Result<StorageProbe> {
150 let page_size = pragmas.as_ref().map(|p| p.page_size);
151 let owned = dir.to_path_buf();
152
153 let blocking = tokio::task::spawn_blocking(move || {
154 let fs = resolve_fs_info(&owned);
155 log_fs_info(&owned, fs.as_ref());
156 (fs, fsync_probe(&owned, page_size))
157 });
158
159 let (fs, fsync) = match tokio::time::timeout(PROBE_TIMEOUT, blocking).await {
162 Ok(Ok(result)) => result,
163 Ok(Err(err)) => {
164 tracing::warn!(%err, "storage probe: probe task failed, skipping");
165 (None, Err(io::Error::other(err)))
166 },
167 Err(_) => anyhow::bail!(
168 "storage directory {} did not respond within {}s. The usual cause is a network mount \
169 whose server is unreachable: a hard-mounted NFS or CIFS export retries forever \
170 rather than returning an error. Check that the mount is alive. The node cannot \
171 persist consensus state on a filesystem that does not respond.",
172 dir.display(),
173 PROBE_TIMEOUT.as_secs(),
174 ),
175 };
176
177 let (fsync, fsync_error) = match fsync {
178 Ok(stats) => (Some(stats), None),
179 Err(err) => (None, Some(err.to_string())),
180 };
181
182 let probe = StorageProbe {
183 fs,
184 fsync,
185 fsync_error,
186 pragmas,
187 };
188 probe.log_fsync_and_pragmas();
189 Ok(probe)
190}
191
192fn log_fs_info(path: &Path, fs: Option<&FsInfo>) {
193 let Some(fs) = fs else {
194 tracing::debug!(
195 path = %path.display(),
196 "storage probe: filesystem was not probed on this platform"
197 );
198 return;
199 };
200
201 tracing::info!(
202 target: "announce",
203 fs = fs.fs_type.as_str(),
204 source = fs.source.as_str(),
205 mount = %fs.mount_point.display(),
206 class = fs_class_label(fs.class),
207 path = %path.display(),
208 "storage probe: filesystem"
209 );
210
211 match fs.class {
212 FsClass::Local => {},
213 FsClass::Network => tracing::error!(
214 "storage probe: ESPRESSO_NODE_STORAGE_PATH is on a network or host-shared filesystem. \
215 SQLite file locking does not work reliably on these and the database can be \
216 corrupted. Move it to a disk attached to this machine."
217 ),
218 FsClass::Volatile => tracing::warn!(
219 "storage probe: ESPRESSO_NODE_STORAGE_PATH is on a RAM-backed filesystem. Everything \
220 under it is erased when this machine reboots, and the node will have to resync from \
221 genesis. Point it at a real disk, for example /var/lib/espresso."
222 ),
223 FsClass::Container => tracing::warn!(
224 "storage probe: ESPRESSO_NODE_STORAGE_PATH is inside the container's own writable \
225 layer, not a mounted volume. The database is destroyed whenever the container is \
226 recreated, which includes every image update. Mount a Docker volume or a host \
227 directory and point ESPRESSO_NODE_STORAGE_PATH at it."
228 ),
229 FsClass::Unknown => tracing::warn!(
230 "storage probe: Could not classify the filesystem backing ESPRESSO_NODE_STORAGE_PATH. \
231 If this is a network mount or a RAM disk, move the database to a local persistent \
232 disk."
233 ),
234 }
235}
236
237impl StorageProbe {
238 fn log_fsync_and_pragmas(&self) {
239 let journal_mode = self
240 .pragmas
241 .as_ref()
242 .map_or("n/a", |p| p.journal_mode.as_str());
243 let synchronous = self.pragmas.as_ref().map_or("n/a", |p| p.synchronous);
244 let auto_vacuum = self.pragmas.as_ref().map_or("n/a", |p| p.auto_vacuum);
245 let page_size = self.pragmas.as_ref().map(|p| p.page_size);
246
247 tracing::info!(
250 target: "announce",
251 journal_mode,
252 synchronous,
253 auto_vacuum,
254 page_size,
255 p50_us = self.fsync.as_ref().map(|f| f.p50.as_micros()),
256 max_us = self.fsync.as_ref().map(|f| f.max.as_micros()),
257 samples = self.fsync.as_ref().map(|f| f.samples),
258 "storage probe: fsync and pragmas"
259 );
260
261 let Some(fsync) = &self.fsync else {
262 let error = self.fsync_error.as_deref().unwrap_or("unknown error");
263 tracing::warn!(
264 error,
265 "storage probe: Could not write a test file into the storage directory. Check \
266 that it exists and is writable by the user running the node."
267 );
268 return;
269 };
270
271 if fsync.p50 > FSYNC_ERROR {
272 tracing::error!(
273 "storage probe: Disk is extremely slow to commit writes, far beyond what \
274 consensus can absorb per view. Use a local SSD or NVMe; network storage and \
275 throttled cloud volumes (exhausted burst credits) are the usual cause."
276 );
277 }
278 }
279
280 fn backend_label(&self) -> &'static str {
281 if self.pragmas.is_some() {
282 "sqlite"
283 } else {
284 "fs"
285 }
286 }
287
288 pub fn register(&self, metrics: &dyn Metrics) {
292 let (fs_type, fs_class) = match &self.fs {
293 Some(fs) => (fs.fs_type.clone(), fs_class_label(fs.class).to_string()),
294 None => ("unknown".to_string(), "not_probed".to_string()),
295 };
296
297 metrics
300 .text_family(
301 "info".to_string(),
302 vec![
303 "backend".to_string(),
304 "fs".to_string(),
305 "class".to_string(),
306 "journal_mode".to_string(),
307 "synchronous".to_string(),
308 "auto_vacuum".to_string(),
309 ],
310 )
311 .create(vec![
312 self.backend_label().to_string(),
313 fs_type,
314 fs_class,
315 self.pragmas
316 .as_ref()
317 .map_or("n/a".to_string(), |p| p.journal_mode.clone()),
318 self.pragmas
319 .as_ref()
320 .map_or("n/a".to_string(), |p| p.synchronous.to_string()),
321 self.pragmas
322 .as_ref()
323 .map_or("n/a".to_string(), |p| p.auto_vacuum.to_string()),
324 ]);
325
326 let Some(fsync) = &self.fsync else {
327 return;
328 };
329
330 let fsync_micros =
331 metrics.gauge_family("fsync_micros".to_string(), vec!["stat".to_string()]);
332 for (stat, value) in [("p50", fsync.p50), ("max", fsync.max)] {
333 fsync_micros
334 .create(vec![stat.to_string()])
335 .set(value.as_micros() as usize);
336 }
337 }
338}
339
340fn fs_class_label(class: FsClass) -> &'static str {
341 match class {
342 FsClass::Local => "local",
343 FsClass::Network => "network",
344 FsClass::Volatile => "volatile",
345 FsClass::Container => "container",
346 FsClass::Unknown => "unknown",
347 }
348}
349
350#[cfg(target_os = "linux")]
351fn unknown_fs_info() -> FsInfo {
352 FsInfo {
353 fs_type: "unknown".to_string(),
354 source: "unknown".to_string(),
355 mount_point: PathBuf::from("unknown"),
356 class: FsClass::Unknown,
357 }
358}
359
360#[cfg(target_os = "linux")]
361fn resolve_fs_info(dir: &Path) -> Option<FsInfo> {
362 let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
363
364 let mountinfo = match std::fs::read_to_string(MOUNTINFO_PATH) {
365 Ok(content) => content,
366 Err(err) => {
367 tracing::warn!(?err, "storage probe: failed to read {MOUNTINFO_PATH}");
369 return Some(unknown_fs_info());
370 },
371 };
372
373 Some(parse_mountinfo(&mountinfo, &canonical).unwrap_or_else(unknown_fs_info))
374}
375
376#[cfg(not(target_os = "linux"))]
378fn resolve_fs_info(_dir: &Path) -> Option<FsInfo> {
379 None
380}
381
382#[cfg(target_os = "linux")]
389fn parse_mountinfo(mountinfo: &str, path: &Path) -> Option<FsInfo> {
390 mountinfo
391 .lines()
392 .filter_map(parse_mountinfo_line)
393 .filter(|(mount_point, ..)| path.starts_with(mount_point))
394 .max_by_key(|(mount_point, ..)| mount_point.as_os_str().len())
395 .map(|(mount_point, fs_type, source)| FsInfo {
396 class: classify_fs(&fs_type),
397 fs_type,
398 source,
399 mount_point,
400 })
401}
402
403#[cfg(target_os = "linux")]
410fn parse_mountinfo_line(line: &str) -> Option<(PathBuf, String, String)> {
411 let (fields, fs_fields) = line.split_once(" - ")?;
412
413 let mount_point = fields.split_whitespace().nth(4)?;
414 let mut fs_fields = fs_fields.split_whitespace();
415 let fs_type = fs_fields.next()?;
416 let source = fs_fields.next()?;
417
418 Some((
419 PathBuf::from(mount_point.replace("\\040", " ")),
420 fs_type.to_string(),
421 source.replace("\\040", " "),
422 ))
423}
424
425#[cfg(target_os = "linux")]
428fn classify_fs(fs_type: &str) -> FsClass {
429 if NETWORK_FS.contains(&fs_type) {
430 FsClass::Network
431 } else if VOLATILE_FS.contains(&fs_type) {
432 FsClass::Volatile
433 } else if CONTAINER_FS.contains(&fs_type) {
434 FsClass::Container
435 } else if LOCAL_FS.contains(&fs_type) {
436 FsClass::Local
437 } else {
438 FsClass::Unknown
439 }
440}
441
442fn fsync_probe(dir: &Path, page_size: Option<u64>) -> io::Result<FsyncStats> {
448 let page_size = page_size.unwrap_or(4096).clamp(512, 65536) as usize;
449 let mut buf = vec![0u8; page_size];
450 rand::thread_rng().fill(&mut buf[..]);
453
454 let mut file = Builder::new().tempfile_in(dir)?;
455 file.write_all(&buf)?;
456 file.as_file().sync_data()?;
457
458 let mut samples = Vec::with_capacity(MAX_SAMPLES);
459 let start = Instant::now();
460 loop {
461 let sample_start = Instant::now();
462 file.seek(io::SeekFrom::Start(0))?;
463 file.write_all(&buf)?;
464 file.as_file().sync_data()?;
465 samples.push(sample_start.elapsed());
466
467 if samples.len() >= MAX_SAMPLES || start.elapsed() >= BUDGET {
468 break;
469 }
470 }
471
472 samples.sort_unstable();
473 Ok(FsyncStats {
474 p50: samples[samples.len() / 2],
475 max: *samples.last().expect("loop takes at least one sample"),
476 samples: samples.len(),
477 })
478}
479
480#[cfg(test)]
481mod test {
482 use hotshot_query_service::metrics::PrometheusMetrics;
483 use tempfile::TempDir;
484
485 use super::*;
486
487 #[test]
488 fn fsync_probe_reports_bounded_samples() {
489 let dir = TempDir::new().unwrap();
490 let stats = fsync_probe(dir.path(), None).unwrap();
491
492 assert!((1..=MAX_SAMPLES).contains(&stats.samples));
493 assert!(stats.max >= stats.p50);
494 }
495
496 #[test]
497 fn register_exports_info_metric_without_fsync() {
498 let probe = StorageProbe {
499 fs: None,
500 fsync: None,
501 fsync_error: None,
502 pragmas: None,
503 };
504 let metrics = PrometheusMetrics::default();
505
506 probe.register(&metrics);
507
508 assert!(metrics.export().unwrap().contains("info"));
509 }
510
511 #[cfg(target_os = "linux")]
512 #[test]
513 fn fs_class_lists_are_disjoint_and_lowercase() {
514 let lists: [&[&str]; 4] = [NETWORK_FS, VOLATILE_FS, CONTAINER_FS, LOCAL_FS];
515
516 for list in lists {
517 for entry in list {
518 assert_eq!(*entry, entry.to_lowercase(), "{entry} is not lowercase");
519 }
520 }
521
522 for (i, a) in lists.iter().enumerate() {
523 for b in &lists[i + 1..] {
524 for entry in *a {
525 assert!(!b.contains(entry), "{entry} appears in two fs-class lists");
526 }
527 }
528 }
529 }
530
531 #[cfg(target_os = "linux")]
532 const MOUNTINFO_FIXTURE: &str = concat!(
533 "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw\n",
534 "37 35 0:31 / /var/lib/espresso rw,relatime shared:2 - nfs4 nfsserver:/export rw\n",
535 "38 35 0:32 / /mnt/my\\040disk rw,relatime shared:3 - ext4 /dev/sdb1 rw\n",
536 "39 35 0:33 / /proc rw,nosuid,relatime - proc proc rw\n",
537 "40 35 0:34 / /run rw,nosuid shared:1 master:2 propagate_from:3 - tmpfs tmpfs rw\n",
538 "41 35 0:35 / /mnt/overmounted rw,relatime - ext4 /dev/sdc1 rw\n",
539 "42 35 0:36 / /mnt/overmounted rw,relatime - tmpfs tmpfs rw\n",
540 );
541
542 #[cfg(target_os = "linux")]
543 #[test]
544 fn classify_fs_representative_per_class() {
545 assert_eq!(classify_fs("ext4"), FsClass::Local);
546 assert_eq!(classify_fs("nfs4"), FsClass::Network);
547 assert_eq!(classify_fs("tmpfs"), FsClass::Volatile);
548 assert_eq!(classify_fs("overlay"), FsClass::Container);
549 assert_eq!(classify_fs("made_up_fs"), FsClass::Unknown);
550 }
551
552 #[cfg(target_os = "linux")]
553 #[test]
554 fn parse_mountinfo_picks_longest_matching_mount() {
555 let info = parse_mountinfo(
556 MOUNTINFO_FIXTURE,
557 Path::new("/var/lib/espresso/sqlite/database"),
558 )
559 .expect("mount found");
560
561 assert_eq!(info.mount_point, Path::new("/var/lib/espresso"));
562 assert_eq!(info.fs_type, "nfs4");
563 assert_eq!(info.source, "nfsserver:/export");
564 assert_eq!(info.class, FsClass::Network);
565 }
566
567 #[cfg(target_os = "linux")]
568 #[test]
569 fn parse_mountinfo_unescapes_mount_point() {
570 let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new("/mnt/my disk/database")).unwrap();
571
572 assert_eq!(info.mount_point, Path::new("/mnt/my disk"));
573 assert_eq!(info.source, "/dev/sdb1");
574 }
575
576 #[cfg(target_os = "linux")]
579 #[test]
580 fn parse_mountinfo_overmount_prefers_later_entry() {
581 let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new("/mnt/overmounted/database"))
582 .expect("mount found");
583
584 assert_eq!(info.fs_type, "tmpfs");
585 assert_eq!(info.class, FsClass::Volatile);
586 }
587
588 #[cfg(target_os = "linux")]
589 #[test]
590 fn parse_mountinfo_line_variants() {
591 let cases = [
592 ("/home/user/data", "/", "ext4", FsClass::Local),
594 ("/proc/self", "/proc", "proc", FsClass::Unknown),
596 ("/run/lock", "/run", "tmpfs", FsClass::Volatile),
598 ];
599 for (path, mount, fs_type, class) in cases {
600 let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new(path)).unwrap_or_else(|| {
601 panic!("expected a mount for {path}");
602 });
603 assert_eq!(info.mount_point, Path::new(mount), "{path}");
604 assert_eq!(info.fs_type, fs_type, "{path}");
605 assert_eq!(info.class, class, "{path}");
606 }
607
608 assert!(parse_mountinfo("", Path::new("/anything")).is_none());
609 }
610}