Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
93e7662
perf(storage): migrate RECEIPTS key to fixed-width format with cursor…
azteca1998 Apr 29, 2026
69e0acc
perf(rpc): use cursor-based batch retrieval for eth_getBlockReceipts
azteca1998 Apr 29, 2026
049d3e6
revert(rpc): restore point lookups for RPC receipt paths
azteca1998 Apr 29, 2026
61d09f3
perf(rpc): use cursor with early stop for eth_getTransactionReceipt
azteca1998 Apr 29, 2026
e020e3f
fix(storage): break prefix iterator in get_transaction_location
azteca1998 Apr 29, 2026
835b6f2
fix(rpc): address review feedback on receipt error handling
azteca1998 Apr 29, 2026
ee8e0ff
docs: add perf changelog entry for receipt cursor optimization
azteca1998 Apr 29, 2026
0990157
fix(storage): address second round of review feedback
azteca1998 Apr 30, 2026
e5a0586
style: fix formatting in transaction.rs
azteca1998 Apr 30, 2026
58b4c83
fix(storage): use temp file instead of Vec for migration key dump
azteca1998 May 4, 2026
9cc728b
style: fix formatting in migrations.rs
azteca1998 May 6, 2026
cd35dd4
refactor(storage): use cursor-held migration instead of temp file
azteca1998 May 11, 2026
33d253d
perf(storage): two-CF receipts migration (receipts → receipts_v2)
azteca1998 May 11, 2026
d195064
Merge remote-tracking branch 'origin/main' into perf/two-cf-receipts-…
azteca1998 May 13, 2026
733a7c9
fix: update quote-gen Cargo.lock after merge
azteca1998 May 13, 2026
f11d0a6
fix: update stale Cargo.lock files after merge
azteca1998 May 13, 2026
6287873
style: fix formatting in seed_migration_test.rs
azteca1998 May 13, 2026
04eae69
style: fix clippy and rustfmt issues in seed_migration_test.rs
azteca1998 May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Perf

### 2026-04-29

