Skip to content

Commit 5341a1b

Browse files
perf(l1): replace synchronous disk I/O with async operations in snap sync (#6113)
## Motivation During snap sync, multiple file system operations block the tokio runtime: - Directory creation (`std::fs::create_dir_all`) - File reading (`std::fs::read`) - Directory listing (`std::fs::read_dir`) - Directory removal (`std::fs::remove_dir_all`) These synchronous operations cause the async runtime to stall, preventing network operations from proceeding while disk I/O completes. This is particularly problematic during: 1. Snapshot file writing (account states, storage states) 2. Snapshot file reading during trie insertion 3. Cleanup after insertion completes This PR addresses **roadmap item 1.6 (Async Disk I/O)** from the snap sync improvement plan. ## Description ### Approach: Hybrid Async Strategy We use a hybrid approach for async file operations: | Operation | Method | Rationale | |-----------|--------|-----------| | `create_dir_all` | `tokio::fs` | Simple, no iterator complexity | | `read` | `tokio::fs` | Simple, direct async support | | `remove_dir_all` | `tokio::fs` | Simple, direct async support | | `read_dir` | `spawn_blocking` | Returns iterator with complex lifetimes; wrapping in spawn_blocking is cleaner than manual async iteration | ### New Module: `snap/async_fs.rs` Created a new module with async wrappers that provide consistent error handling: ```rust /// Ensures a directory exists, creating it if necessary. pub async fn ensure_dir_exists(path: &Path) -> Result<(), SnapError> /// Reads all file paths from a directory (sorted for deterministic ordering). pub async fn read_dir_paths(dir: &Path) -> Result<Vec<PathBuf>, SnapError> /// Reads the contents of a file asynchronously. pub async fn read_file(path: &Path) -> Result<Vec<u8>, SnapError> /// Removes a directory and all its contents asynchronously. pub async fn remove_dir_all(path: &Path) -> Result<(), SnapError> /// Writes data to a file asynchronously. pub async fn write_file(path: &Path, contents: &[u8]) -> Result<(), SnapError> /// Removes a directory if it exists, ignoring NotFound errors. pub async fn remove_dir_all_if_exists(path: &Path) -> Result<(), SnapError> ``` All functions return `SnapError::FileSystem` with: - Operation name (for debugging) - Path (to identify which file/directory failed) - Error kind (from `std::io::ErrorKind`) ### Changes to `snap/client.rs` **Before (4 occurrences):** ```rust if !std::fs::exists(account_state_snapshots_dir).map_err(|_| { SnapError::SnapshotDir("State snapshots directory does not exist".to_string()) })? { std::fs::create_dir_all(account_state_snapshots_dir).map_err(|_| { SnapError::SnapshotDir("Failed to create state snapshots directory".to_string()) })?; } ``` **After:** ```rust async_fs::ensure_dir_exists(account_state_snapshots_dir).await?; ``` Locations updated: 1. `request_account_range()` - account state snapshot directory (loop) 2. `request_account_range()` - account state snapshot directory (final flush) 3. `request_storage_ranges()` - storage snapshot directory (loop) 4. `request_storage_ranges()` - storage snapshot directory (final flush) ### Changes to `sync/snap_sync.rs` #### Code Hashes Processing **Before:** ```rust std::fs::create_dir_all(&code_hashes_snapshot_dir).map_err(|_| SyncError::CorruptPath)?; // ... for entry in std::fs::read_dir(&code_hashes_dir) .map_err(|_| SyncError::CodeHashesSnapshotsDirNotFound)? { let entry = entry.map_err(|_| SyncError::CorruptPath)?; let snapshot_contents = std::fs::read(entry.path()) .map_err(|err| SyncError::SnapshotReadError(entry.path(), err))?; // ... } std::fs::remove_dir_all(code_hashes_dir) .map_err(|_| SyncError::CodeHashesSnapshotsDirNotFound)?; ``` **After:** ```rust async_fs::ensure_dir_exists(&code_hashes_snapshot_dir).await?; // ... let code_hash_files = async_fs::read_dir_paths(&code_hashes_dir).await?; for file_path in code_hash_files { let snapshot_contents = async_fs::read_file(&file_path).await?; // ... } async_fs::remove_dir_all(&code_hashes_dir).await?; ``` #### Account Insertion (non-rocksdb) **Before:** ```rust for entry in std::fs::read_dir(account_state_snapshots_dir) .map_err(|_| SyncError::AccountStateSnapshotsDirNotFound)? { let entry = entry.map_err(|err| SyncError::SnapshotReadError(...))?; let snapshot_path = entry.path(); let snapshot_contents = std::fs::read(&snapshot_path) .map_err(|err| SyncError::SnapshotReadError(...))?; // ... } std::fs::remove_dir_all(account_state_snapshots_dir) .map_err(|_| SyncError::AccountStoragesSnapshotsDirNotFound)?; ``` **After:** ```rust let snapshot_files = async_fs::read_dir_paths(account_state_snapshots_dir).await?; for snapshot_path in snapshot_files { let snapshot_contents = async_fs::read_file(&snapshot_path).await?; // ... } async_fs::remove_dir_all(account_state_snapshots_dir).await?; ``` #### Storage Insertion (non-rocksdb) Same pattern as account insertion - replaced `read_dir` + `read` + `remove_dir_all` with async equivalents. #### Account Insertion (rocksdb) **Before:** ```rust let file_paths: Vec<PathBuf> = std::fs::read_dir(account_state_snapshots_dir) .map_err(|_| SyncError::AccountStateSnapshotsDirNotFound)? .collect::<Result<Vec<_>, _>>() .map_err(|_| SyncError::AccountStateSnapshotsDirNotFound)? .into_iter() .map(|res| res.path()) .collect(); // ... std::fs::remove_dir_all(account_state_snapshots_dir) .map_err(|_| SyncError::AccountStateSnapshotsDirNotFound)?; std::fs::remove_dir_all(get_rocksdb_temp_accounts_dir(datadir)) .map_err(|e| SyncError::AccountTempDBDirNotFound(e.to_string()))?; ``` **After:** ```rust let file_paths: Vec<PathBuf> = async_fs::read_dir_paths(account_state_snapshots_dir).await?; // ... async_fs::remove_dir_all(account_state_snapshots_dir).await?; async_fs::remove_dir_all(&get_rocksdb_temp_accounts_dir(datadir)).await?; ``` #### Storage Insertion (rocksdb) Same pattern - replaced sync fs operations with async equivalents. ### Summary of Changes | File | Before | After | Change | |------|--------|-------|--------| | `snap/async_fs.rs` | - | 189 lines | New module | | `snap/mod.rs` | - | +1 line | Module export | | `snap/client.rs` | 42 lines | 4 lines | -38 lines (4 patterns simplified) | | `sync/snap_sync.rs` | 82 lines | 16 lines | -66 lines (16 calls simplified) | | `Cargo.toml` | - | +1 line | tempfile dev-dependency | | `Cargo.lock` | - | +1 line | tempfile entry | **Net change:** +223 lines, -93 lines = +130 lines (including 189 lines of new module with tests) ## How to Test ### Unit Tests Run the async_fs module tests: ```bash cargo test -p ethrex-p2p async_fs ``` Expected output: ``` running 6 tests test snap::async_fs::tests::test_remove_dir_all_if_exists ... ok test snap::async_fs::tests::test_ensure_dir_exists_idempotent ... ok test snap::async_fs::tests::test_remove_dir_all ... ok test snap::async_fs::tests::test_ensure_dir_exists_creates_new ... ok test snap::async_fs::tests::test_read_write_file ... ok test snap::async_fs::tests::test_read_dir_paths ... ok test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 38 filtered out ``` ### Compilation Tests Verify both feature configurations compile: ```bash # Without rocksdb (uses non-rocksdb insert_accounts/insert_storages) cargo check -p ethrex-p2p # With rocksdb (uses rocksdb-specific insert_accounts/insert_storages) cargo check -p ethrex-p2p --features rocksdb ``` ### Integration Testing Run a snap sync on testnet to verify: 1. Account state snapshots are written and read correctly 2. Storage snapshots are written and read correctly 3. Code hash snapshots are written and read correctly 4. All cleanup operations complete successfully 5. No tokio runtime blocking warnings appear in logs ```bash # Example: Run snap sync on Holesky make start-holesky-metrics-docker ``` ### Performance Verification To verify async I/O is working: 1. Monitor CPU usage during snapshot write/read phases 2. Verify network operations continue during disk I/O 3. Check that `tokio::time::sleep` polling loops respond promptly ## Files Changed | File | Description | |------|-------------| | `crates/networking/p2p/snap/async_fs.rs` | New async filesystem helpers module | | `crates/networking/p2p/snap/mod.rs` | Export async_fs module | | `crates/networking/p2p/snap/client.rs` | Use async_fs for directory creation | | `crates/networking/p2p/sync/snap_sync.rs` | Use async_fs for all file operations | | `crates/networking/p2p/Cargo.toml` | Add tempfile dev-dependency | | `Cargo.lock` | Updated lockfile | ## Checklist - [x] Code compiles without rocksdb feature - [x] Code compiles with rocksdb feature - [x] All async_fs unit tests pass - [x] No remaining `std::fs::` calls in snap sync code paths - [x] Error handling preserved (SnapError::FileSystem) - [x] Deterministic ordering maintained (read_dir_paths sorts results) ## Related - Snap Sync Roadmap: Item 1.6 (Async Disk I/O) - Parent branch: `refactor/snapsync-healing-unification` --------- Co-authored-by: Esteban Dimitroff Hodi <esteban.dimitroff@lambdaclass.com> Co-authored-by: ElFantasma <estebandh@gmail.com>
1 parent fee57b1 commit 5341a1b

8 files changed

Lines changed: 205 additions & 161 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Perf
44

5+
### 2026-05-15
6+
7+
- Replace synchronous disk I/O with async operations in snap sync [#6113](https://github.com/lambdaclass/ethrex/pull/6113)
8+
59
### 2026-05-14
610

711
- Skip `vm.run_execution()` for transfers to codeless EOAs [#6570](https://github.com/lambdaclass/ethrex/pull/6570)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/networking/p2p/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ crossbeam.workspace = true
5858

5959
[dev-dependencies]
6060
hex-literal.workspace = true
61+
tempfile = "3"
6162
criterion = { version = "0.5", features = ["html_reports"] }
6263

6364
[lib]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
//! Async file system utilities for snap sync
2+
//!
3+
//! Provides async wrappers around file system operations to avoid blocking the
4+
//! tokio runtime during disk I/O. `tokio::fs` is used for simple operations
5+
//! (internally delegates to `spawn_blocking`), while explicit `spawn_blocking`
6+
//! is used for `read_dir` to avoid async stream complexity.
7+
8+
use std::path::{Path, PathBuf};
9+
10+
use super::error::SnapError;
11+
12+
/// Ensures a directory exists, creating it if necessary.
13+
pub async fn ensure_dir_exists(path: &Path) -> Result<(), SnapError> {
14+
tokio::fs::create_dir_all(path)
15+
.await
16+
.map_err(|e| SnapError::FileSystem {
17+
operation: "create directory",
18+
path: path.to_path_buf(),
19+
kind: e.kind(),
20+
})
21+
}
22+
23+
/// Reads all file paths from a directory, sorted alphabetically.
24+
///
25+
/// Uses `spawn_blocking` with sync `read_dir` since we always need all paths
26+
/// upfront (for `ingest_external_file` or batch iteration).
27+
pub async fn read_dir_paths(dir: &Path) -> Result<Vec<PathBuf>, SnapError> {
28+
let dir = dir.to_path_buf();
29+
tokio::task::spawn_blocking(move || {
30+
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
31+
.map_err(|e| SnapError::FileSystem {
32+
operation: "read directory",
33+
path: dir.clone(),
34+
kind: e.kind(),
35+
})?
36+
.map(|entry| {
37+
entry.map(|e| e.path()).map_err(|e| SnapError::FileSystem {
38+
operation: "read directory entry",
39+
path: dir.clone(),
40+
kind: e.kind(),
41+
})
42+
})
43+
.collect::<Result<Vec<_>, _>>()?;
44+
paths.sort();
45+
Ok(paths)
46+
})
47+
.await?
48+
}
49+
50+
/// Reads the contents of a file asynchronously.
51+
pub async fn read_file(path: &Path) -> Result<Vec<u8>, SnapError> {
52+
tokio::fs::read(path)
53+
.await
54+
.map_err(|e| SnapError::FileSystem {
55+
operation: "read file",
56+
path: path.to_path_buf(),
57+
kind: e.kind(),
58+
})
59+
}
60+
61+
/// Removes a directory and all its contents asynchronously.
62+
pub async fn remove_dir_all(path: &Path) -> Result<(), SnapError> {
63+
tokio::fs::remove_dir_all(path)
64+
.await
65+
.map_err(|e| SnapError::FileSystem {
66+
operation: "remove directory",
67+
path: path.to_path_buf(),
68+
kind: e.kind(),
69+
})
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use tempfile::tempdir;
76+
77+
#[tokio::test]
78+
async fn test_ensure_dir_exists_creates_new() {
79+
let temp = tempdir().unwrap();
80+
let new_dir = temp.path().join("new_dir");
81+
82+
assert!(!new_dir.exists());
83+
ensure_dir_exists(&new_dir).await.unwrap();
84+
assert!(new_dir.exists());
85+
}
86+
87+
#[tokio::test]
88+
async fn test_ensure_dir_exists_idempotent() {
89+
let temp = tempdir().unwrap();
90+
let existing_dir = temp.path().join("existing");
91+
std::fs::create_dir(&existing_dir).unwrap();
92+
93+
ensure_dir_exists(&existing_dir).await.unwrap();
94+
assert!(existing_dir.exists());
95+
}
96+
97+
#[tokio::test]
98+
async fn test_read_dir_paths() {
99+
let temp = tempdir().unwrap();
100+
101+
std::fs::write(temp.path().join("b.txt"), b"b").unwrap();
102+
std::fs::write(temp.path().join("a.txt"), b"a").unwrap();
103+
std::fs::write(temp.path().join("c.txt"), b"c").unwrap();
104+
105+
let paths = read_dir_paths(temp.path()).await.unwrap();
106+
107+
assert_eq!(paths.len(), 3);
108+
assert!(paths[0].ends_with("a.txt"));
109+
assert!(paths[1].ends_with("b.txt"));
110+
assert!(paths[2].ends_with("c.txt"));
111+
}
112+
113+
#[tokio::test]
114+
async fn test_read_file() {
115+
let temp = tempdir().unwrap();
116+
let file_path = temp.path().join("test.bin");
117+
118+
let data = b"hello world";
119+
std::fs::write(&file_path, data).unwrap();
120+
121+
let read_data = read_file(&file_path).await.unwrap();
122+
assert_eq!(read_data, data);
123+
}
124+
125+
#[tokio::test]
126+
async fn test_remove_dir_all() {
127+
let temp = tempdir().unwrap();
128+
let dir = temp.path().join("to_remove");
129+
std::fs::create_dir(&dir).unwrap();
130+
std::fs::write(dir.join("file.txt"), b"data").unwrap();
131+
132+
assert!(dir.exists());
133+
remove_dir_all(&dir).await.unwrap();
134+
assert!(!dir.exists());
135+
}
136+
}

crates/networking/p2p/snap/client.rs

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::{
1616
GetStorageRanges, GetTrieNodes, StorageRanges, TrieNodes,
1717
},
1818
},
19-
snap::{constants::*, encodable_to_proof, error::SnapError},
19+
snap::{async_fs, constants::*, encodable_to_proof, error::SnapError},
2020
sync::{AccountStorageRoots, SnapBlockSyncState, block_is_stale, update_pivot},
2121
utils::{
2222
AccountsWithStorage, dump_accounts_to_file, dump_storages_to_file,
@@ -160,13 +160,7 @@ pub async fn request_account_range(
160160
.zip(current_account_states)
161161
.collect::<Vec<(H256, AccountState)>>();
162162

163-
if !std::fs::exists(account_state_snapshots_dir).map_err(|_| {
164-
SnapError::SnapshotDir("State snapshots directory does not exist".to_string())
165-
})? {
166-
std::fs::create_dir_all(account_state_snapshots_dir).map_err(|_| {
167-
SnapError::SnapshotDir("Failed to create state snapshots directory".to_string())
168-
})?;
169-
}
163+
async_fs::ensure_dir_exists(account_state_snapshots_dir).await?;
170164

171165
let account_state_snapshots_dir_cloned = account_state_snapshots_dir.to_path_buf();
172166
write_set.spawn(async move {
@@ -289,13 +283,7 @@ pub async fn request_account_range(
289283
.zip(current_account_states)
290284
.collect::<Vec<(H256, AccountState)>>();
291285

292-
if !std::fs::exists(account_state_snapshots_dir).map_err(|_| {
293-
SnapError::SnapshotDir("State snapshots directory does not exist".to_string())
294-
})? {
295-
std::fs::create_dir_all(account_state_snapshots_dir).map_err(|_| {
296-
SnapError::SnapshotDir("Failed to create state snapshots directory".to_string())
297-
})?;
298-
}
286+
async_fs::ensure_dir_exists(account_state_snapshots_dir).await?;
299287

300288
let path = get_account_state_snapshot_file(account_state_snapshots_dir, chunk_file);
301289
dump_accounts_to_file(&path, account_state_chunk)
@@ -603,15 +591,8 @@ pub async fn request_storage_ranges(
603591
let current_account_storages = std::mem::take(&mut current_account_storages);
604592
let snapshot = current_account_storages.into_values().collect::<Vec<_>>();
605593

606-
if !std::fs::exists(account_storages_snapshots_dir).map_err(|_| {
607-
SnapError::SnapshotDir("Storage snapshots directory does not exist".to_string())
608-
})? {
609-
std::fs::create_dir_all(account_storages_snapshots_dir).map_err(|_| {
610-
SnapError::SnapshotDir(
611-
"Failed to create storage snapshots directory".to_string(),
612-
)
613-
})?;
614-
}
594+
async_fs::ensure_dir_exists(account_storages_snapshots_dir).await?;
595+
615596
let account_storages_snapshots_dir_cloned =
616597
account_storages_snapshots_dir.to_path_buf();
617598
if !disk_joinset.is_empty() {
@@ -996,13 +977,8 @@ pub async fn request_storage_ranges(
996977
{
997978
let snapshot = current_account_storages.into_values().collect::<Vec<_>>();
998979

999-
if !std::fs::exists(account_storages_snapshots_dir).map_err(|_| {
1000-
SnapError::SnapshotDir("Storage snapshots directory does not exist".to_string())
1001-
})? {
1002-
std::fs::create_dir_all(account_storages_snapshots_dir).map_err(|_| {
1003-
SnapError::SnapshotDir("Failed to create storage snapshots directory".to_string())
1004-
})?;
1005-
}
980+
async_fs::ensure_dir_exists(account_storages_snapshots_dir).await?;
981+
1006982
let path = get_account_storages_snapshot_file(account_storages_snapshots_dir, chunk_index);
1007983
dump_storages_to_file(&path, snapshot).map_err(|_| {
1008984
SnapError::SnapshotDir(format!(

crates/networking/p2p/snap/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::rlpx::error::PeerConnectionError;
77
use ethrex_rlp::error::RLPDecodeError;
88
use ethrex_storage::error::StoreError;
99
use ethrex_trie::TrieError;
10-
use spawned_concurrency::error::ActorError;
10+
use spawned_concurrency::ActorError;
1111
use std::io::ErrorKind;
1212
use std::path::PathBuf;
1313
use thiserror::Error;

crates/networking/p2p/snap/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
//! - `constants`: Protocol constants and configuration values
1313
//! - `error`: Unified error types for snap protocol operations
1414
15+
pub mod async_fs;
1516
pub mod client;
1617
pub mod constants;
1718
pub mod error;

0 commit comments

Comments
 (0)