From f5acf16a4f7c3afd777d04b11ac233c69d104b9b Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Thu, 16 Jul 2026 19:32:47 +0800 Subject: [PATCH 01/14] feat: add `--augmented-data` and `--tipset-lookup` to `forestcli snapshot export` --- scripts/tests/calibnet_export_check.sh | 11 +- src/chain/mod.rs | 76 +++++++++++++- src/cli/subcommands/snapshot_cmd.rs | 11 +- src/db/car/forest.rs | 50 +++++++++ src/ipld/util.rs | 8 ++ src/rpc/methods/chain.rs | 136 +++++++++++++++++++++++-- src/rpc/mod.rs | 1 + src/tool/subcommands/snapshot_cmd.rs | 103 ++++++++++++++++++- 8 files changed, 381 insertions(+), 15 deletions(-) diff --git a/scripts/tests/calibnet_export_check.sh b/scripts/tests/calibnet_export_check.sh index cb8cbe1c725f..147ef730f427 100755 --- a/scripts/tests/calibnet_export_check.sh +++ b/scripts/tests/calibnet_export_check.sh @@ -61,7 +61,7 @@ echo "Exporting zstd compressed snapshot at genesis" $FOREST_CLI_PATH snapshot export --tipset 0 --format "$format" echo "Exporting zstd compressed snapshot in $format format" -$FOREST_CLI_PATH snapshot export --format "$format" & +$FOREST_CLI_PATH snapshot export --format "$format" --tipset-lookup -o snapshot.forest.car.zst & EXPORT_CMD_PID=$! sleep 5 # another export job should be disallowed @@ -91,6 +91,15 @@ $FOREST_CLI_PATH snapshot export-status --wait $FOREST_CLI_PATH shutdown --force +# Check file sizes +ls -ahl *.forest.car.zst +# Validate tipset lookup snapshots +# export and check augmented data once we have receipts and events tipset published and imported +# for now there's no receipts and events on a freshly bootstrapped node. +forest-tool snapshot validate-extended --base snapshot.forest.car.zst --tipset-lookup snapshot_tipset_lookup.forest.car.zst +# Remove tipset lookup snapshots before proceeding +rm *_tipset_lookup.forest.car.zst + for f in *.car.zst; do echo "Inspecting archive info $f" $FOREST_TOOL_PATH archive info "$f" diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 63e2dddb8e7b..89f78d357dd9 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -12,11 +12,12 @@ pub use self::{snapshot_format::*, store::*, weight::*}; use crate::blocks::{Tipset, TipsetKey}; use crate::chain::index::ChainIndex; -use crate::cid_collections::CidHashSetLike; +use crate::cid_collections::{CidHashSet, CidHashSetLike}; use crate::db::IndexMapBlockstore; use crate::db::car::forest::{self, ForestCarFrame, finalize_frame}; -use crate::ipld::stream_chain; +use crate::ipld::{IpldStream, stream_chain}; use crate::prelude::*; +use crate::shim::executor::Receipt; use crate::utils::db::car_stream::{CarBlock, CarBlockWrite}; use crate::utils::io::{AsyncWriterWithChecksum, Checksum}; use crate::utils::multihash::MultihashCode; @@ -32,7 +33,7 @@ use std::time::Instant; use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter}; use tokio_util::task::AbortOnDropHandle; -const TIPSET_LOOKUP_HAMT_BIT_WIDTH: u32 = 5; +pub const TIPSET_LOOKUP_HAMT_BIT_WIDTH: u32 = 5; pub struct ExportOptions { pub skip_checksum: bool, @@ -260,3 +261,72 @@ async fn export_to_forest_car anyhow::Result<()> { + let start = Instant::now(); + tracing::info!( + "Exporting message receipts and events snapshot, epoch={}, depth={lookup_depth}", + tipset.epoch(), + ); + + let min_lookup_epoch_exclusive = tipset.epoch() - lookup_depth; + let mut ipld_roots = vec![]; + for ts in tipset + .shallow_clone() + .chain(db) + .take_while(|ts| ts.epoch() > min_lookup_epoch_exclusive) + { + for b in ts.block_headers() { + let message_receipts_root = b.message_receipts; + ipld_roots.push(message_receipts_root); + let receipts = Receipt::get_receipts(db, message_receipts_root).with_context(|| { + format!( + "failed to get receipts, root: {message_receipts_root}, epoch: {}, block: {}", + b.epoch, + b.cid(), + ) + })?; + ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root())); + } + } + + let stream = IpldStream::new(db.shallow_clone(), ipld_roots, CidHashSet::default()); + + // Wrap writer in optional checksum calculator + let mut writer = BufWriter::new(writer); + + // Stream stateroots in range (stateroot_lookup_limit+1)..=tipset.epoch(). Also + // stream all block headers until genesis. + let (blocks, _drop_guard) = par_buffer( + // Queue 1k blocks. This is enough to saturate the compressor and blocks + // are small enough that keeping 1k in memory isn't a problem. Average + // block size is between 1kb and 2kb. + 1024, stream, + ); + + // Encode Ipld key-value pairs in zstd frames + let block_frames = forest::Encoder::compress_stream_default(blocks); + + // There's no data root for this snapshot, use a default CID as placeholder + let roots = nunny::vec![Cid::default()]; + + // Write zstd frames and include a skippable index + forest::Encoder::write(&mut writer, roots, block_frames).await?; + + // Flush to ensure everything has been successfully written + tokio::time::timeout(forest::ASYNC_OPS_TIMEOUT, writer.flush()) + .await + .context("`writer.flush` timed out")??; + + tracing::info!( + "Exported message receipts and events snapshot, took {}", + humantime::format_duration(start.elapsed()) + ); + + Ok(()) +} diff --git a/src/cli/subcommands/snapshot_cmd.rs b/src/cli/subcommands/snapshot_cmd.rs index e135fbb809e3..b7cb9094ef2e 100644 --- a/src/cli/subcommands/snapshot_cmd.rs +++ b/src/cli/subcommands/snapshot_cmd.rs @@ -45,6 +45,12 @@ pub enum SnapshotCommands { /// Snapshot format to export. #[arg(long, value_enum, default_value_t = FilecoinSnapshotVersion::V2)] format: FilecoinSnapshotVersion, + /// Also exports an augmented data snapshot that contains message receipts and events + #[arg(long)] + augmented_data: bool, + /// Also exports a tipset lookup HAMT snapshot + #[arg(long)] + tipset_lookup: bool, }, /// Show status of the current export. ExportStatus { @@ -84,6 +90,8 @@ impl SnapshotCommands { tipset, depth, format, + augmented_data, + tipset_lookup, } => { anyhow::ensure!( depth >= 0, @@ -141,7 +149,8 @@ impl SnapshotCommands { include_receipts: false, include_events: false, include_tipset_keys: false, - include_tipset_lookup: false, + augmented_data, + tipset_lookup, skip_checksum, dry_run, }; diff --git a/src/db/car/forest.rs b/src/db/car/forest.rs index c8c784d11dd5..4fc4d560915e 100644 --- a/src/db/car/forest.rs +++ b/src/db/car/forest.rs @@ -530,6 +530,21 @@ pub fn forest_car_sha256sum_path(output_path: &Path) -> PathBuf { p } +pub fn forest_car_with_filename_suffix(path: &Path, suffix: &str) -> anyhow::Result { + anyhow::ensure!(!suffix.is_empty(), "suffix cannot be empty"); + let file_name = path.file_name().and_then(|n| n.to_str()).with_context(|| { + format!( + "failed to extract filename from the give path: {}", + path.display() + ) + })?; + let new_name = match file_name.split_once('.') { + Some((stem, rest)) => format!("{stem}{suffix}.{rest}"), + None => format!("{file_name}{suffix}"), + }; + Ok(path.with_file_name(new_name)) +} + #[cfg(test)] mod tests { use super::*; @@ -682,4 +697,39 @@ mod tests { fn test_forest_car_sha256sum_path(#[case] input: &Path, #[case] output: &Path) { assert_eq!(forest_car_sha256sum_path(input), output); } + + #[rstest] + #[case( + Path::new("/tmp/a.forst.car.zst"), + "_suffix", + Path::new("/tmp/a_suffix.forst.car.zst") + )] + #[case( + Path::new("a.forst.car.zst"), + "_suffix", + Path::new("a_suffix.forst.car.zst") + )] + #[case(Path::new("a"), "_suffix", Path::new("a_suffix"))] + fn test_forest_car_with_filename_suffix_valid( + #[case] input: &Path, + #[case] suffix: &str, + #[case] output: &Path, + ) { + assert_eq!( + forest_car_with_filename_suffix(input, suffix).unwrap(), + output + ); + } + + #[rstest] + #[case(Path::new("/tmp/a.forst.car.zst"), "", "suffix cannot be empty")] + #[case(Path::new("."), "_suffix", "failed to extract filename")] + fn test_forest_car_with_filename_suffix_invalid( + #[case] input: &Path, + #[case] suffix: &str, + #[case] reason: &str, + ) { + let e = forest_car_with_filename_suffix(input, suffix).unwrap_err(); + assert!(e.to_string().contains(reason)); + } } diff --git a/src/ipld/util.rs b/src/ipld/util.rs index 920ad9ab7a3a..25f43fdf4fb1 100644 --- a/src/ipld/util.rs +++ b/src/ipld/util.rs @@ -577,6 +577,14 @@ impl Stream for IpldStream { } } +pin_project! { + pub struct MessageReceiptsEventsStream { + db: DB, + cid_vec: Vec, + seen: S, + } +} + fn ipld_to_cid(ipld: Ipld) -> Option { if let Ipld::Link(cid) = ipld { Some(cid) diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index ffdde9d7e599..3922a850ef31 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -12,7 +12,8 @@ use crate::chain::{ChainStore, ExportOptions, ExportResult, FilecoinSnapshotVers use crate::chain_sync::{get_full_tipset, load_full_tipset}; use crate::cid_collections::{CidHashSet, FileBackedCidHashSet}; use crate::db::car::forest::{ - ASYNC_OPS_TIMEOUT, forest_car_sha256sum_path, tmp_exporting_forest_car_path, + ASYNC_OPS_TIMEOUT, forest_car_sha256sum_path, forest_car_with_filename_suffix, + tmp_exporting_forest_car_path, }; use crate::ipld::DfsIter; use crate::ipld::{CHAIN_EXPORT_STATUS, ChainExportGuard}; @@ -272,6 +273,54 @@ impl RpcMethod<1> for ChainPruneSnapshot { } } +/// For internal testing only. Generate this snapshot with `Forest.ChainExport` by setting `augmented_data: true` +pub enum ForestChainExportReceiptsEvents {} +impl RpcMethod<1> for ForestChainExportReceiptsEvents { + const NAME: &'static str = "Forest.ChainExportReceiptsEvents"; + const PARAM_NAMES: [&'static str; 1] = ["params"]; + const API_PATHS: BitFlags = ApiPaths::all_with_v2(); + const PERMISSION: Permission = Permission::Read; + const DESCRIPTION: &'static str = + "Exports chain message receipts and events snapshot to a CAR file from the given epoch"; + + type Params = (ForestChainExportReceiptsEventsParams,); + type Ok = ApiExportResult; + + async fn handle( + ctx: Ctx, + (params,): Self::Params, + _: &http::Extensions, + ) -> Result { + let ForestChainExportReceiptsEventsParams { + epoch, + recent_roots, + output_path, + tipset_keys: ApiTipsetKey(tsk), + } = params; + + let head = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?; + let start_ts = ctx + .chain_index() + .load_required_tipset_by_height(epoch, head, ResolveNullTipset::TakeOlder) + .await?; + + let tmp_path = + tempfile::TempPath::try_from_path(tmp_exporting_forest_car_path(&output_path))?; + let writer = tokio::fs::File::create(&tmp_path).await?; + crate::chain::export_receipts_events_to_forest_car( + ctx.db(), + &start_ts, + recent_roots, + writer, + ) + .await?; + tmp_path + .persist(&output_path) + .context("failed to persist the exported message receipts and events snapshot")?; + Ok(ApiExportResult::Done) + } +} + pub enum ForestChainExport {} impl RpcMethod<1> for ForestChainExport { const NAME: &'static str = "Forest.ChainExport"; @@ -319,7 +368,8 @@ impl RpcMethod<1> for ForestChainExport { include_receipts, include_events, include_tipset_keys, - include_tipset_lookup, + augmented_data, + tipset_lookup, skip_checksum, dry_run, } = params; @@ -337,7 +387,7 @@ impl RpcMethod<1> for ForestChainExport { include_receipts, include_events, include_tipset_keys, - include_tipset_lookup, + include_tipset_lookup: tipset_lookup, seen: FileBackedCidHashSet::new(ctx.temp_dir.as_path())?, }; let tmp_path = @@ -392,8 +442,12 @@ impl RpcMethod<1> for ForestChainExport { }; match chain_export_guard.run_cancellable(chain_export).await { Some(result) => { - let ExportResult { checksum, .. } = result?; + let ExportResult { + checksum, + tipset_lookup: hamt, + } = result?; if !dry_run { + let output_path = output_path.clone(); spawn_blocking_with_timeout(ASYNC_OPS_TIMEOUT, move || { tmp_path.persist(&output_path)?; // The snapshot at `output_path` is usable from this point on; @@ -411,6 +465,58 @@ impl RpcMethod<1> for ForestChainExport { .await .context("failed to persist the exported snapshot")?; } + match (tipset_lookup, hamt) { + (true, Some(hamt)) => { + let mut hamt = + hamt.context("failed to generated tipset lookup snapshot")?; + let roots = nunny::vec![hamt.flush()?]; + let hamt_output_path = + forest_car_with_filename_suffix(&output_path, "_tipset_lookup")?; + let mut writer = if dry_run { + tokio_util::either::Either::Left(VoidAsyncWriter) + } else { + tokio_util::either::Either::Right( + tokio::fs::File::create(&hamt_output_path).await?, + ) + }; + hamt.into_store() + .export_forest_car(roots, &mut writer) + .await + .context("failed to write tipset lookup snapshot")?; + } + (true, None) => { + anyhow::bail!("requested tipset lookup snapshot is missing") + } + (false, Some(_)) => { + anyhow::bail!( + "tipset lookup snapshot should not be generated when it's not requested" + ) + } + _ => {} + } + if augmented_data { + // It takes <10s on mainnet so export it in sequence for simplicity. + // Some stats: + // calibnet 3895470+2000: 5s 5.4MiB + // mainnet 6193120+2000: 5s 12MiB + let augmented_data_output_path = + forest_car_with_filename_suffix(&output_path, "_receipts_events")?; + let writer = if dry_run { + tokio_util::either::Either::Left(VoidAsyncWriter) + } else { + tokio_util::either::Either::Right( + tokio::fs::File::create(&augmented_data_output_path).await?, + ) + }; + crate::chain::export_receipts_events_to_forest_car( + ctx.db(), + &start_ts, + recent_roots, + writer, + ) + .await + .context("failed to export message receipts and events snapshot")?; + } chain_export_guard.mark_as_succeeded(); anyhow::Ok(ApiExportResult::Done) } @@ -609,7 +715,8 @@ impl RpcMethod<1> for ChainExport { include_receipts: false, include_events: false, include_tipset_keys: false, - include_tipset_lookup: false, + augmented_data: false, + tipset_lookup: false, skip_checksum, dry_run, },), @@ -1524,6 +1631,17 @@ pub struct ApiMessage { lotus_json_with_self!(ApiMessage); +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ForestChainExportReceiptsEventsParams { + pub epoch: ChainEpoch, + pub recent_roots: i64, + pub output_path: PathBuf, + #[schemars(with = "LotusJson")] + #[serde(with = "crate::lotus_json", default)] + pub tipset_keys: ApiTipsetKey, +} +lotus_json_with_self!(ForestChainExportReceiptsEventsParams); + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ForestChainExportParams { pub version: FilecoinSnapshotVersion, @@ -1531,7 +1649,7 @@ pub struct ForestChainExportParams { pub recent_roots: i64, pub output_path: PathBuf, #[schemars(with = "LotusJson")] - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::lotus_json", default)] pub tipset_keys: ApiTipsetKey, #[serde(default)] pub include_receipts: bool, @@ -1539,8 +1657,12 @@ pub struct ForestChainExportParams { pub include_events: bool, #[serde(default)] pub include_tipset_keys: bool, + /// Generate a separate snapshot that contains augmented data + #[serde(default)] + pub augmented_data: bool, + /// Generate a separate snapshot that contains tipset lookup #[serde(default)] - pub include_tipset_lookup: bool, + pub tipset_lookup: bool, pub skip_checksum: bool, pub dry_run: bool, } diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 152e6c00ba67..83914e422f5e 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -98,6 +98,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::chain::ForestChainExportDiff); $callback!($crate::rpc::chain::ForestChainExportStatus); $callback!($crate::rpc::chain::ForestChainExportCancel); + $callback!($crate::rpc::chain::ForestChainExportReceiptsEvents); $callback!($crate::rpc::chain::ChainGetTipsetByParentState); // common vertical diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index 429ecc78107f..fb4a57129420 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::*; -use crate::blocks::Tipset; -use crate::chain::index::{ChainIndex, ResolveNullTipset}; +use crate::blocks::{Tipset, TipsetKey}; +use crate::chain::{ + TIPSET_LOOKUP_HAMT_BIT_WIDTH, + index::{ChainIndex, ResolveNullTipset}, +}; use crate::cid_collections::CidHashSet; use crate::cli_shared::snapshot; use crate::daemon::bundle::load_actor_bundles; @@ -16,14 +19,16 @@ use crate::networks::{ChainConfig, NetworkChain, butterflynet, calibnet, mainnet use crate::prelude::*; use crate::shim::address::CurrentNetwork; use crate::shim::clock::ChainEpoch; +use crate::shim::executor::{Receipt, StampedEvent}; use crate::shim::fvm_shared_latest::address::Network; use crate::shim::machine::GLOBAL_MULTI_ENGINE; use crate::state_manager::{ExecutedTipset, apply_block_messages_blocking}; -use crate::utils::db::car_stream::CarStream; +use crate::utils::db::car_stream::{CarBlock, CarStream}; use crate::utils::proofs_api::ensure_proof_params_downloaded; use anyhow::bail; use clap::Subcommand; use dialoguer::{Confirm, theme::ColorfulTheme}; +use fil_actors_shared::fvm_ipld_hamt::Hamt; use futures::TryStreamExt; use indicatif::{ProgressBar, ProgressStyle}; use std::path::PathBuf; @@ -81,6 +86,19 @@ pub enum SnapshotCommands { fail_fast: bool, }, + /// Validate a snapshot's associated augmented and tipset lookup snapshots. + ValidateExtended { + /// Path to a snapshot CAR, which may be zstd compressed + #[arg(long)] + base: PathBuf, + /// Path to the associated augmented snapshot (message receipts and events) + #[arg(long)] + augmented: Option, + /// Path to the associated tipset lookup snapshot + #[arg(long)] + tipset_lookup: Option, + }, + /// Make this snapshot suitable for use as a compressed car-backed blockstore. Compress { /// Input CAR file, in `.car`, `.car.zst`, or `.forest.car.zst` format. @@ -197,6 +215,85 @@ impl SnapshotCommands { }; Ok(()) } + Self::ValidateExtended { + base, + augmented, + tipset_lookup, + } => { + anyhow::ensure!( + augmented.is_some() || tipset_lookup.is_some(), + "either `--augmented` or `--tipset-lookup` needs to be specified" + ); + let store = ManyCar::new(MemoryDB::default()) + .with_read_only(AnyCar::try_from(base.as_path())?)?; + if let Some(augmented) = &augmented { + println!("Importing augmented snapshot..."); + { + let mut car_stream = CarStream::new_from_path(augmented).await?; + while let Some(CarBlock { cid, data }) = car_stream.try_next().await? { + store.put_keyed(&cid, &data)?; + } + } + println!("Verifying message receipts and events can be loaded..."); + let head = store.heaviest_tipset()?; + let mut n_ts = 0; + for ts in head.shallow_clone().chain(&store) { + if !store.has(ts.parent_state())? { + break; + } + n_ts += 1; + for b in ts.block_headers() { + let message_receipts_root = b.message_receipts; + let receipts = Receipt::get_receipts(&store, message_receipts_root).with_context(|| { + format!( + "failed to load receipts, root: {message_receipts_root}, epoch: {}, block: {}", + b.epoch, + b.cid(), + ) + })?; + for r in receipts { + if let Some(events_root) = r.events_root() { + StampedEvent::get_events(&store, &events_root).with_context(|| { + format!( + "failed to load events, root: {events_root}, epoch: {}, block: {}", + b.epoch, + b.cid(), + ) + })?; + } + } + } + } + println!("Augmented snapshot is valid, {n_ts} tipsets validated"); + } + if let Some(tipset_lookup) = &tipset_lookup { + println!("Importing tipset lookup snapshot..."); + let hamt_root = { + let mut car_stream = CarStream::new_from_path(tipset_lookup).await?; + let hamt_root = *car_stream.header_v1.roots.first(); + while let Some(CarBlock { cid, data }) = car_stream.try_next().await? { + store.put_keyed(&cid, &data)?; + } + hamt_root + }; + let hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::load_with_bit_width(&hamt_root, &store, TIPSET_LOOKUP_HAMT_BIT_WIDTH) + .context("failed to load tipset lookup HAMT")?; + let head = store.heaviest_tipset()?; + println!("Verifying lookup checkpoints can be loaded..."); + let mut n = 0; + for checkpoint in (0..=head.epoch()) + .filter(|&epoch| ChainIndex::is_tipset_lookup_checkpoint(epoch)) + { + if let Some(tsk) = hamt.get(&checkpoint)? { + n += 1; + Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?; + } + } + println!("Tipset lookup snapshot is valid, {n} checkpoints validated"); + } + Ok(()) + } Self::Compress { source, output_path, From d7376252bee67aac729423f7b98f678ecf4daa29 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Fri, 17 Jul 2026 14:34:19 +0800 Subject: [PATCH 02/14] resolve comments --- scripts/tests/calibnet_export_check.sh | 2 +- src/tool/subcommands/snapshot_cmd.rs | 27 ++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/tests/calibnet_export_check.sh b/scripts/tests/calibnet_export_check.sh index 147ef730f427..56426886b7da 100755 --- a/scripts/tests/calibnet_export_check.sh +++ b/scripts/tests/calibnet_export_check.sh @@ -96,7 +96,7 @@ ls -ahl *.forest.car.zst # Validate tipset lookup snapshots # export and check augmented data once we have receipts and events tipset published and imported # for now there's no receipts and events on a freshly bootstrapped node. -forest-tool snapshot validate-extended --base snapshot.forest.car.zst --tipset-lookup snapshot_tipset_lookup.forest.car.zst +$FOREST_TOOL_PATH snapshot validate-extended --base snapshot.forest.car.zst --tipset-lookup snapshot_tipset_lookup.forest.car.zst # Remove tipset lookup snapshots before proceeding rm *_tipset_lookup.forest.car.zst diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index fb4a57129420..a65b4bbc70e5 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -281,16 +281,35 @@ impl SnapshotCommands { .context("failed to load tipset lookup HAMT")?; let head = store.heaviest_tipset()?; println!("Verifying lookup checkpoints can be loaded..."); - let mut n = 0; + let mut n_checkpoints = 0; + let mut n_null_checkpoints = 0; + let mut last_checkpoint_ts = None; for checkpoint in (0..=head.epoch()) .filter(|&epoch| ChainIndex::is_tipset_lookup_checkpoint(epoch)) { if let Some(tsk) = hamt.get(&checkpoint)? { - n += 1; - Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?; + n_checkpoints += 1; + last_checkpoint_ts=Some(Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?); + } else if let Some(last_checkpoint_ts) = &last_checkpoint_ts { + n_null_checkpoints += 1; + // verify checkpoint epoch is null + let checkpoint_ts = last_checkpoint_ts + .shallow_clone() + .chain(&store) + .take_while(|ts| ts.epoch() >= checkpoint) + .find(|ts| ts.epoch() == checkpoint); + if let Some(checkpoint_ts) = checkpoint_ts { + anyhow::bail!( + "checkpoint epoch {} with key {} is missing from tipset lookup snapshot", + checkpoint_ts.epoch(), + checkpoint_ts.key() + ); + } } } - println!("Tipset lookup snapshot is valid, {n} checkpoints validated"); + println!( + "Tipset lookup snapshot is valid, {n_checkpoints} checkpoints and {n_null_checkpoints} null checkpoints validated" + ); } Ok(()) } From 492ae16900c39cdf69e087310158e0c36ac6db02 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Fri, 17 Jul 2026 14:36:42 +0800 Subject: [PATCH 03/14] mise insta --- .../forest__rpc__tests__rpc__v0.snap | 44 +++++++++++++++++-- .../forest__rpc__tests__rpc__v1.snap | 44 +++++++++++++++++-- .../forest__rpc__tests__rpc__v2.snap | 44 +++++++++++++++++-- src/tool/subcommands/snapshot_cmd.rs | 4 +- 4 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 2ac6259fb9f9..b7053993d4c8 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -435,6 +435,19 @@ methods: schema: type: boolean paramStructure: by-position + - name: Forest.ChainExportReceiptsEvents + description: Exports chain message receipts and events snapshot to a CAR file from the given epoch + params: + - name: params + required: true + schema: + $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" + result: + name: Forest.ChainExportReceiptsEvents.Result + required: true + schema: + $ref: "#/components/schemas/ApiExportResult" + paramStructure: by-position - name: Forest.ChainGetTipsetByParentState description: "Returns the tipset whose parent state root matches the given CID, or null if none is found." params: @@ -6381,6 +6394,10 @@ components: ForestChainExportParams: type: object properties: + augmented_data: + description: Generate a separate snapshot that contains augmented data + type: boolean + default: false dry_run: type: boolean epoch: @@ -6395,9 +6412,6 @@ components: include_tipset_keys: type: boolean default: false - include_tipset_lookup: - type: boolean - default: false output_path: type: string recent_roots: @@ -6407,6 +6421,11 @@ components: type: boolean tipset_keys: $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + tipset_lookup: + description: Generate a separate snapshot that contains tipset lookup + type: boolean + default: false version: $ref: "#/components/schemas/FilecoinSnapshotVersion" required: @@ -6414,9 +6433,26 @@ components: - epoch - recent_roots - output_path - - tipset_keys - skip_checksum - dry_run + ForestChainExportReceiptsEventsParams: + type: object + properties: + epoch: + type: integer + format: int64 + output_path: + type: string + recent_roots: + type: integer + format: int64 + tipset_keys: + $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + required: + - epoch + - recent_roots + - output_path ForestComputeStateOutput: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index 0c8af394c2ab..e89e0202045f 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -430,6 +430,19 @@ methods: schema: type: boolean paramStructure: by-position + - name: Forest.ChainExportReceiptsEvents + description: Exports chain message receipts and events snapshot to a CAR file from the given epoch + params: + - name: params + required: true + schema: + $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" + result: + name: Forest.ChainExportReceiptsEvents.Result + required: true + schema: + $ref: "#/components/schemas/ApiExportResult" + paramStructure: by-position - name: Forest.ChainGetTipsetByParentState description: "Returns the tipset whose parent state root matches the given CID, or null if none is found." params: @@ -6496,6 +6509,10 @@ components: ForestChainExportParams: type: object properties: + augmented_data: + description: Generate a separate snapshot that contains augmented data + type: boolean + default: false dry_run: type: boolean epoch: @@ -6510,9 +6527,6 @@ components: include_tipset_keys: type: boolean default: false - include_tipset_lookup: - type: boolean - default: false output_path: type: string recent_roots: @@ -6522,6 +6536,11 @@ components: type: boolean tipset_keys: $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + tipset_lookup: + description: Generate a separate snapshot that contains tipset lookup + type: boolean + default: false version: $ref: "#/components/schemas/FilecoinSnapshotVersion" required: @@ -6529,9 +6548,26 @@ components: - epoch - recent_roots - output_path - - tipset_keys - skip_checksum - dry_run + ForestChainExportReceiptsEventsParams: + type: object + properties: + epoch: + type: integer + format: int64 + output_path: + type: string + recent_roots: + type: integer + format: int64 + tipset_keys: + $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + required: + - epoch + - recent_roots + - output_path ForestComputeStateOutput: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index 65eebc1025bf..607cd00cf25c 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -73,6 +73,19 @@ methods: schema: type: boolean paramStructure: by-position + - name: Forest.ChainExportReceiptsEvents + description: Exports chain message receipts and events snapshot to a CAR file from the given epoch + params: + - name: params + required: true + schema: + $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" + result: + name: Forest.ChainExportReceiptsEvents.Result + required: true + schema: + $ref: "#/components/schemas/ApiExportResult" + paramStructure: by-position - name: Filecoin.EthAccounts description: Returns the list of addresses owned by the client; always empty since Forest does not manage private keys. params: [] @@ -2649,6 +2662,10 @@ components: ForestChainExportParams: type: object properties: + augmented_data: + description: Generate a separate snapshot that contains augmented data + type: boolean + default: false dry_run: type: boolean epoch: @@ -2663,9 +2680,6 @@ components: include_tipset_keys: type: boolean default: false - include_tipset_lookup: - type: boolean - default: false output_path: type: string recent_roots: @@ -2675,6 +2689,11 @@ components: type: boolean tipset_keys: $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + tipset_lookup: + description: Generate a separate snapshot that contains tipset lookup + type: boolean + default: false version: $ref: "#/components/schemas/FilecoinSnapshotVersion" required: @@ -2682,9 +2701,26 @@ components: - epoch - recent_roots - output_path - - tipset_keys - skip_checksum - dry_run + ForestChainExportReceiptsEventsParams: + type: object + properties: + epoch: + type: integer + format: int64 + output_path: + type: string + recent_roots: + type: integer + format: int64 + tipset_keys: + $ref: "#/components/schemas/Nullable_Array_of_Cid" + default: ~ + required: + - epoch + - recent_roots + - output_path GethCallFrame: description: "Geth-style nested call frame returned by the `callTracer`." type: object diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index a65b4bbc70e5..f894e8113b1f 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -280,7 +280,7 @@ impl SnapshotCommands { Hamt::load_with_bit_width(&hamt_root, &store, TIPSET_LOOKUP_HAMT_BIT_WIDTH) .context("failed to load tipset lookup HAMT")?; let head = store.heaviest_tipset()?; - println!("Verifying lookup checkpoints can be loaded..."); + println!("Verifying lookup checkpoints match the snapshot chain..."); let mut n_checkpoints = 0; let mut n_null_checkpoints = 0; let mut last_checkpoint_ts = None; @@ -289,7 +289,7 @@ impl SnapshotCommands { { if let Some(tsk) = hamt.get(&checkpoint)? { n_checkpoints += 1; - last_checkpoint_ts=Some(Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?); + last_checkpoint_ts = Some(Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?); } else if let Some(last_checkpoint_ts) = &last_checkpoint_ts { n_null_checkpoints += 1; // verify checkpoint epoch is null From 042f4c0841c0486a4e2d71532b05f913304325b5 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Fri, 17 Jul 2026 15:54:13 +0800 Subject: [PATCH 04/14] resolve comments --- src/rpc/methods/chain.rs | 4 +++- src/rpc/mod.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 3922a850ef31..a82222c9bcdb 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -273,7 +273,8 @@ impl RpcMethod<1> for ChainPruneSnapshot { } } -/// For internal testing only. Generate this snapshot with `Forest.ChainExport` by setting `augmented_data: true` +#[allow(dead_code)] +/// For dev testing only. Uncomment method registration in `src/rpc/mod.rs` to enable pub enum ForestChainExportReceiptsEvents {} impl RpcMethod<1> for ForestChainExportReceiptsEvents { const NAME: &'static str = "Forest.ChainExportReceiptsEvents"; @@ -1631,6 +1632,7 @@ pub struct ApiMessage { lotus_json_with_self!(ApiMessage); +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ForestChainExportReceiptsEventsParams { pub epoch: ChainEpoch, diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 83914e422f5e..e9c3df228190 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -98,7 +98,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::chain::ForestChainExportDiff); $callback!($crate::rpc::chain::ForestChainExportStatus); $callback!($crate::rpc::chain::ForestChainExportCancel); - $callback!($crate::rpc::chain::ForestChainExportReceiptsEvents); + // $callback!($crate::rpc::chain::ForestChainExportReceiptsEvents); $callback!($crate::rpc::chain::ChainGetTipsetByParentState); // common vertical From f8111dff5320abe0913204fa209685fce37164c0 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Fri, 17 Jul 2026 17:50:24 +0800 Subject: [PATCH 05/14] mise insta --- .../forest__rpc__tests__rpc__v0.snap | 31 ------------------- .../forest__rpc__tests__rpc__v1.snap | 31 ------------------- .../forest__rpc__tests__rpc__v2.snap | 31 ------------------- 3 files changed, 93 deletions(-) diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 2882bb44ab8d..10f1f7afccec 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -435,19 +435,6 @@ methods: schema: type: boolean paramStructure: by-position - - name: Forest.ChainExportReceiptsEvents - description: Exports chain message receipts and events snapshot to a CAR file from the given epoch - params: - - name: params - required: true - schema: - $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" - result: - name: Forest.ChainExportReceiptsEvents.Result - required: true - schema: - $ref: "#/components/schemas/ApiExportResult" - paramStructure: by-position - name: Forest.ChainGetTipsetByParentState description: "Returns the tipset whose parent state root matches the given CID, or null if none is found." params: @@ -6435,24 +6422,6 @@ components: - output_path - skip_checksum - dry_run - ForestChainExportReceiptsEventsParams: - type: object - properties: - epoch: - type: integer - format: int64 - output_path: - type: string - recent_roots: - type: integer - format: int64 - tipset_keys: - $ref: "#/components/schemas/Nullable_Array_of_Cid" - default: ~ - required: - - epoch - - recent_roots - - output_path ForestComputeStateOutput: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index 54e61c2bc8db..e9e76b8d993d 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -430,19 +430,6 @@ methods: schema: type: boolean paramStructure: by-position - - name: Forest.ChainExportReceiptsEvents - description: Exports chain message receipts and events snapshot to a CAR file from the given epoch - params: - - name: params - required: true - schema: - $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" - result: - name: Forest.ChainExportReceiptsEvents.Result - required: true - schema: - $ref: "#/components/schemas/ApiExportResult" - paramStructure: by-position - name: Forest.ChainGetTipsetByParentState description: "Returns the tipset whose parent state root matches the given CID, or null if none is found." params: @@ -6550,24 +6537,6 @@ components: - output_path - skip_checksum - dry_run - ForestChainExportReceiptsEventsParams: - type: object - properties: - epoch: - type: integer - format: int64 - output_path: - type: string - recent_roots: - type: integer - format: int64 - tipset_keys: - $ref: "#/components/schemas/Nullable_Array_of_Cid" - default: ~ - required: - - epoch - - recent_roots - - output_path ForestComputeStateOutput: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index 607cd00cf25c..52da75c3fadb 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -73,19 +73,6 @@ methods: schema: type: boolean paramStructure: by-position - - name: Forest.ChainExportReceiptsEvents - description: Exports chain message receipts and events snapshot to a CAR file from the given epoch - params: - - name: params - required: true - schema: - $ref: "#/components/schemas/ForestChainExportReceiptsEventsParams" - result: - name: Forest.ChainExportReceiptsEvents.Result - required: true - schema: - $ref: "#/components/schemas/ApiExportResult" - paramStructure: by-position - name: Filecoin.EthAccounts description: Returns the list of addresses owned by the client; always empty since Forest does not manage private keys. params: [] @@ -2703,24 +2690,6 @@ components: - output_path - skip_checksum - dry_run - ForestChainExportReceiptsEventsParams: - type: object - properties: - epoch: - type: integer - format: int64 - output_path: - type: string - recent_roots: - type: integer - format: int64 - tipset_keys: - $ref: "#/components/schemas/Nullable_Array_of_Cid" - default: ~ - required: - - epoch - - recent_roots - - output_path GethCallFrame: description: "Geth-style nested call frame returned by the `callTracer`." type: object From bc1f0693571964ac9cd015f6420122ffcb07e2fb Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Mon, 20 Jul 2026 15:15:52 +0800 Subject: [PATCH 06/14] resolve comments --- docs/docs/users/reference/cli.sh | 1 + src/rpc/methods/chain.rs | 4 ++-- src/tool/subcommands/snapshot_cmd.rs | 13 +++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/docs/users/reference/cli.sh b/docs/docs/users/reference/cli.sh index 1ed416bc6a76..077383d7110a 100755 --- a/docs/docs/users/reference/cli.sh +++ b/docs/docs/users/reference/cli.sh @@ -112,6 +112,7 @@ generate_markdown_section "forest-tool" "snapshot" generate_markdown_section "forest-tool" "snapshot fetch" generate_markdown_section "forest-tool" "snapshot validate-diffs" generate_markdown_section "forest-tool" "snapshot validate" +generate_markdown_section "forest-tool" "snapshot validate-extended" generate_markdown_section "forest-tool" "snapshot compress" generate_markdown_section "forest-tool" "snapshot compute-state" diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index a82222c9bcdb..b028d77e1004 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -311,7 +311,7 @@ impl RpcMethod<1> for ForestChainExportReceiptsEvents { crate::chain::export_receipts_events_to_forest_car( ctx.db(), &start_ts, - recent_roots, + recent_roots.into(), writer, ) .await?; @@ -1636,7 +1636,7 @@ lotus_json_with_self!(ApiMessage); #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ForestChainExportReceiptsEventsParams { pub epoch: ChainEpoch, - pub recent_roots: i64, + pub recent_roots: u32, pub output_path: PathBuf, #[schemars(with = "LotusJson")] #[serde(with = "crate::lotus_json", default)] diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index f894e8113b1f..27bd2a83f9a3 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -87,15 +87,20 @@ pub enum SnapshotCommands { }, /// Validate a snapshot's associated augmented and tipset lookup snapshots. + #[command(group( + clap::ArgGroup::new("aug") + .required(true) + .multiple(true), + ))] ValidateExtended { /// Path to a snapshot CAR, which may be zstd compressed #[arg(long)] base: PathBuf, /// Path to the associated augmented snapshot (message receipts and events) - #[arg(long)] + #[arg(long, group = "aug")] augmented: Option, /// Path to the associated tipset lookup snapshot - #[arg(long)] + #[arg(long, group = "aug")] tipset_lookup: Option, }, @@ -220,10 +225,6 @@ impl SnapshotCommands { augmented, tipset_lookup, } => { - anyhow::ensure!( - augmented.is_some() || tipset_lookup.is_some(), - "either `--augmented` or `--tipset-lookup` needs to be specified" - ); let store = ManyCar::new(MemoryDB::default()) .with_read_only(AnyCar::try_from(base.as_path())?)?; if let Some(augmented) = &augmented { From 38a09a479719fddcc7bd0f4734ba1485976f761d Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Mon, 20 Jul 2026 16:27:44 +0800 Subject: [PATCH 07/14] resolve comments --- src/rpc/methods/chain.rs | 61 ---------------------------- src/rpc/mod.rs | 1 - src/tool/subcommands/snapshot_cmd.rs | 1 + 3 files changed, 1 insertion(+), 62 deletions(-) diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index b028d77e1004..21dc713d723c 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -273,55 +273,6 @@ impl RpcMethod<1> for ChainPruneSnapshot { } } -#[allow(dead_code)] -/// For dev testing only. Uncomment method registration in `src/rpc/mod.rs` to enable -pub enum ForestChainExportReceiptsEvents {} -impl RpcMethod<1> for ForestChainExportReceiptsEvents { - const NAME: &'static str = "Forest.ChainExportReceiptsEvents"; - const PARAM_NAMES: [&'static str; 1] = ["params"]; - const API_PATHS: BitFlags = ApiPaths::all_with_v2(); - const PERMISSION: Permission = Permission::Read; - const DESCRIPTION: &'static str = - "Exports chain message receipts and events snapshot to a CAR file from the given epoch"; - - type Params = (ForestChainExportReceiptsEventsParams,); - type Ok = ApiExportResult; - - async fn handle( - ctx: Ctx, - (params,): Self::Params, - _: &http::Extensions, - ) -> Result { - let ForestChainExportReceiptsEventsParams { - epoch, - recent_roots, - output_path, - tipset_keys: ApiTipsetKey(tsk), - } = params; - - let head = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?; - let start_ts = ctx - .chain_index() - .load_required_tipset_by_height(epoch, head, ResolveNullTipset::TakeOlder) - .await?; - - let tmp_path = - tempfile::TempPath::try_from_path(tmp_exporting_forest_car_path(&output_path))?; - let writer = tokio::fs::File::create(&tmp_path).await?; - crate::chain::export_receipts_events_to_forest_car( - ctx.db(), - &start_ts, - recent_roots.into(), - writer, - ) - .await?; - tmp_path - .persist(&output_path) - .context("failed to persist the exported message receipts and events snapshot")?; - Ok(ApiExportResult::Done) - } -} - pub enum ForestChainExport {} impl RpcMethod<1> for ForestChainExport { const NAME: &'static str = "Forest.ChainExport"; @@ -1632,18 +1583,6 @@ pub struct ApiMessage { lotus_json_with_self!(ApiMessage); -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ForestChainExportReceiptsEventsParams { - pub epoch: ChainEpoch, - pub recent_roots: u32, - pub output_path: PathBuf, - #[schemars(with = "LotusJson")] - #[serde(with = "crate::lotus_json", default)] - pub tipset_keys: ApiTipsetKey, -} -lotus_json_with_self!(ForestChainExportReceiptsEventsParams); - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ForestChainExportParams { pub version: FilecoinSnapshotVersion, diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index e9c3df228190..152e6c00ba67 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -98,7 +98,6 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::chain::ForestChainExportDiff); $callback!($crate::rpc::chain::ForestChainExportStatus); $callback!($crate::rpc::chain::ForestChainExportCancel); - // $callback!($crate::rpc::chain::ForestChainExportReceiptsEvents); $callback!($crate::rpc::chain::ChainGetTipsetByParentState); // common vertical diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index 27bd2a83f9a3..49464971ca10 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -239,6 +239,7 @@ impl SnapshotCommands { let head = store.heaviest_tipset()?; let mut n_ts = 0; for ts in head.shallow_clone().chain(&store) { + // Validate only when state trees present if !store.has(ts.parent_state())? { break; } From 31ee9c1d27afaa18ba630f811709f9d50e050417 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Mon, 20 Jul 2026 22:03:06 +0800 Subject: [PATCH 08/14] resolve CC feedbacks --- src/chain/mod.rs | 23 ++++++++--------- src/db/car/forest.rs | 33 ++++++++++++++----------- src/db/memory.rs | 6 +++-- src/ipld/util.rs | 8 ------ src/rpc/methods/chain.rs | 37 +++++++++++++++++++++------- src/tool/subcommands/snapshot_cmd.rs | 15 ++++++++--- 6 files changed, 71 insertions(+), 51 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 89f78d357dd9..3cb7d353f2d7 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -281,23 +281,20 @@ pub async fn export_receipts_events_to_forest_car( .chain(db) .take_while(|ts| ts.epoch() > min_lookup_epoch_exclusive) { - for b in ts.block_headers() { - let message_receipts_root = b.message_receipts; - ipld_roots.push(message_receipts_root); - let receipts = Receipt::get_receipts(db, message_receipts_root).with_context(|| { - format!( - "failed to get receipts, root: {message_receipts_root}, epoch: {}, block: {}", - b.epoch, - b.cid(), - ) - })?; - ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root())); - } + let message_receipts_root = *ts.parent_message_receipts(); + ipld_roots.push(message_receipts_root); + let receipts = Receipt::get_receipts(db, message_receipts_root).with_context(|| { + format!( + "failed to get receipts, root: {message_receipts_root}, epoch: {}, tipset key: {}", + ts.epoch(), + ts.key(), + ) + })?; + ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root())); } let stream = IpldStream::new(db.shallow_clone(), ipld_roots, CidHashSet::default()); - // Wrap writer in optional checksum calculator let mut writer = BufWriter::new(writer); // Stream stateroots in range (stateroot_lookup_limit+1)..=tipset.epoch(). Also diff --git a/src/db/car/forest.rs b/src/db/car/forest.rs index 4fc4d560915e..be273a58b840 100644 --- a/src/db/car/forest.rs +++ b/src/db/car/forest.rs @@ -534,7 +534,7 @@ pub fn forest_car_with_filename_suffix(path: &Path, suffix: &str) -> anyhow::Res anyhow::ensure!(!suffix.is_empty(), "suffix cannot be empty"); let file_name = path.file_name().and_then(|n| n.to_str()).with_context(|| { format!( - "failed to extract filename from the give path: {}", + "failed to extract filename from the given path: {}", path.display() ) })?; @@ -671,11 +671,14 @@ mod tests { #[rstest] #[case( - Path::new("/tmp/a.forst.car.zst"), - Path::new("/tmp/a.forst.car.zst.tmp") + Path::new("/tmp/a.forest.car.zst"), + Path::new("/tmp/a.forest.car.zst.tmp") )] - #[case(Path::new("tmp/a.forst.car.zst"), Path::new("tmp/a.forst.car.zst.tmp"))] - #[case(Path::new("a.forst.car.zst"), Path::new("a.forst.car.zst.tmp"))] + #[case( + Path::new("tmp/a.forest.car.zst"), + Path::new("tmp/a.forest.car.zst.tmp") + )] + #[case(Path::new("a.forest.car.zst"), Path::new("a.forest.car.zst.tmp"))] #[case(Path::new(""), Path::new(""))] #[case(Path::new("."), Path::new("."))] fn test_tmp_exporting_forest_car_path(#[case] input: &Path, #[case] output: &Path) { @@ -684,14 +687,14 @@ mod tests { #[rstest] #[case( - Path::new("/tmp/a.forst.car.zst"), - Path::new("/tmp/a.forst.car.zst.sha256sum") + Path::new("/tmp/a.forest.car.zst"), + Path::new("/tmp/a.forest.car.zst.sha256sum") )] #[case( - Path::new("tmp/a.forst.car.zst"), - Path::new("tmp/a.forst.car.zst.sha256sum") + Path::new("tmp/a.forest.car.zst"), + Path::new("tmp/a.forest.car.zst.sha256sum") )] - #[case(Path::new("a.forst.car.zst"), Path::new("a.forst.car.zst.sha256sum"))] + #[case(Path::new("a.forest.car.zst"), Path::new("a.forest.car.zst.sha256sum"))] #[case(Path::new(""), Path::new(""))] #[case(Path::new("."), Path::new("."))] fn test_forest_car_sha256sum_path(#[case] input: &Path, #[case] output: &Path) { @@ -700,14 +703,14 @@ mod tests { #[rstest] #[case( - Path::new("/tmp/a.forst.car.zst"), + Path::new("/tmp/a.forest.car.zst"), "_suffix", - Path::new("/tmp/a_suffix.forst.car.zst") + Path::new("/tmp/a_suffix.forest.car.zst") )] #[case( - Path::new("a.forst.car.zst"), + Path::new("a.forest.car.zst"), "_suffix", - Path::new("a_suffix.forst.car.zst") + Path::new("a_suffix.forest.car.zst") )] #[case(Path::new("a"), "_suffix", Path::new("a_suffix"))] fn test_forest_car_with_filename_suffix_valid( @@ -722,7 +725,7 @@ mod tests { } #[rstest] - #[case(Path::new("/tmp/a.forst.car.zst"), "", "suffix cannot be empty")] + #[case(Path::new("/tmp/a.forest.car.zst"), "", "suffix cannot be empty")] #[case(Path::new("."), "_suffix", "failed to extract filename")] fn test_forest_car_with_filename_suffix_invalid( #[case] input: &Path, diff --git a/src/db/memory.rs b/src/db/memory.rs index 48aaba3dcf97..abc0e8f599b2 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -17,6 +17,7 @@ use ahash::HashMap; use indexmap::IndexMap; use nunny::Vec as NonEmpty; use parking_lot::RwLock; +use tokio::io::AsyncWriteExt as _; #[derive(Debug, Default)] pub struct MemoryDB { @@ -243,7 +244,6 @@ pub struct IndexMapBlockstore { } impl IndexMapBlockstore { - #[allow(dead_code)] pub async fn export_forest_car( &self, roots: NonEmpty, @@ -271,7 +271,9 @@ impl IndexMapBlockstore { }; let frames = crate::db::car::forest::Encoder::compress_stream_default(futures::stream::iter(blocks)); - crate::db::car::forest::Encoder::write(writer, roots, frames).await + crate::db::car::forest::Encoder::write(&mut *writer, roots, frames).await?; + writer.flush().await?; + Ok(()) } } diff --git a/src/ipld/util.rs b/src/ipld/util.rs index 25f43fdf4fb1..920ad9ab7a3a 100644 --- a/src/ipld/util.rs +++ b/src/ipld/util.rs @@ -577,14 +577,6 @@ impl Stream for IpldStream { } } -pin_project! { - pub struct MessageReceiptsEventsStream { - db: DB, - cid_vec: Vec, - seen: S, - } -} - fn ipld_to_cid(ipld: Ipld) -> Option { if let Ipld::Link(cid) = ipld { Some(cid) diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 21dc713d723c..d41e005aebdd 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -420,21 +420,30 @@ impl RpcMethod<1> for ForestChainExport { match (tipset_lookup, hamt) { (true, Some(hamt)) => { let mut hamt = - hamt.context("failed to generated tipset lookup snapshot")?; + hamt.context("failed to generate tipset lookup snapshot")?; let roots = nunny::vec![hamt.flush()?]; let hamt_output_path = forest_car_with_filename_suffix(&output_path, "_tipset_lookup")?; - let mut writer = if dry_run { - tokio_util::either::Either::Left(VoidAsyncWriter) + let (mut writer, hamt_output_tmp_path) = if dry_run { + (tokio_util::either::Either::Left(VoidAsyncWriter), None) } else { - tokio_util::either::Either::Right( - tokio::fs::File::create(&hamt_output_path).await?, + let tmp_path = tempfile::TempPath::try_from_path( + tmp_exporting_forest_car_path(&hamt_output_path), + )?; + ( + tokio_util::either::Either::Right( + tokio::fs::File::create(&tmp_path).await?, + ), + Some(tmp_path), ) }; hamt.into_store() .export_forest_car(roots, &mut writer) .await .context("failed to write tipset lookup snapshot")?; + if let Some(hamt_output_tmp_path) = hamt_output_tmp_path { + hamt_output_tmp_path.persist(hamt_output_path)?; + } } (true, None) => { anyhow::bail!("requested tipset lookup snapshot is missing") @@ -453,11 +462,17 @@ impl RpcMethod<1> for ForestChainExport { // mainnet 6193120+2000: 5s 12MiB let augmented_data_output_path = forest_car_with_filename_suffix(&output_path, "_receipts_events")?; - let writer = if dry_run { - tokio_util::either::Either::Left(VoidAsyncWriter) + let (writer, augmented_data_output_tmp_path) = if dry_run { + (tokio_util::either::Either::Left(VoidAsyncWriter), None) } else { - tokio_util::either::Either::Right( - tokio::fs::File::create(&augmented_data_output_path).await?, + let tmp_path = tempfile::TempPath::try_from_path( + tmp_exporting_forest_car_path(&augmented_data_output_path), + )?; + ( + tokio_util::either::Either::Right( + tokio::fs::File::create(&tmp_path).await?, + ), + Some(tmp_path), ) }; crate::chain::export_receipts_events_to_forest_car( @@ -468,6 +483,10 @@ impl RpcMethod<1> for ForestChainExport { ) .await .context("failed to export message receipts and events snapshot")?; + if let Some(augmented_data_output_tmp_path) = augmented_data_output_tmp_path + { + augmented_data_output_tmp_path.persist(augmented_data_output_path)?; + } } chain_export_guard.mark_as_succeeded(); anyhow::Ok(ApiExportResult::Done) diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index 49464971ca10..5238c5428103 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -285,17 +285,24 @@ impl SnapshotCommands { println!("Verifying lookup checkpoints match the snapshot chain..."); let mut n_checkpoints = 0; let mut n_null_checkpoints = 0; - let mut last_checkpoint_ts = None; + let mut null_checkpoint_search_from_ts = head.shallow_clone(); for checkpoint in (0..=head.epoch()) .filter(|&epoch| ChainIndex::is_tipset_lookup_checkpoint(epoch)) + .rev() { if let Some(tsk) = hamt.get(&checkpoint)? { n_checkpoints += 1; - last_checkpoint_ts = Some(Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?); - } else if let Some(last_checkpoint_ts) = &last_checkpoint_ts { + let ts = Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?; + anyhow::ensure!( + ts.epoch() == checkpoint, + "tipset mismatch, checkpoint epoch: {checkpoint}, tipset epoch: {}, tipset key: {tsk}", + ts.epoch() + ); + null_checkpoint_search_from_ts = ts; + } else { n_null_checkpoints += 1; // verify checkpoint epoch is null - let checkpoint_ts = last_checkpoint_ts + let checkpoint_ts = null_checkpoint_search_from_ts .shallow_clone() .chain(&store) .take_while(|ts| ts.epoch() >= checkpoint) From 70fc617b7905f9386a26d9b182dc87b7da3d1dce Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 15:28:30 +0800 Subject: [PATCH 09/14] tests and changelog --- CHANGELOG.md | 2 + src/chain/store/index.rs | 8 +- src/tool/subcommands/snapshot_cmd.rs | 126 ++++++++++++--------- src/tool/subcommands/snapshot_cmd/tests.rs | 106 +++++++++++++++++ 4 files changed, 184 insertions(+), 58 deletions(-) create mode 100644 src/tool/subcommands/snapshot_cmd/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8022b83ab8..4e292ba011ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ ### Added +- [#7376](https://github.com/ChainSafe/forest/pull/7376): Added `--augmented-data` and `--tipset-lookup` to `forest-cli snapshot export`. + ### Changed ### Removed diff --git a/src/chain/store/index.rs b/src/chain/store/index.rs index 29d373df8e8c..5b4a84689ecd 100644 --- a/src/chain/store/index.rs +++ b/src/chain/store/index.rs @@ -443,7 +443,7 @@ impl ChainIndex { } #[cfg(test)] -mod tests { +pub mod tests { use super::*; use crate::blocks::{CachingBlockHeader, RawBlockHeader}; use crate::chain::store::ChainStore; @@ -457,20 +457,20 @@ mod tests { atomic::{AtomicU64, Ordering}, }; - fn persist_tipset(tipset: &Tipset, db: &impl Blockstore) { + pub fn persist_tipset(tipset: &Tipset, db: &impl Blockstore) { for block in tipset.block_headers() { db.put_cbor_default(block).unwrap(); } } - fn genesis_tipset() -> Tipset { + pub fn genesis_tipset() -> Tipset { Tipset::from(CachingBlockHeader::new(RawBlockHeader { ticket: dummy_ticket(0), ..Default::default() })) } - fn tipset_child(parent: &Tipset, epoch: ChainEpoch) -> Tipset { + pub fn tipset_child(parent: &Tipset, epoch: ChainEpoch) -> Tipset { // Use a static counter to give all tipsets a unique timestamp static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index 5238c5428103..3bb52f44c3ee 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -1,6 +1,9 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +#[cfg(test)] +mod tests; + use super::*; use crate::blocks::{Tipset, TipsetKey}; use crate::chain::{ @@ -244,25 +247,23 @@ impl SnapshotCommands { break; } n_ts += 1; - for b in ts.block_headers() { - let message_receipts_root = b.message_receipts; - let receipts = Receipt::get_receipts(&store, message_receipts_root).with_context(|| { + let message_receipts_root = *ts.parent_message_receipts(); + let receipts = Receipt::get_receipts(&store, message_receipts_root).with_context(|| { format!( - "failed to load receipts, root: {message_receipts_root}, epoch: {}, block: {}", - b.epoch, - b.cid(), + "failed to load receipts, root: {message_receipts_root}, epoch: {}, tipset key: {}", + ts.epoch(), + ts.key(), ) })?; - for r in receipts { - if let Some(events_root) = r.events_root() { - StampedEvent::get_events(&store, &events_root).with_context(|| { + for r in receipts { + if let Some(events_root) = r.events_root() { + StampedEvent::get_events(&store, &events_root).with_context(|| { format!( - "failed to load events, root: {events_root}, epoch: {}, block: {}", - b.epoch, - b.cid(), + "failed to load events, root: {events_root}, epoch: {}, tipset key: {}", + ts.epoch(), + ts.key(), ) })?; - } } } } @@ -278,47 +279,7 @@ impl SnapshotCommands { } hamt_root }; - let hamt: Hamt<_, TipsetKey, ChainEpoch> = - Hamt::load_with_bit_width(&hamt_root, &store, TIPSET_LOOKUP_HAMT_BIT_WIDTH) - .context("failed to load tipset lookup HAMT")?; - let head = store.heaviest_tipset()?; - println!("Verifying lookup checkpoints match the snapshot chain..."); - let mut n_checkpoints = 0; - let mut n_null_checkpoints = 0; - let mut null_checkpoint_search_from_ts = head.shallow_clone(); - for checkpoint in (0..=head.epoch()) - .filter(|&epoch| ChainIndex::is_tipset_lookup_checkpoint(epoch)) - .rev() - { - if let Some(tsk) = hamt.get(&checkpoint)? { - n_checkpoints += 1; - let ts = Tipset::load_required(&store, tsk).with_context(|| format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}"))?; - anyhow::ensure!( - ts.epoch() == checkpoint, - "tipset mismatch, checkpoint epoch: {checkpoint}, tipset epoch: {}, tipset key: {tsk}", - ts.epoch() - ); - null_checkpoint_search_from_ts = ts; - } else { - n_null_checkpoints += 1; - // verify checkpoint epoch is null - let checkpoint_ts = null_checkpoint_search_from_ts - .shallow_clone() - .chain(&store) - .take_while(|ts| ts.epoch() >= checkpoint) - .find(|ts| ts.epoch() == checkpoint); - if let Some(checkpoint_ts) = checkpoint_ts { - anyhow::bail!( - "checkpoint epoch {} with key {} is missing from tipset lookup snapshot", - checkpoint_ts.epoch(), - checkpoint_ts.key() - ); - } - } - } - println!( - "Tipset lookup snapshot is valid, {n_checkpoints} checkpoints and {n_null_checkpoints} null checkpoints validated" - ); + validate_tipset_lookup_hamt(&store, hamt_root, store.heaviest_tipset()?)?; } Ok(()) } @@ -396,6 +357,63 @@ impl SnapshotCommands { } } +fn validate_tipset_lookup_hamt( + store: &impl Blockstore, + hamt_root: Cid, + head: Tipset, +) -> anyhow::Result<()> { + let hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::load_with_bit_width(&hamt_root, &store, TIPSET_LOOKUP_HAMT_BIT_WIDTH) + .context("failed to load tipset lookup HAMT")?; + hamt.for_each_cacheless(|&epoch, _| { + anyhow::ensure!( + ChainIndex::is_tipset_lookup_checkpoint(epoch), + "tipset lookup table has non checkpoint entries for epoch {epoch}" + ); + Ok(()) + })?; + println!("Verifying lookup checkpoints match the snapshot chain..."); + let mut n_checkpoints = 0; + let mut n_null_checkpoints = 0; + let mut null_checkpoint_search_from_ts = head.shallow_clone(); + for checkpoint in (0..=head.epoch()) + .filter(|&epoch| ChainIndex::is_tipset_lookup_checkpoint(epoch)) + .rev() + { + if let Some(tsk) = hamt.get(&checkpoint)? { + n_checkpoints += 1; + let ts = Tipset::load_required(&store, tsk).with_context(|| { + format!("failed to lookup tipset at checkpoint, epoch: {checkpoint}, key: {tsk}") + })?; + anyhow::ensure!( + ts.epoch() == checkpoint, + "tipset mismatch, checkpoint epoch: {checkpoint}, tipset epoch: {}, tipset key: {tsk}", + ts.epoch() + ); + null_checkpoint_search_from_ts = ts; + } else { + n_null_checkpoints += 1; + // verify checkpoint epoch is null + let checkpoint_ts = null_checkpoint_search_from_ts + .shallow_clone() + .chain(&store) + .take_while(|ts| ts.epoch() >= checkpoint) + .find(|ts| ts.epoch() == checkpoint); + if let Some(checkpoint_ts) = checkpoint_ts { + anyhow::bail!( + "checkpoint epoch {} with key {} is missing from tipset lookup snapshot", + checkpoint_ts.epoch(), + checkpoint_ts.key() + ); + } + } + } + println!( + "Tipset lookup snapshot is valid, {n_checkpoints} checkpoints and {n_null_checkpoints} null checkpoints validated" + ); + Ok(()) +} + // Check the validity of a snapshot by looking at IPLD links, the genesis block, // and message output. More checks may be added in the future. // diff --git a/src/tool/subcommands/snapshot_cmd/tests.rs b/src/tool/subcommands/snapshot_cmd/tests.rs new file mode 100644 index 000000000000..fbc62f771c12 --- /dev/null +++ b/src/tool/subcommands/snapshot_cmd/tests.rs @@ -0,0 +1,106 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use super::*; +use crate::chain::index::tests::{genesis_tipset, persist_tipset, tipset_child}; + +#[test] +fn test_validate_tipset_lookup_hamt_success() { + let db = Arc::new(MemoryDB::default()); + let mut hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::new_with_bit_width(db.shallow_clone(), TIPSET_LOOKUP_HAMT_BIT_WIDTH); + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + // Epoch 20 is a null round: 19 is followed directly by 21. + let mut prev = genesis.shallow_clone(); + for epoch in [10, 19, 21, 30, 40, 41] { + let ts = tipset_child(&prev, epoch); + if ChainIndex::is_tipset_lookup_checkpoint(epoch) { + hamt.set(epoch, ts.key().clone()).unwrap(); + } + persist_tipset(&ts, &db); + prev = ts; + } + let head = prev; + let hamt_root = hamt.flush().unwrap(); + validate_tipset_lookup_hamt(&db, hamt_root, head).unwrap(); +} + +#[test] +fn test_validate_tipset_lookup_hamt_non_checkpoint_entry() { + let db = Arc::new(MemoryDB::default()); + let mut hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::new_with_bit_width(db.shallow_clone(), TIPSET_LOOKUP_HAMT_BIT_WIDTH); + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + // Epoch 20 is a null round: 19 is followed directly by 21. + let mut prev = genesis.shallow_clone(); + for epoch in [10, 19, 21, 30, 40, 41] { + let ts = tipset_child(&prev, epoch); + hamt.set(epoch, ts.key().clone()).unwrap(); + persist_tipset(&ts, &db); + prev = ts; + } + let head = prev; + let hamt_root = hamt.flush().unwrap(); + validate_tipset_lookup_hamt(&db, hamt_root, head).unwrap_err(); +} + +#[test] +fn test_validate_tipset_lookup_hamt_missing_checkpoint_entry() { + let db = Arc::new(MemoryDB::default()); + let mut hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::new_with_bit_width(db.shallow_clone(), TIPSET_LOOKUP_HAMT_BIT_WIDTH); + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + // Epoch 20 is a null round: 19 is followed directly by 21. + let mut prev = genesis.shallow_clone(); + for epoch in [10, 19, 21, 30, 40, 41] { + let ts = tipset_child(&prev, epoch); + persist_tipset(&ts, &db); + prev = ts; + } + let head = prev; + let hamt_root = hamt.flush().unwrap(); + validate_tipset_lookup_hamt(&db, hamt_root, head).unwrap_err(); +} + +#[test] +fn test_validate_tipset_lookup_hamt_bad_checkpoint_entry() { + let db = Arc::new(MemoryDB::default()); + let mut hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::new_with_bit_width(db.shallow_clone(), TIPSET_LOOKUP_HAMT_BIT_WIDTH); + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + hamt.set(40, genesis.key().clone()).unwrap(); + // Epoch 20 is a null round: 19 is followed directly by 21. + let mut prev = genesis.shallow_clone(); + for epoch in [10, 19, 21, 30, 40, 41] { + let ts = tipset_child(&prev, epoch); + persist_tipset(&ts, &db); + prev = ts; + } + let head = prev; + let hamt_root = hamt.flush().unwrap(); + validate_tipset_lookup_hamt(&db, hamt_root, head).unwrap_err(); +} + +#[test] +fn test_validate_tipset_lookup_hamt_null_checkpoint_entry() { + let db = Arc::new(MemoryDB::default()); + let mut hamt: Hamt<_, TipsetKey, ChainEpoch> = + Hamt::new_with_bit_width(db.shallow_clone(), TIPSET_LOOKUP_HAMT_BIT_WIDTH); + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + hamt.set(20, genesis.key().clone()).unwrap(); + // Epoch 20 is a null round: 19 is followed directly by 21. + let mut prev = genesis.shallow_clone(); + for epoch in [10, 19, 21, 30, 40, 41] { + let ts = tipset_child(&prev, epoch); + persist_tipset(&ts, &db); + prev = ts; + } + let head = prev; + let hamt_root = hamt.flush().unwrap(); + validate_tipset_lookup_hamt(&db, hamt_root, head).unwrap_err(); +} From b3b0ee9eaa3eab260a4090318fccf3a8c9298adc Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 15:54:17 +0800 Subject: [PATCH 10/14] resolve CC feedbacks --- scripts/tests/calibnet_export_check.sh | 2 +- src/chain/mod.rs | 49 +++++++++++-------- src/cli/subcommands/snapshot_cmd.rs | 4 +- src/rpc/methods/chain.rs | 24 +++++---- .../forest__rpc__tests__rpc__v0.snap | 6 ++- .../forest__rpc__tests__rpc__v1.snap | 6 ++- .../forest__rpc__tests__rpc__v2.snap | 6 ++- src/shim/executor.rs | 4 +- 8 files changed, 60 insertions(+), 41 deletions(-) diff --git a/scripts/tests/calibnet_export_check.sh b/scripts/tests/calibnet_export_check.sh index 56426886b7da..d85ff3fc1e9a 100755 --- a/scripts/tests/calibnet_export_check.sh +++ b/scripts/tests/calibnet_export_check.sh @@ -97,7 +97,7 @@ ls -ahl *.forest.car.zst # export and check augmented data once we have receipts and events tipset published and imported # for now there's no receipts and events on a freshly bootstrapped node. $FOREST_TOOL_PATH snapshot validate-extended --base snapshot.forest.car.zst --tipset-lookup snapshot_tipset_lookup.forest.car.zst -# Remove tipset lookup snapshots before proceeding +# Remove tipset lookup snapshots before proceeding rm *_tipset_lookup.forest.car.zst for f in *.car.zst; do diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 3cb7d353f2d7..e37a5b254a8c 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -275,30 +275,36 @@ pub async fn export_receipts_events_to_forest_car( ); let min_lookup_epoch_exclusive = tipset.epoch() - lookup_depth; - let mut ipld_roots = vec![]; - for ts in tipset - .shallow_clone() - .chain(db) - .take_while(|ts| ts.epoch() > min_lookup_epoch_exclusive) - { - let message_receipts_root = *ts.parent_message_receipts(); - ipld_roots.push(message_receipts_root); - let receipts = Receipt::get_receipts(db, message_receipts_root).with_context(|| { - format!( - "failed to get receipts, root: {message_receipts_root}, epoch: {}, tipset key: {}", - ts.epoch(), - ts.key(), - ) - })?; - ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root())); - } + let ipld_roots = tokio::task::spawn_blocking({ + let tipset = tipset.shallow_clone(); + let db = db.shallow_clone(); + move || { + let mut ipld_roots = vec![]; + for ts in tipset + .chain(&db) + .take_while(|ts| ts.epoch() > min_lookup_epoch_exclusive) + { + let message_receipts_root = *ts.parent_message_receipts(); + ipld_roots.push(message_receipts_root); + let receipts = Receipt::get_receipts(&db, message_receipts_root).with_context(|| { + format!( + "failed to get receipts, root: {message_receipts_root}, epoch: {}, tipset key: {}", + ts.epoch(), + ts.key(), + ) + })?; + ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root())); + } + anyhow::Ok(ipld_roots) + } + }) + .await??; let stream = IpldStream::new(db.shallow_clone(), ipld_roots, CidHashSet::default()); let mut writer = BufWriter::new(writer); - // Stream stateroots in range (stateroot_lookup_limit+1)..=tipset.epoch(). Also - // stream all block headers until genesis. + // Stream message receipts and events in range (stateroot_lookup_limit+1)..=tipset.epoch(). let (blocks, _drop_guard) = par_buffer( // Queue 1k blocks. This is enough to saturate the compressor and blocks // are small enough that keeping 1k in memory isn't a problem. Average @@ -309,7 +315,10 @@ pub async fn export_receipts_events_to_forest_car( // Encode Ipld key-value pairs in zstd frames let block_frames = forest::Encoder::compress_stream_default(blocks); - // There's no data root for this snapshot, use a default CID as placeholder + // There's no data root for this snapshot, use a default CID as placeholder. + // Note that the output CAR could only be validated with `forest-tool snapshot validate-extended`. + // Another option could be including chain spine in the CAR and use head tipset key as root, + // whose downside would be bloating the CAR. let roots = nunny::vec![Cid::default()]; // Write zstd frames and include a skippable index diff --git a/src/cli/subcommands/snapshot_cmd.rs b/src/cli/subcommands/snapshot_cmd.rs index b7cb9094ef2e..38389813286c 100644 --- a/src/cli/subcommands/snapshot_cmd.rs +++ b/src/cli/subcommands/snapshot_cmd.rs @@ -90,7 +90,7 @@ impl SnapshotCommands { tipset, depth, format, - augmented_data, + augmented_data: augmented_snapshot, tipset_lookup, } => { anyhow::ensure!( @@ -149,7 +149,7 @@ impl SnapshotCommands { include_receipts: false, include_events: false, include_tipset_keys: false, - augmented_data, + augmented_snapshot, tipset_lookup, skip_checksum, dry_run, diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index d41e005aebdd..2ccd89270ed0 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -320,7 +320,7 @@ impl RpcMethod<1> for ForestChainExport { include_receipts, include_events, include_tipset_keys, - augmented_data, + augmented_snapshot, tipset_lookup, skip_checksum, dry_run, @@ -455,18 +455,18 @@ impl RpcMethod<1> for ForestChainExport { } _ => {} } - if augmented_data { + if augmented_snapshot { // It takes <10s on mainnet so export it in sequence for simplicity. // Some stats: // calibnet 3895470+2000: 5s 5.4MiB // mainnet 6193120+2000: 5s 12MiB - let augmented_data_output_path = + let augmented_snapshot_output_path = forest_car_with_filename_suffix(&output_path, "_receipts_events")?; - let (writer, augmented_data_output_tmp_path) = if dry_run { + let (writer, augmented_snapshot_output_tmp_path) = if dry_run { (tokio_util::either::Either::Left(VoidAsyncWriter), None) } else { let tmp_path = tempfile::TempPath::try_from_path( - tmp_exporting_forest_car_path(&augmented_data_output_path), + tmp_exporting_forest_car_path(&augmented_snapshot_output_path), )?; ( tokio_util::either::Either::Right( @@ -483,9 +483,11 @@ impl RpcMethod<1> for ForestChainExport { ) .await .context("failed to export message receipts and events snapshot")?; - if let Some(augmented_data_output_tmp_path) = augmented_data_output_tmp_path + if let Some(augmented_snapshot_output_tmp_path) = + augmented_snapshot_output_tmp_path { - augmented_data_output_tmp_path.persist(augmented_data_output_path)?; + augmented_snapshot_output_tmp_path + .persist(augmented_snapshot_output_path)?; } } chain_export_guard.mark_as_succeeded(); @@ -686,7 +688,7 @@ impl RpcMethod<1> for ChainExport { include_receipts: false, include_events: false, include_tipset_keys: false, - augmented_data: false, + augmented_snapshot: false, tipset_lookup: false, skip_checksum, dry_run, @@ -1611,15 +1613,17 @@ pub struct ForestChainExportParams { #[schemars(with = "LotusJson")] #[serde(with = "crate::lotus_json", default)] pub tipset_keys: ApiTipsetKey, + /// Include message receipts in the output snapshot #[serde(default)] pub include_receipts: bool, + /// Include events in the output snapshot #[serde(default)] pub include_events: bool, #[serde(default)] pub include_tipset_keys: bool, - /// Generate a separate snapshot that contains augmented data + /// Generate a separate snapshot that contains augmented data (message receipts and events) #[serde(default)] - pub augmented_data: bool, + pub augmented_snapshot: bool, /// Generate a separate snapshot that contains tipset lookup #[serde(default)] pub tipset_lookup: bool, diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 10f1f7afccec..762e9c519f1a 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -6381,8 +6381,8 @@ components: ForestChainExportParams: type: object properties: - augmented_data: - description: Generate a separate snapshot that contains augmented data + augmented_snapshot: + description: Generate a separate snapshot that contains augmented data (message receipts and events) type: boolean default: false dry_run: @@ -6391,9 +6391,11 @@ components: type: integer format: int64 include_events: + description: Include events in the output snapshot type: boolean default: false include_receipts: + description: Include message receipts in the output snapshot type: boolean default: false include_tipset_keys: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index e9e76b8d993d..95ce13c39b7f 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -6496,8 +6496,8 @@ components: ForestChainExportParams: type: object properties: - augmented_data: - description: Generate a separate snapshot that contains augmented data + augmented_snapshot: + description: Generate a separate snapshot that contains augmented data (message receipts and events) type: boolean default: false dry_run: @@ -6506,9 +6506,11 @@ components: type: integer format: int64 include_events: + description: Include events in the output snapshot type: boolean default: false include_receipts: + description: Include message receipts in the output snapshot type: boolean default: false include_tipset_keys: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index 52da75c3fadb..ef8d3f1bee2b 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -2649,8 +2649,8 @@ components: ForestChainExportParams: type: object properties: - augmented_data: - description: Generate a separate snapshot that contains augmented data + augmented_snapshot: + description: Generate a separate snapshot that contains augmented data (message receipts and events) type: boolean default: false dry_run: @@ -2659,9 +2659,11 @@ components: type: integer format: int64 include_events: + description: Include events in the output snapshot type: boolean default: false include_receipts: + description: Include message receipts in the output snapshot type: boolean default: false include_tipset_keys: diff --git a/src/shim/executor.rs b/src/shim/executor.rs index af8466fd2610..549a8e5225f6 100644 --- a/src/shim/executor.rs +++ b/src/shim/executor.rs @@ -153,14 +153,14 @@ impl Receipt { // Try Receipt_v4 first. (Receipt_v4 and Receipt_v3 are identical, use v4 here) if let Ok(amt) = Amtv0::::load(&receipts_cid, db) { - amt.for_each(|_, receipt| { + amt.for_each_cacheless(|_, receipt| { receipts.push(Receipt::V4(receipt.clone())); Ok(()) })?; } else { // Fallback to Receipt_v2. let amt = Amtv0::::load(&receipts_cid, db)?; - amt.for_each(|_, receipt| { + amt.for_each_cacheless(|_, receipt| { receipts.push(Receipt::V2(receipt.clone())); Ok(()) })?; From 3f53318b74fe3ee7b3798c6ad9cfa08567791ca2 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 16:12:08 +0800 Subject: [PATCH 11/14] checksums --- scripts/tests/calibnet_export_check.sh | 9 +++++---- src/rpc/methods/chain.rs | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/tests/calibnet_export_check.sh b/scripts/tests/calibnet_export_check.sh index d85ff3fc1e9a..d486542ae408 100755 --- a/scripts/tests/calibnet_export_check.sh +++ b/scripts/tests/calibnet_export_check.sh @@ -93,12 +93,16 @@ $FOREST_CLI_PATH shutdown --force # Check file sizes ls -ahl *.forest.car.zst + +echo "Verifying snapshot checksum" +sha256sum --check ./*.sha256sum + # Validate tipset lookup snapshots # export and check augmented data once we have receipts and events tipset published and imported # for now there's no receipts and events on a freshly bootstrapped node. $FOREST_TOOL_PATH snapshot validate-extended --base snapshot.forest.car.zst --tipset-lookup snapshot_tipset_lookup.forest.car.zst # Remove tipset lookup snapshots before proceeding -rm *_tipset_lookup.forest.car.zst +rm *_tipset_lookup.forest.car.zst* for f in *.car.zst; do echo "Inspecting archive info $f" @@ -110,9 +114,6 @@ done echo "Testing snapshot validity" zstd --test ./*.car.zst -echo "Verifying snapshot checksum" -sha256sum --check ./*.sha256sum - for f in *.car.zst; do echo "Validating CAR file $f" $FOREST_TOOL_PATH snapshot validate "$f" diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 2ccd89270ed0..8bd5f9633767 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -35,6 +35,7 @@ use crate::utils::io::VoidAsyncWriter; use crate::utils::misc::env::is_env_truthy; use crate::utils::spawn_blocking_with_timeout; use anyhow::{Context as _, Result}; +use digest::Digest as _; use enumflags2::{BitFlags, make_bitflags}; use fvm_ipld_encoding::{CborStore, RawBytes}; use hex::ToHex; @@ -442,7 +443,14 @@ impl RpcMethod<1> for ForestChainExport { .await .context("failed to write tipset lookup snapshot")?; if let Some(hamt_output_tmp_path) = hamt_output_tmp_path { - hamt_output_tmp_path.persist(hamt_output_path)?; + hamt_output_tmp_path.persist(&hamt_output_path)?; + if !skip_checksum { + // No need to generate checksum on the fly for small snapshots + save_checksum( + Sha256::digest(std::fs::read(&hamt_output_path)?), + &hamt_output_path, + )?; + } } } (true, None) => { @@ -487,7 +495,14 @@ impl RpcMethod<1> for ForestChainExport { augmented_snapshot_output_tmp_path { augmented_snapshot_output_tmp_path - .persist(augmented_snapshot_output_path)?; + .persist(&augmented_snapshot_output_path)?; + if !skip_checksum { + // No need to generate checksum on the fly for small snapshots + save_checksum( + Sha256::digest(std::fs::read(&augmented_snapshot_output_path)?), + &augmented_snapshot_output_path, + )?; + } } } chain_export_guard.mark_as_succeeded(); From 84f01f28d470c6845a06d8596577e4b3550eb7cc Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 16:38:23 +0800 Subject: [PATCH 12/14] fix comments --- src/chain/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index e37a5b254a8c..d49d96955033 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -301,17 +301,13 @@ pub async fn export_receipts_events_to_forest_car( .await??; let stream = IpldStream::new(db.shallow_clone(), ipld_roots, CidHashSet::default()); - let mut writer = BufWriter::new(writer); - - // Stream message receipts and events in range (stateroot_lookup_limit+1)..=tipset.epoch(). let (blocks, _drop_guard) = par_buffer( // Queue 1k blocks. This is enough to saturate the compressor and blocks // are small enough that keeping 1k in memory isn't a problem. Average // block size is between 1kb and 2kb. 1024, stream, ); - // Encode Ipld key-value pairs in zstd frames let block_frames = forest::Encoder::compress_stream_default(blocks); From 9394f1c0018e1d866b652e23ab46fec63eae99a4 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 20:04:58 +0800 Subject: [PATCH 13/14] change `--augmented-data` to `--augmented-snapshot` --- CHANGELOG.md | 2 +- src/cli/subcommands/snapshot_cmd.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4212e0dd8df6..efaa4247c610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ ### Added -- [#7376](https://github.com/ChainSafe/forest/pull/7376): Added `--augmented-data` and `--tipset-lookup` to `forest-cli snapshot export`. +- [#7376](https://github.com/ChainSafe/forest/pull/7376): Added `--augmented-snapshot` and `--tipset-lookup` to `forest-cli snapshot export`. ### Changed diff --git a/src/cli/subcommands/snapshot_cmd.rs b/src/cli/subcommands/snapshot_cmd.rs index 38389813286c..52bae165982c 100644 --- a/src/cli/subcommands/snapshot_cmd.rs +++ b/src/cli/subcommands/snapshot_cmd.rs @@ -47,7 +47,7 @@ pub enum SnapshotCommands { format: FilecoinSnapshotVersion, /// Also exports an augmented data snapshot that contains message receipts and events #[arg(long)] - augmented_data: bool, + augmented_snapshot: bool, /// Also exports a tipset lookup HAMT snapshot #[arg(long)] tipset_lookup: bool, @@ -90,7 +90,7 @@ impl SnapshotCommands { tipset, depth, format, - augmented_data: augmented_snapshot, + augmented_snapshot, tipset_lookup, } => { anyhow::ensure!( From 78bdd906083881de249a1bb3b2699a2c24eaa1ff Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Tue, 21 Jul 2026 20:26:59 +0800 Subject: [PATCH 14/14] comment on estimated ipld roots capacity --- src/chain/mod.rs | 1 + src/tool/subcommands/snapshot_cmd.rs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index d49d96955033..cf30efeb91cf 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -279,6 +279,7 @@ pub async fn export_receipts_events_to_forest_car( let tipset = tipset.shallow_clone(); let db = db.shallow_clone(); move || { + // With 2k state trees on mainnet, it's `~2k receipt roots` + `~8k event roots` ~= `~10k ipld roots`. let mut ipld_roots = vec![]; for ts in tipset .chain(&db) diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs index 3bb52f44c3ee..733dd122a15a 100644 --- a/src/tool/subcommands/snapshot_cmd.rs +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -241,6 +241,7 @@ impl SnapshotCommands { println!("Verifying message receipts and events can be loaded..."); let head = store.heaviest_tipset()?; let mut n_ts = 0; + let mut n_events = 0; for ts in head.shallow_clone().chain(&store) { // Validate only when state trees present if !store.has(ts.parent_state())? { @@ -257,6 +258,7 @@ impl SnapshotCommands { })?; for r in receipts { if let Some(events_root) = r.events_root() { + n_events += 1; StampedEvent::get_events(&store, &events_root).with_context(|| { format!( "failed to load events, root: {events_root}, epoch: {}, tipset key: {}", @@ -267,7 +269,9 @@ impl SnapshotCommands { } } } - println!("Augmented snapshot is valid, {n_ts} tipsets validated"); + println!( + "Augmented snapshot is valid, {n_ts} tipsets with {n_events} events validated" + ); } if let Some(tipset_lookup) = &tipset_lookup { println!("Importing tipset lookup snapshot...");