Skip to main content

reth_cli_commands/db/
migrate_v2.rs

1//! `reth db migrate-v2` command for migrating v1 storage layout to v2.
2
3use crate::common::CliNodeTypes;
4use alloy_primitives::Address;
5use clap::Parser;
6use reth_db::{
7    mdbx::{self, ffi},
8    models::StorageBeforeTx,
9    DatabaseEnv,
10};
11use reth_db_api::{
12    cursor::DbCursorRO,
13    database::Database,
14    table::Table,
15    tables,
16    transaction::{DbTx, DbTxMut},
17};
18use reth_node_builder::NodeTypesWithDBAdapter;
19use reth_provider::{
20    providers::ProviderNodeTypes, BlockNumReader, DBProvider, DatabaseProviderFactory,
21    MetadataProvider, MetadataWriter, ProviderFactory, PruneCheckpointReader,
22    RocksDBProviderFactory, StageCheckpointWriter, StaticFileProviderFactory, StaticFileWriter,
23    StorageSettings,
24};
25use reth_prune_types::PruneSegment;
26use reth_stages_types::{StageCheckpoint, StageId};
27use reth_static_file_types::StaticFileSegment;
28use reth_storage_api::StageCheckpointReader;
29use tracing::{info, warn};
30
31/// `reth db migrate-v2` command
32#[derive(Debug, Parser)]
33pub struct Command;
34
35impl Command {
36    /// Execute the full v1 → v2 migration:
37    ///
38    /// 1. Migrate changesets + receipts to static files
39    /// 2. Flip `StorageSettings` to v2
40    /// 3. Clear recomputable MDBX tables + reset stage checkpoints
41    /// 4. Compact MDBX
42    pub async fn execute<N: CliNodeTypes>(
43        self,
44        provider_factory: ProviderFactory<NodeTypesWithDBAdapter<N, DatabaseEnv>>,
45    ) -> eyre::Result<()>
46    where
47        N::Primitives: reth_primitives_traits::NodePrimitives<
48            Receipt: reth_db_api::table::Value + reth_codecs::Compact,
49        >,
50    {
51        // === Phase 0: Preflight ===
52        info!(target: "reth::cli", "Starting v1 → v2 storage migration");
53
54        let provider = provider_factory.provider()?;
55        let current_settings = provider.storage_settings()?;
56
57        if current_settings.is_some_and(|s| s.is_v2()) {
58            info!(target: "reth::cli", "Storage is already v2, nothing to do");
59            return Ok(());
60        }
61
62        let tip =
63            provider.get_stage_checkpoint(StageId::Execution)?.map(|c| c.block_number).unwrap_or(0);
64
65        info!(target: "reth::cli", tip, "Chain tip block number");
66
67        let sf_provider = provider_factory.static_file_provider();
68
69        for segment in [StaticFileSegment::AccountChangeSets, StaticFileSegment::StorageChangeSets]
70        {
71            if sf_provider.get_highest_static_file_block(segment).is_some() {
72                eyre::bail!(
73                    "Static file segment {segment:?} already contains data. \
74                     Cannot migrate — target must be empty."
75                );
76            }
77        }
78
79        drop(provider);
80
81        // === Phase 1: Migrate changesets → static files ===
82        Self::migrate_account_changesets(&provider_factory, tip)?;
83        Self::migrate_storage_changesets(&provider_factory, tip)?;
84
85        // === Phase 2: Migrate receipts → static files ===
86        Self::migrate_receipts::<NodeTypesWithDBAdapter<N, DatabaseEnv>>(&provider_factory, tip)?;
87
88        // === Phase 3: Migrate indices → RocksDB ===
89        Self::migrate_to_rocksdb::<_, tables::TransactionHashNumbers>(&provider_factory)?;
90        Self::migrate_to_rocksdb::<_, tables::AccountsHistory>(&provider_factory)?;
91        Self::migrate_to_rocksdb::<_, tables::StoragesHistory>(&provider_factory)?;
92
93        // === Phase 4: Flip metadata to v2 ===
94        info!(target: "reth::cli", "Writing StorageSettings v2 metadata");
95        {
96            let provider_rw = provider_factory.database_provider_rw()?;
97            provider_rw.write_storage_settings(StorageSettings::v2())?;
98            provider_rw.commit()?;
99        }
100        info!(target: "reth::cli", "Storage settings updated to v2");
101
102        // === Phase 5: Clear migrated and recomputable MDBX tables ===
103        Self::clear_migrated_and_recomputable_tables(&provider_factory)?;
104
105        // === Phase 6: Compact MDBX (before pipeline, so it runs on a smaller DB) ===
106        let db_path = provider_factory.db_ref().path();
107        Self::compact_mdbx(provider_factory.db_ref())?;
108
109        // Drop to release DB handle for swap
110        drop(provider_factory);
111
112        let compact_path = db_path.with_file_name("db_compact");
113        Self::swap_compacted_db(&db_path, &compact_path)?;
114
115        // === Phase 7: Reopen DB and run pipeline ===
116        // The caller will reopen the environment and run the pipeline.
117        // We return here — the pipeline step is handled in mod.rs after
118        // reopening the database with the compacted copy.
119        info!(target: "reth::cli", "Migration complete. You should now restart the node and let it run the pipeline to rebuild the remaining data.");
120        Ok(())
121    }
122
123    fn migrate_account_changesets<N: ProviderNodeTypes>(
124        factory: &ProviderFactory<N>,
125        tip: u64,
126    ) -> eyre::Result<()> {
127        info!(target: "reth::cli", "Migrating AccountChangeSets → static files");
128        let provider = factory.provider()?.disable_long_read_transaction_safety();
129        let sf_provider = factory.static_file_provider();
130
131        let mut cursor = provider.tx_ref().cursor_read::<tables::AccountChangeSets>()?;
132
133        let first_block = provider
134            .get_prune_checkpoint(PruneSegment::AccountHistory)?
135            .and_then(|cp| cp.block_number)
136            .map_or(0, |b| b + 1);
137
138        // The writer always starts at the fixed range boundary (e.g. 2500000) which may be
139        // earlier than first_block (e.g. 2603897 from prune checkpoint).
140        let mut writer = sf_provider.latest_writer(StaticFileSegment::AccountChangeSets)?;
141        if first_block > 0 {
142            writer.ensure_at_block(first_block - 1)?;
143        }
144
145        let mut count = 0u64;
146        let mut walker = cursor.walk(Some(first_block))?.peekable();
147
148        for block in first_block..=tip {
149            let mut entries = Vec::new();
150
151            while let Some(Ok((block_number, _))) = walker.peek() {
152                if *block_number != block {
153                    break;
154                }
155                let (_, entry) = walker.next().expect("peeked")?;
156                entries.push(entry);
157            }
158
159            if matches!(walker.peek(), Some(Err(_))) {
160                walker.next().expect("peeked")?;
161            }
162
163            count += entries.len() as u64;
164            writer.append_account_changeset(entries, block)?;
165        }
166
167        writer.commit()?;
168
169        info!(target: "reth::cli", count, "AccountChangeSets migrated");
170        Ok(())
171    }
172
173    fn migrate_storage_changesets<N: ProviderNodeTypes>(
174        factory: &ProviderFactory<N>,
175        tip: u64,
176    ) -> eyre::Result<()> {
177        info!(target: "reth::cli", "Migrating StorageChangeSets → static files");
178        let provider = factory.provider()?.disable_long_read_transaction_safety();
179        let sf_provider = factory.static_file_provider();
180
181        let mut cursor = provider.tx_ref().cursor_read::<tables::StorageChangeSets>()?;
182
183        let first_block = provider
184            .get_prune_checkpoint(PruneSegment::StorageHistory)?
185            .and_then(|cp| cp.block_number)
186            .map_or(0, |b| b + 1);
187
188        // The writer always starts at the fixed range boundary (e.g. 2500000) which may be
189        // earlier than first_block (e.g. 2603897 from prune checkpoint).
190        let mut writer = sf_provider.latest_writer(StaticFileSegment::StorageChangeSets)?;
191        if first_block > 0 {
192            writer.ensure_at_block(first_block - 1)?;
193        }
194
195        let mut count = 0u64;
196        let mut walker = cursor.walk(Some((first_block, Address::ZERO).into()))?.peekable();
197
198        for block in first_block..=tip {
199            let mut entries = Vec::new();
200
201            while let Some(Ok((key, _))) = walker.peek() {
202                if key.block_number() != block {
203                    break;
204                }
205                let (key, entry) = walker.next().expect("peeked")?;
206                entries.push(StorageBeforeTx {
207                    address: key.address(),
208                    key: entry.key,
209                    value: entry.value,
210                });
211            }
212
213            if matches!(walker.peek(), Some(Err(_))) {
214                walker.next().expect("peeked")?;
215            }
216
217            count += entries.len() as u64;
218            writer.append_storage_changeset(entries, block)?;
219        }
220
221        writer.commit()?;
222
223        info!(target: "reth::cli", count, "StorageChangeSets migrated");
224        Ok(())
225    }
226
227    fn migrate_receipts<N: ProviderNodeTypes>(
228        factory: &ProviderFactory<N>,
229        tip: u64,
230    ) -> eyre::Result<()>
231    where
232        N::Primitives: reth_primitives_traits::NodePrimitives<
233            Receipt: reth_db_api::table::Value + reth_codecs::Compact,
234        >,
235    {
236        let provider = factory.provider()?;
237        if !provider.prune_modes_ref().receipts_log_filter.is_empty() {
238            info!(target: "reth::cli", "Receipt log filter pruning is enabled, keeping receipts in MDBX");
239            return Ok(());
240        }
241        drop(provider);
242
243        let sf_provider = factory.static_file_provider();
244        let existing = sf_provider.get_highest_static_file_block(StaticFileSegment::Receipts);
245
246        if existing.is_some_and(|b| b >= tip) {
247            info!(target: "reth::cli", "Receipts already in static files, skipping");
248            return Ok(());
249        }
250
251        info!(target: "reth::cli", "Migrating Receipts → static files");
252
253        let provider = factory.provider()?.disable_long_read_transaction_safety();
254        let prune_start = provider
255            .get_prune_checkpoint(PruneSegment::Receipts)?
256            .and_then(|cp| cp.block_number)
257            .map_or(0, |b| b + 1);
258        let first_block = prune_start.max(existing.map_or(0, |b| b + 1));
259
260        // The writer always starts at the fixed range boundary (e.g. 2500000) which may be
261        // earlier than first_block (e.g. 2603897 from prune checkpoint).
262        if first_block > 0 {
263            let mut writer = sf_provider.latest_writer(StaticFileSegment::Receipts)?;
264            writer.ensure_at_block(first_block - 1)?;
265            writer.commit()?;
266        }
267
268        let before = sf_provider
269            .get_highest_static_file_tx(StaticFileSegment::Receipts)
270            .map_or(0, |tx| tx + 1);
271
272        let block_range = first_block..=tip;
273
274        let segment = reth_static_file::segments::Receipts;
275        reth_static_file::segments::Segment::copy_to_static_files(&segment, provider, block_range)?;
276
277        sf_provider.commit()?;
278
279        let after = sf_provider
280            .get_highest_static_file_tx(StaticFileSegment::Receipts)
281            .map_or(0, |tx| tx + 1);
282        let count = after - before;
283        info!(target: "reth::cli", count, "Receipts migrated");
284        Ok(())
285    }
286
287    fn migrate_to_rocksdb<N: ProviderNodeTypes, T: Table>(
288        factory: &ProviderFactory<N>,
289    ) -> eyre::Result<()> {
290        info!(target: "reth::cli", table = T::NAME, "Migrating MDBX table → RocksDB");
291
292        let provider = factory.provider()?.disable_long_read_transaction_safety();
293        let mut cursor = provider.tx_ref().cursor_read::<T>()?;
294
295        let rocksdb = factory.rocksdb_provider();
296        rocksdb.clear::<T>()?;
297        let mut batch = rocksdb.batch_with_auto_commit();
298
299        let mut count = 0u64;
300        for entry in cursor.walk(None)? {
301            let (key, value) = entry?;
302            batch.put::<T>(key, &value)?;
303            count += 1;
304        }
305
306        batch.commit()?;
307        rocksdb.flush(&[T::NAME])?;
308
309        info!(target: "reth::cli", table = T::NAME, count, "MDBX table migrated to RocksDB");
310        Ok(())
311    }
312
313    /// Clears MDBX tables that were migrated to v2 backends or can be recomputed by the pipeline,
314    /// and resets only the recomputed stage checkpoints.
315    fn clear_migrated_and_recomputable_tables<N: ProviderNodeTypes>(
316        factory: &ProviderFactory<N>,
317    ) -> eyre::Result<()> {
318        info!(target: "reth::cli", "Clearing migrated and recomputable MDBX tables");
319        let db = factory.db_ref();
320
321        macro_rules! clear_table {
322            ($table:ty) => {{
323                let tx = db.tx_mut()?;
324                tx.clear::<$table>()?;
325                tx.commit()?;
326                info!(target: "reth::cli", table = <$table as Table>::NAME, "Cleared");
327            }};
328        }
329
330        // Migrated changeset tables (now in static files)
331        clear_table!(tables::AccountChangeSets);
332        clear_table!(tables::StorageChangeSets);
333
334        // Senders — rebuilt by SenderRecovery
335        clear_table!(tables::TransactionSenders);
336
337        // Indices — migrated to RocksDB
338        clear_table!(tables::TransactionHashNumbers);
339        clear_table!(tables::AccountsHistory);
340        clear_table!(tables::StoragesHistory);
341
342        // Plain state — superseded by hashed state in v2
343        clear_table!(tables::PlainAccountState);
344        clear_table!(tables::PlainStorageState);
345
346        // Trie — rebuilt by MerkleExecute
347        clear_table!(tables::AccountsTrie);
348        clear_table!(tables::StoragesTrie);
349
350        // Reset stage checkpoints so the pipeline rebuilds everything
351        info!(target: "reth::cli", "Resetting stage checkpoints");
352        let provider_rw = factory.database_provider_rw()?;
353        for stage in [StageId::SenderRecovery, StageId::MerkleExecute, StageId::MerkleUnwind] {
354            provider_rw.save_stage_checkpoint(stage, StageCheckpoint::new(0))?;
355            info!(target: "reth::cli", %stage, "Checkpoint reset to 0");
356        }
357        provider_rw.save_stage_checkpoint_progress(StageId::MerkleExecute, vec![])?;
358
359        if provider_rw.last_block_number()? > 0 {
360            let first_indices_entry = provider_rw
361                .tx_ref()
362                .cursor_read::<tables::BlockBodyIndices>()?
363                .seek(1)?
364                .map(|(block, _)| block)
365                .ok_or_else(|| eyre::eyre!("no block body indices found"))?;
366
367            // If the first block body indices entry is not block 1, it means that the v1 database
368            // was likely initialized with dummy blocks coming from a dummy chain generated by
369            // `setup_without_evm`.
370            //
371            // In that case, sender recovery starts from the first block that has a corresponding
372            // block body indices entry.
373            if first_indices_entry > 1 {
374                provider_rw.save_stage_checkpoint(
375                    StageId::SenderRecovery,
376                    StageCheckpoint::new(first_indices_entry - 1),
377                )?;
378
379                // Make sure that senders static files segment is at the correct height.
380                let static_file_provider = provider_rw.static_file_provider();
381                let mut senders_writer =
382                    static_file_provider.latest_writer(StaticFileSegment::TransactionSenders)?;
383                senders_writer.ensure_at_block(first_indices_entry - 1)?;
384                senders_writer.commit()?;
385
386                warn!(
387                    target: "reth::cli",
388                    "Missing block body indices data for first {first_indices_entry} blocks, initializing sender recovery with the first block that has a corresponding block body indices entry"
389                );
390            }
391        }
392        provider_rw.commit()?;
393
394        info!(target: "reth::cli", "Recomputable tables cleared");
395        Ok(())
396    }
397
398    /// Creates a compacted copy of the MDBX database.
399    fn compact_mdbx(db: &mdbx::DatabaseEnv) -> eyre::Result<()> {
400        let db_path = db.path();
401        let compact_path = db_path.with_file_name("db_compact");
402
403        reth_fs_util::create_dir_all(&compact_path)?;
404
405        info!(target: "reth::cli", ?db_path, ?compact_path, "Compacting MDBX database");
406
407        let compact_dest = compact_path.join("mdbx.dat");
408        let dest_cstr = std::ffi::CString::new(
409            compact_dest.to_str().ok_or_else(|| eyre::eyre!("compact path must be valid UTF-8"))?,
410        )?;
411
412        let flags = ffi::MDBX_CP_COMPACT | ffi::MDBX_CP_FORCE_DYNAMIC_SIZE;
413
414        let rc = db.with_raw_env_ptr(|env_ptr| unsafe {
415            ffi::mdbx_env_copy(env_ptr, dest_cstr.as_ptr(), flags)
416        });
417
418        if rc != 0 {
419            eyre::bail!("mdbx_env_copy failed with error code {rc}: {}", unsafe {
420                std::ffi::CStr::from_ptr(ffi::mdbx_strerror(rc)).to_string_lossy()
421            });
422        }
423
424        info!(target: "reth::cli", "MDBX compaction complete");
425        Ok(())
426    }
427
428    /// Swaps the original MDBX database with a compacted copy.
429    fn swap_compacted_db(
430        db_path: &std::path::Path,
431        compact_path: &std::path::Path,
432    ) -> eyre::Result<()> {
433        let backup_path = db_path.with_file_name("db_pre_compact");
434
435        info!(target: "reth::cli", ?db_path, ?compact_path, "Swapping compacted database");
436
437        reth_fs_util::rename(db_path, &backup_path)?;
438
439        if let Err(e) = reth_fs_util::rename(compact_path, db_path) {
440            if let Err(restore_err) = reth_fs_util::rename(&backup_path, db_path) {
441                eyre::bail!(
442                    "failed to move compacted database into place ({e}) and failed to restore \
443                     the original ({restore_err}); the original database is now at {} and the \
444                     compacted copy is at {}; move the original back to {} manually",
445                    backup_path.display(),
446                    compact_path.display(),
447                    db_path.display(),
448                );
449            }
450            return Err(e.into());
451        }
452
453        std::fs::remove_dir_all(&backup_path)?;
454
455        info!(target: "reth::cli", "Database compaction swap complete");
456        Ok(())
457    }
458}