Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion Cargo.lock

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

5 changes: 2 additions & 3 deletions crates/chainspec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,9 +995,8 @@ impl From<Genesis> for ChainSpec {
}
let gravity_hardforks = ChainHardforks::new(gravity_hardforks);

// Gravity protocol minimum base fee floor (wei). Presence marks the chainspec
// as Gravity; absence keeps upstream EIP-1559 semantics (e.g. Ethereum mainnet
// history sync).
// This is intentionally optional: Gravity chains enable the fee floor through genesis,
// while its absence preserves upstream EIP-1559 semantics for Reth tests and history sync.
let gravity_min_base_fee =
genesis.config.extra_fields.get("gravityMinBaseFee").and_then(|v| v.as_u64());
// main: floor activates at genesis (block 0). Released testnet branches override
Expand Down
67 changes: 60 additions & 7 deletions crates/cli/commands/src/db/migrate_changesets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ use reth_db_common::DbTool;
use reth_provider::{
providers::ProviderNodeTypes, writer::UnifiedStorageWriter, BlockNumReader,
DatabaseProviderFactory, MetadataProvider, MetadataWriter, ProviderFactory,
StaticFileProviderFactory, StaticFileWriter, StorageSettingsCache,
PruneCheckpointReader, StaticFileProviderFactory, StaticFileWriter, StorageSettingsCache,
};
use reth_prune_types::PruneSegment;
use reth_static_file_types::StaticFileSegment;
use tracing::{info, warn};

Expand Down Expand Up @@ -80,11 +81,22 @@ impl Command {
}

let tip = provider.last_block_number()?;
// Pruned nodes whose earliest changeset is above block 1 are not supported: the static
// file segments are append-only from genesis and gravity has no prune-aware start yet.
// Block 0 is expected, not evidence of pruning: genesis initialization writes the
// genesis-alloc reverts at block 0 on every chain (see `insert_genesis_state`), so stock
// datadirs start at 0 and databases seeded without genesis state start at 1 (#391).
// The account and storage histories are pruned independently. Check their authoritative
// checkpoints before inspecting table contents, because a late first storage change does
// not by itself indicate pruning.
for segment in [PruneSegment::AccountHistory, PruneSegment::StorageHistory] {
eyre::ensure!(
provider
.get_prune_checkpoint(segment)?
.and_then(|checkpoint| checkpoint.block_number)
.is_none(),
"{segment:?} history is pruned; migrating a pruned database is not yet supported"
);
}