- Use fixed-width RECEIPTS keys and cursor iteration with early stop for eth_getTransactionReceipt; fix prefix iterator scan in get_transaction_location [#6548](https://github.com/lambdaclass/ethrex/pull/6548)

### 2026-04-27

- Reduce peak disk usage during snap sync by moving SST files into the temp DB instead of copying [#6532](https://github.com/lambdaclass/ethrex/pull/6532)
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/l2/tee/quote-gen/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/networking/p2p/rlpx/connection/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,7 @@ async fn handle_incoming_message(
let start_index = if i == 0 { first_block_receipt_index } else { 0 };
let block_receipts = state
.storage
.get_receipts_for_block_from_index(hash, start_index)
.get_receipts_for_block_from_index(hash, start_index, None)
.await?;

let mut block_receipt_list = Vec::new();
Expand Down
61 changes: 37 additions & 24 deletions crates/networking/rpc/eth/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::{
utils::RpcErr,
};
use ethrex_common::types::{
Block, BlockBody, BlockHash, BlockHeader, BlockNumber, Receipt, calculate_base_fee_per_blob_gas,
Block, BlockBody, BlockHash, BlockHeader, Receipt, calculate_base_fee_per_blob_gas,
};
use ethrex_storage::Store;

Expand Down Expand Up @@ -177,7 +177,7 @@ impl RpcHandler for GetBlockReceiptsRequest {
// Block not found
_ => return Ok(Value::Null),
};
let receipts = get_all_block_rpc_receipts(block_number, header, body, storage).await?;
let receipts = get_all_block_rpc_receipts(header, body, storage, None).await?;

serde_json::to_value(&receipts).map_err(|error| RpcErr::Internal(error.to_string()))
}
Expand Down Expand Up @@ -270,11 +270,11 @@ impl RpcHandler for GetRawReceipts {
};
let header = storage.get_block_header(block_number)?;
let body = storage.get_block_body(block_number).await?;
let (header, body) = match (header, body) {
let (header, _body) = match (header, body) {
(Some(header), Some(body)) => (header, body),
_ => return Ok(Value::Null),
};
let receipts: Vec<String> = get_all_block_receipts(block_number, header, body, storage)
let receipts: Vec<String> = get_all_block_receipts(header, storage)
.await?
.iter()
.map(|receipt| {
Expand Down Expand Up @@ -329,11 +329,18 @@ impl RpcHandler for GetBlobBaseFee {
}
}

/// Fetches RPC receipts for a block, optionally stopping after `target_index`.
///
/// When `target_index` is `Some(n)`, only receipts 0..=n are fetched using a
/// cursor pass — this is the fast path for `eth_getTransactionReceipt` which
/// only needs one receipt but requires preceding cumulative gas values.
///
/// When `target_index` is `None`, all receipts are fetched (for `eth_getBlockReceipts`).
pub async fn get_all_block_rpc_receipts(
block_number: BlockNumber,
header: BlockHeader,
body: BlockBody,
storage: &Store,
target_index: Option<u64>,
) -> Result<Vec<RpcReceipt>, RpcErr> {
let mut receipts = Vec::new();
// Check if this is the genesis block
Expand All @@ -353,16 +360,32 @@ pub async fn get_all_block_rpc_receipts(
.try_into()
.map_err(|_| RpcErr::Internal("blob_base_fee does not fit in u64".to_owned()))?;
// Fetch receipt info from block
let block_hash = header.hash();
let block_info = RpcReceiptBlockInfo::from_block_header(header);
// Fetch receipt for each tx in the block and add block and tx info
// Fetch receipts: only up to target_index+1 when set, otherwise all
let fetch_count = target_index
.map(|ti| (ti + 1) as usize)
.unwrap_or(body.transactions.len());
let all_receipts = storage
.get_receipts_for_block_from_index(&block_hash, 0, Some(fetch_count))
.await?;
if all_receipts.len() != fetch_count {
return Err(RpcErr::Internal(format!(
"Expected {} receipts, got {}",
fetch_count,
all_receipts.len()
)));
}
let mut last_cumulative_gas_used = 0;
let mut current_log_index = 0;
for (index, tx) in body.transactions.iter().enumerate() {
for (index, (tx, receipt)) in body
.transactions
.iter()
.zip(all_receipts.iter())
.enumerate()
.take(fetch_count)
{
let index = index as u64;
let receipt = match storage.get_receipt(block_number, index).await? {
Some(receipt) => receipt,
_ => return Err(RpcErr::Internal("Could not get receipt".to_owned())),
};
let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;
let tx_info = RpcReceiptTxInfo::from_transaction(
tx.clone(),
Expand All @@ -385,23 +408,13 @@ pub async fn get_all_block_rpc_receipts(
}

pub async fn get_all_block_receipts(
block_number: BlockNumber,
header: BlockHeader,
body: BlockBody,
storage: &Store,
) -> Result<Vec<Receipt>, RpcErr> {
let mut receipts = Vec::new();
// Check if this is the genesis block
if header.parent_hash.is_zero() {
return Ok(receipts);
return Ok(Vec::new());
}
for (index, _) in body.transactions.iter().enumerate() {
let index = index as u64;
let receipt = match storage.get_receipt(block_number, index).await? {
Some(receipt) => receipt,
_ => return Err(RpcErr::Internal("Could not get receipt".to_owned())),
};
receipts.push(receipt);
}
Ok(receipts)
let block_hash = header.hash();
Ok(storage.get_receipts_for_block(&block_hash).await?)
}
7 changes: 3 additions & 4 deletions crates/networking/rpc/eth/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ impl RpcHandler for GetTransactionReceiptRequest {
"Requested receipt for transaction {:#x}",
self.transaction_hash,
);
let (block_number, block_hash, index) = match storage
let (_block_number, block_hash, index) = match storage
.get_transaction_location(self.transaction_hash)
.await?
{
Expand All @@ -304,11 +304,10 @@ impl RpcHandler for GetTransactionReceiptRequest {
None => return Ok(Value::Null),
};
let receipts =
block::get_all_block_rpc_receipts(block_number, block.header, block.body, storage)
block::get_all_block_rpc_receipts(block.header, block.body, storage, Some(index))
.await?;

serde_json::to_value(receipts.get(index as usize))
.map_err(|error| RpcErr::Internal(error.to_string()))
serde_json::to_value(receipts.last()).map_err(|error| RpcErr::Internal(error.to_string()))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 receipts.last() is correct only as long as fetch_count == index + 1 and the count check above enforces that exact length. Using receipts.get(index as usize) is an explicit, index-stable access that doesn't silently return the wrong receipt if the count logic is ever adjusted.

Suggested change
serde_json::to_value(receipts.last()).map_err(|error| RpcErr::Internal(error.to_string()))
serde_json::to_value(receipts.get(index as usize)).map_err(|error| RpcErr::Internal(error.to_string()))
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/eth/transaction.rs
Line: 310

Comment:
`receipts.last()` is correct only as long as `fetch_count == index + 1` and the count check above enforces that exact length. Using `receipts.get(index as usize)` is an explicit, index-stable access that doesn't silently return the wrong receipt if the count logic is ever adjusted.

```suggestion
        serde_json::to_value(receipts.get(index as usize)).map_err(|error| RpcErr::Internal(error.to_string()))
```

How can I resolve this? If you propose a fix, please make it concise.

}
}

Expand Down
13 changes: 13 additions & 0 deletions crates/storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ tokio = { workspace = true, features = ["rt", "sync"] }
fastbloom = "0.14"
rayon.workspace = true
lru.workspace = true
libc = "0.2"
tracing-subscriber = { workspace = true }

[features]
default = []
Expand All @@ -35,9 +37,20 @@ rocksdb = ["dep:rocksdb"]
tempfile.workspace = true
tokio = { workspace = true, features = ["full"] }


[lib]
path = "./lib.rs"

[[bin]]
name = "seed_migration_test"
path = "seed_migration_test.rs"
required-features = ["rocksdb"]

[[bin]]
name = "bench_migration"
path = "bench_migration.rs"
required-features = ["rocksdb"]

[lints.clippy]
unwrap_used = "deny"
redundant_clone = "warn"
14 changes: 10 additions & 4 deletions crates/storage/api/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ pub const ACCOUNT_CODES: &str = "account_codes";
/// - [`u8; 8`] = `code_length.to_be_bytes()`
pub const ACCOUNT_CODE_METADATA: &str = "account_code_metadata";

/// Receipts column family: [`Vec<u8>`] => [`Vec<u8>`]
/// - [`Vec<u8>`] = `(block_hash, index).encode_to_vec()`
/// - [`Vec<u8>`] = `receipt.encode_to_vec()`
/// Receipts column family (legacy, pre-v2): [`Vec<u8>`] => [`Vec<u8>`]
/// Kept only for migration reads; dropped automatically on next startup.
pub const RECEIPTS: &str = "receipts";

/// Receipts v2 column family: [`Vec<u8>`] => [`Vec<u8>`]
/// - Key: `block_hash (32B) || index (8B big-endian u64)` — fixed-width raw key
/// enabling cursor-based prefix iteration by block hash.
/// - Value: `receipt.encode_to_vec()`
pub const RECEIPTS_V2: &str = "receipts_v2";

/// Transaction locations column family: [`Vec<u8>`] => [`Vec<u8>`]
/// - [`Vec<u8>`] = Composite key
/// ```rust,no_run
Expand Down Expand Up @@ -102,7 +107,7 @@ pub const MISC_VALUES: &str = "misc_values";
/// - [`Vec<u8>`] = `serde_json::to_vec(&witness)`
pub const EXECUTION_WITNESSES: &str = "execution_witnesses";

pub const TABLES: [&str; 19] = [
pub const TABLES: [&str; 20] = [
CHAIN_DATA,
ACCOUNT_CODES,
ACCOUNT_CODE_METADATA,
Expand All @@ -113,6 +118,7 @@ pub const TABLES: [&str; 19] = [
PENDING_BLOCKS,
TRANSACTION_LOCATIONS,
RECEIPTS,
RECEIPTS_V2,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old RECEIPTS CF won't auto-drop. The migration doc (migrations.rs:104-106) and the PR description both say:

"the old receipts CF is not deleted here — it will be dropped automatically by the auto-cleanup in RocksDBBackend::open() on the next startup (since RECEIPTS is no longer listed in TABLES)."

But RECEIPTS IS still in TABLES (line 120 above this addition). The auto-cleanup at backend/rocksdb.rs only drops CFs whose name is NOT in TABLES:

for cf_name in &existing_cfs {
    if cf_name != "default" && !TABLES.contains(&cf_name.as_str()) {
        warn!("Dropping obsolete column family: {}", cf_name);
        let _ = db.drop_cf(cf_name) ...
    }
}

So with both RECEIPTS and RECEIPTS_V2 in TABLES, the old CF survives forever. After migration both CFs hold the same data — on srv1's 16 GB receipts CF, that's 16 GB of duplicate state that never goes away.

The test at migrations.rs:250-254 confirms the intent ("Old keys should still be in RECEIPTS (dropped at startup)") but doesn't actually verify the drop happens.

Fix: remove RECEIPTS, from the TABLES array (line 120). The migration code still references it via tables::RECEIPTS for read-only reading, which is fine — prefix_iterator against a CF name that's been opened (via the auto-create path) works regardless of whether the constant is in TABLES. The auto-cleanup loop will then see "receipts" is unlisted and call drop_cf on next startup.

Worth a quick test of this scenario before merge — e.g., a unit test that runs migrate + reopens the backend + asserts receipts CF is gone.

SNAP_STATE,
INVALID_CHAINS,
ACCOUNT_TRIE_NODES,
Expand Down
6 changes: 3 additions & 3 deletions crates/storage/backend/rocksdb.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::api::tables::{
ACCOUNT_CODES, ACCOUNT_FLATKEYVALUE, ACCOUNT_TRIE_NODES, BLOCK_NUMBERS, BODIES,
CANONICAL_BLOCK_HASHES, FULLSYNC_HEADERS, HEADERS, RECEIPTS, STORAGE_FLATKEYVALUE,
CANONICAL_BLOCK_HASHES, FULLSYNC_HEADERS, HEADERS, RECEIPTS_V2, STORAGE_FLATKEYVALUE,
STORAGE_TRIE_NODES, TRANSACTION_LOCATIONS,
};
use crate::api::{
Expand Down Expand Up @@ -68,7 +68,7 @@ impl RocksDBBackend {
BLOCK_NUMBERS,
HEADERS,
BODIES,
RECEIPTS,
RECEIPTS_V2,
TRANSACTION_LOCATIONS,
FULLSYNC_HEADERS,
];
Expand Down Expand Up @@ -165,7 +165,7 @@ impl RocksDBBackend {
block_opts.set_block_cache(&block_cache);
cf_opts.set_block_based_table_factory(&block_opts);
}
RECEIPTS => {
RECEIPTS_V2 => {
cf_opts.set_write_buffer_size(128 * 1024 * 1024); // 128MB
cf_opts.set_max_write_buffer_number(3);
cf_opts.set_target_file_size_base(256 * 1024 * 1024); // 256MB
Expand Down
100 changes: 100 additions & 0 deletions crates/storage/bench_migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/// Standalone binary to benchmark the v1→v2 RECEIPTS migration.
///
/// Usage: bench_migration <db_path>
///
/// Opens the RocksDB database, runs the two-CF migration (receipts → receipts_v2),
/// and reports wall-clock time and peak RSS.
///
/// Prerequisites:
/// 1. Run `seed_migration_test <db_path>` to seed 150M old-format entries
/// 2. Ensure metadata.json has {"schema_version": 1} (or just don't create one)
/// 3. Run this binary
use std::time::Instant;

fn get_rss_mb() -> Option<f64> {
#[cfg(target_os = "macos")]
{
use std::mem;
let mut info: libc::rusage = unsafe { mem::zeroed() };
let ret = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut info) };
if ret == 0 {
// macOS reports maxrss in bytes
Some(info.ru_maxrss as f64 / (1024.0 * 1024.0))
} else {
None
}
}
#[cfg(target_os = "linux")]
{
use std::mem;
let mut info: libc::rusage = unsafe { mem::zeroed() };
let ret = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut info) };
if ret == 0 {
// Linux reports maxrss in kilobytes
Some(info.ru_maxrss as f64 / 1024.0)
} else {
None
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
None
}
}

fn main() {
// Initialize tracing so migration progress logs are visible
tracing_subscriber::fmt()
.with_target(false)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();

let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
eprintln!("Usage: {} <db_path>", args[0]);
std::process::exit(1);
}
let db_path = &args[1];

println!("Opening database at: {db_path}");
let rss_before = get_rss_mb();

let backend = ethrex_storage::backend::rocksdb::RocksDBBackend::open(db_path)
.expect("Failed to open RocksDB");

let rss_after_open = get_rss_mb();
println!(
"Database opened. RSS after open: {:.1} MB",
rss_after_open.unwrap_or(0.0)
);

// Run the migration
println!("Starting migration v1→v2 (two-CF: receipts → receipts_v2)...");
let start = Instant::now();

ethrex_storage::migrations::run_pending_migrations(
&backend,
std::path::Path::new(db_path),
1, // pretend we're at v1
)
.expect("Migration failed");

let elapsed = start.elapsed();
let rss_after = get_rss_mb();

println!("\n=== Migration Benchmark Results ===");
println!("Wall-clock time: {:.1}s", elapsed.as_secs_f64());
if let Some(before) = rss_before {
println!("RSS before open: {:.1} MB", before);
}
if let Some(after_open) = rss_after_open {
println!("RSS after open: {:.1} MB", after_open);
}
if let Some(after) = rss_after {
println!("Peak RSS (maxrss): {:.1} MB", after);
}
println!("===================================");
}
2 changes: 1 addition & 1 deletion crates/storage/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub use store::{
/// When bumping this version, add a corresponding migration function to
/// `migrations::MIGRATIONS`. The migration framework will automatically
/// upgrade existing databases instead of requiring a full resync.
pub const STORE_SCHEMA_VERSION: u64 = 1;
pub const STORE_SCHEMA_VERSION: u64 = 2;

/// Name of the file storing the metadata about the database.
///
Expand Down
Loading
Loading