// Keep the table-based account check for legacy databases without prune checkpoints.
// Block 0 is expected: genesis initialization writes the genesis-alloc reverts there, and
// databases seeded without genesis state can start at block 1 (#391).
eyre::ensure!(
provider
.tx_ref()
Expand Down Expand Up @@ -259,8 +271,9 @@ mod tests {
use reth_provider::{
test_utils::{create_test_provider_factory, create_test_provider_factory_with_chain_spec},
AccountReader, ChangeSetReader, DatabaseProviderFactory, HistoricalStateProviderRef,
StorageChangeSetReader, StorageSettingsCache,
PruneCheckpointWriter, StorageChangeSetReader, StorageSettingsCache,
};
use reth_prune_types::{PruneCheckpoint, PruneMode};
use std::{collections::BTreeMap, sync::Arc};

#[test]
Expand Down Expand Up @@ -492,6 +505,46 @@ mod tests {
assert!(err.to_string().contains("pruned"), "unexpected error: {err}");
}

#[test]
fn rejects_pruned_storage_history() {
let factory = create_test_provider_factory();
let address = Address::with_last_byte(1);
let storage_key = B256::with_last_byte(1);
{
let provider_rw = factory.database_provider_rw().unwrap();
provider_rw
.tx_ref()
.put::<tables::AccountChangeSets>(1, AccountBeforeTx { address, info: None })
.unwrap();
provider_rw
.tx_ref()
.put::<tables::StorageChangeSets>(
BlockNumberAddress((3, address)),
StorageEntry { key: storage_key, value: U256::from(1) },
)
.unwrap();
provider_rw
.save_prune_checkpoint(
PruneSegment::StorageHistory,
PruneCheckpoint {
block_number: Some(2),
tx_number: None,
prune_mode: PruneMode::before_inclusive(2),
},
)
.unwrap();
provider_rw.commit().unwrap();
}

let tool = DbTool::new(factory.clone()).unwrap();
let err = Command.execute(&tool).unwrap_err();
assert!(err.to_string().contains("StorageHistory"), "unexpected error: {err}");

let provider = factory.database_provider_ro().unwrap();
assert!(!provider.cached_storage_settings().changesets_in_static_files);
assert_eq!(provider.storage_changeset(3).unwrap().len(), 1);
}

/// A crash before the flag flip that left partially-written segments: rerun resets them and
/// migrates cleanly.
#[test]
Expand Down
182 changes: 166 additions & 16 deletions crates/engine/tree/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use reth_provider::{
providers::ProviderNodeTypes, writer::UnifiedStorageWriter, BlockHashReader, BlockWriter,
ChainStateBlockWriter, DatabaseProviderFactory, HistoryWriter, ProviderFactory,
StageCheckpointWriter, StateWriter, StaticFileProviderFactory, StaticFileWriter,
StorageLocation, TrieWriter, TrieWriterV2, PERSIST_BLOCK_CACHE,
StorageLocation, StorageSettingsCache, TrieWriter, TrieWriterV2, PERSIST_BLOCK_CACHE,
};
use reth_prune::{PrunerError, PrunerWithFactory};
use reth_stages_api::{MetricEvent, MetricEventsSender, StageCheckpoint, StageId};
Expand Down Expand Up @@ -405,13 +405,17 @@ where
Ok(())
}

/// Persist `blocks` as a sequence of merged groups, each committed once. Groups are bounded by
/// Persist `blocks` as a sequence of merged groups. Groups are bounded by
/// [`MERGE_GROUP_MAX_GAS`] and [`MERGE_GROUP_MAX_STATE`] so the in-flight write batch and the
/// crash-replay window stay bounded.
fn save_merged_blocks(
&self,
blocks: Vec<ExecutedBlockWithTrieUpdates<N::Primitives>>,
) -> Result<(), PersistenceError> {
if self.provider.cached_storage_settings().changesets_in_static_files {
return Err(PersistenceError::MergeBlocksWithStorageV2)
}

let mut group: Vec<ExecutedBlockWithTrieUpdates<N::Primitives>> = Vec::new();
let mut group_gas = 0u64;
let mut group_state = 0usize;
Expand All @@ -435,15 +439,11 @@ where
self.commit_block_group(group)
}

/// Write one contiguous group of executed blocks and commit it once.
/// Write one contiguous group of executed blocks with amortized commits.
///
/// The whole group is written into a single transaction and committed together, so the
/// per-commit fsync is paid once per group instead of once per block. The group is atomic: a
/// crash before the commit rolls it back and recovery re-executes it idempotently from the
/// stage checkpoints, with consensus re-supplying anything past the persisted tip. Batching the
/// per-block `write_*` calls is safe because state/hashed/trie writes are last-writer-wins puts
/// and receipts use the indices returned by `insert_blocks`, so none of them depends on
/// observing earlier uncommitted writes within the transaction.
/// State and changesets are flushed once before history indexing because `RocksDB` batches do
/// not provide read-your-writes. History and stage checkpoints are committed together
/// afterwards, so recovery can replay a group interrupted between the two commits.
fn commit_block_group(
&self,
group: Vec<ExecutedBlockWithTrieUpdates<N::Primitives>>,
Expand Down Expand Up @@ -480,9 +480,21 @@ where
let body_indices = provider_rw.insert_blocks(recovered_blocks, StorageLocation::Both)?;

// Receipts, state changesets and hashed state, per block.
for ((execution_output, hashed_state), body_index) in
execution_outputs.into_iter().zip(hashed_states).zip(body_indices)
for (block_index, ((execution_output, hashed_state), body_index)) in
execution_outputs.into_iter().zip(hashed_states).zip(body_indices).enumerate()
{
// A primary storage wipe builds its changeset by scanning plain storage. Flush prior
// blocks so that scan observes slots created earlier in this merged group.
if block_index != 0 &&
execution_output
.bundle
.reverts
.iter()
.flatten()
.any(|(_, revert)| revert.wipe_storage)
{
provider_rw.commit_view()?;
}
provider_rw.write_state_with_indices(
&execution_output,
OriginalValuesKnown::No,
Expand All @@ -500,10 +512,15 @@ where
provider_rw.write_trie_updatesv2(triev2.as_ref()).map_err(ProviderError::Database)?;
}

// History indexing reads the changesets back through RocksDB cursors, which cannot see
// pending WriteBatch entries. Storage V2 is rejected before this path because its
// changesets live in static files and `commit_view` cannot make them visible.
provider_rw.commit_view()?;

// History indices for the whole range, once.
provider_rw.update_history_indices(group_first..=group_last)?;

// Advance every written stage's checkpoint to the group tip, then commit the group once.
// Advance every written stage's checkpoint to the group tip, then make the final commit.
// `MerkleExecute` passes `None` (trie writes are idempotent and may resume mid-range); the
// rest assert checkpoint continuity from `group_first`.
let tx = provider_rw.tx_ref();
Expand All @@ -523,7 +540,7 @@ where

/// Read `stage_id`'s checkpoint (asserting continuity when `check_next` is set) and re-write it
/// at block `to`. Lets [`commit_block_group`](Self::commit_block_group) advance every stage to
/// the group tip within the single group commit.
/// the group tip within the final group commit.
fn advance_checkpoint<TX: DbTx + DbTxMut>(
tx: &TX,
stage_id: StageId,
Expand All @@ -538,6 +555,10 @@ where
/// One of the errors that can happen when using the persistence service.
#[derive(Debug, Error)]
pub enum PersistenceError {
/// Merged persistence cannot read uncommitted Storage V2 changesets.
#[error("--gravity.persist.merge-blocks is incompatible with Storage V2")]
MergeBlocksWithStorageV2,

/// A pruner error
#[error(transparent)]
PrunerError(#[from] PrunerError),
Expand Down Expand Up @@ -705,11 +726,17 @@ impl Drop for ServiceGuard {
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::B256;
use alloy_primitives::{Address, B256, U256};
use reth_chain_state::test_utils::TestBlockBuilder;
use reth_db::models::GravityStorageSettings;
use reth_execution_types::{BundleStateInit, ExecutionOutcome, RevertsInit};
use reth_exex_types::FinishedExExHeight;
use reth_provider::test_utils::create_test_provider_factory;
use reth_primitives_traits::Account;
use reth_provider::{test_utils::create_test_provider_factory, StorageChangeSetReader};
use reth_prune::Pruner;
use revm::database::states::{
reverts::Reverts, AccountRevert, AccountStatus, BundleAccount, BundleState,
};
use tokio::sync::mpsc::unbounded_channel;

fn default_persistence_handle() -> PersistenceHandle<EthPrimitives> {
Expand Down Expand Up @@ -739,6 +766,129 @@ mod tests {
assert!(result.last_block.is_none());
}

#[test]
fn rejects_merged_persistence_with_storage_v2() {
let provider = create_test_provider_factory();
provider.set_storage_settings_cache(GravityStorageSettings {
changesets_in_static_files: true,
});
let (_finished_exex_height_tx, finished_exex_height_rx) =
tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
let pruner =
Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
let service = PersistenceService::new(
provider,
std::sync::mpsc::channel().1,
pruner,
sync_metrics_tx,
);

assert!(matches!(
service.save_merged_blocks(Vec::new()),
Err(PersistenceError::MergeBlocksWithStorageV2)
));
}

#[test]
fn merged_persistence_builds_history_from_committed_changesets() {
let provider = create_test_provider_factory();
let (_finished_exex_height_tx, finished_exex_height_rx) =
tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
let pruner =
Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
let service = PersistenceService::new(
provider.clone(),
std::sync::mpsc::channel().1,
pruner,
sync_metrics_tx,
);

let block_number = 0;
let address = Address::random();
let state: BundleStateInit =
std::iter::once((address, (None, Some(Account::default()), Default::default())))
.collect();
let account_reverts = std::iter::once((address, (Some(None), vec![]))).collect();
let reverts: RevertsInit = std::iter::once((block_number, account_reverts)).collect();
let mut test_block_builder = TestBlockBuilder::eth();
let mut block =
test_block_builder.get_executed_block_with_number(block_number, B256::random());
block.block.execution_output = Arc::new(ExecutionOutcome::new_init(
state,
reverts,
[],
vec![vec![]],
block_number,
vec![Default::default()],
));

service.save_merged_blocks(vec![block]).unwrap();

let provider_ro = provider.database_provider_ro().unwrap();
assert_eq!(provider_ro.tx_ref().entries::<tables::AccountsHistory>().unwrap(), 1);
}

#[test]
fn merged_persistence_storage_wipe_sees_prior_block_state() {
let provider = create_test_provider_factory();
let (_finished_exex_height_tx, finished_exex_height_rx) =
tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
let pruner =
Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
let service = PersistenceService::new(
provider.clone(),
std::sync::mpsc::channel().1,
pruner,
sync_metrics_tx,
);

let address = Address::random();
let slot = B256::with_last_byte(1);
let value = U256::from(42);
let storage = std::iter::once((slot, (U256::ZERO, value))).collect();
let state: BundleStateInit =
std::iter::once((address, (None, Some(Account::default()), storage))).collect();
let account_reverts = std::iter::once((address, (Some(None), vec![]))).collect();
let reverts: RevertsInit = std::iter::once((0, account_reverts)).collect();
let mut test_block_builder = TestBlockBuilder::eth();
let mut blocks = test_block_builder.get_executed_blocks(0..2).collect::<Vec<_>>();
blocks[0].block.execution_output = Arc::new(ExecutionOutcome::new_init(
state,
reverts,
[],
vec![vec![]],
0,
vec![Default::default()],
));

let mut wipe_bundle = BundleState::default();
wipe_bundle.state.insert(
address,
BundleAccount::new(
Some(Default::default()),
None,
Default::default(),
AccountStatus::Destroyed,
),
);
wipe_bundle.reverts = Reverts::new(vec![vec![(
address,
AccountRevert { wipe_storage: true, ..Default::default() },
)]]);
blocks[1].block.execution_output =
Arc::new(ExecutionOutcome::new(wipe_bundle, vec![vec![]], 1, vec![Default::default()]));

service.save_merged_blocks(blocks).unwrap();

let changeset = provider.provider().unwrap().storage_changeset(1).unwrap();
assert_eq!(changeset.len(), 1);
assert_eq!(changeset[0].1.key, slot);
assert_eq!(changeset[0].1.value, value);
}

#[test]
fn test_save_blocks_single_block() {
reth_tracing::init_test_tracing();
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/tree/src/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ impl<'a, N: ProviderNodeTypes> StorageRecoveryHelper<'a, N> {
.map_err(ProviderError::Database)?
.unwrap_or_default();

// Merkle can be ahead when its parallel commit wins the race with a failed state commit.
// Gravity consensus never replaces an ordered block, so recovery will replay the same
// block with absolute state values. Trie upserts and deletes are idempotent; retaining the
// ahead trie lets that replay overwrite identical nodes and complete any missing trie DB.
if ck.block_number < block_number {
info!(target: "engine::recovery", checkpoint = ?ck.block_number, block_number = ?block_number, "Recovering merkle state");
let nested_state_root = NestedStateRoot::new(provider_rw.tx_ref(), None);
Expand Down Expand Up @@ -285,7 +289,6 @@ mod tests {
persist_merge_blocks: false,
cache_capacity: 2_000_000,
report_db_metrics: false,
trie_parallel_levels: 1,
});
});
}
Expand Down
Loading
Loading