Skip to main content

forest/chain/
mod.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4pub mod ec_finality;
5mod snapshot_format;
6pub mod store;
7#[cfg(test)]
8mod tests;
9mod weight;
10
11pub use self::{snapshot_format::*, store::*, weight::*};
12
13use crate::blocks::{Tipset, TipsetKey};
14use crate::chain::index::ChainIndex;
15use crate::cid_collections::{CidHashSet, CidHashSetLike};
16use crate::db::IndexMapBlockstore;
17use crate::db::car::forest::{self, ForestCarFrame, finalize_frame};
18use crate::ipld::{IpldStream, stream_chain};
19use crate::prelude::*;
20use crate::shim::executor::Receipt;
21use crate::utils::db::car_stream::{CarBlock, CarBlockWrite};
22use crate::utils::io::{AsyncWriterWithChecksum, Checksum};
23use crate::utils::multihash::MultihashCode;
24use crate::utils::stream::par_buffer;
25use fil_actors_shared::fvm_ipld_hamt::Hamt;
26use futures::StreamExt as _;
27use fvm_ipld_encoding::DAG_CBOR;
28use multihash_derive::MultihashDigest as _;
29use nunny::Vec as NonEmpty;
30use sha2::digest::{self, Digest};
31use std::io::{Read, Seek, SeekFrom};
32use std::time::Instant;
33use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter};
34use tokio_util::task::AbortOnDropHandle;
35
36pub const TIPSET_LOOKUP_HAMT_BIT_WIDTH: u32 = 5;
37
38pub struct ExportOptions<S> {
39    pub skip_checksum: bool,
40    pub include_receipts: bool,
41    pub include_events: bool,
42    pub include_tipset_keys: bool,
43    pub include_tipset_lookup: bool,
44    pub seen: S,
45}
46
47impl<S: Default> Default for ExportOptions<S> {
48    fn default() -> Self {
49        Self {
50            skip_checksum: Default::default(),
51            include_receipts: Default::default(),
52            include_events: Default::default(),
53            include_tipset_keys: Default::default(),
54            include_tipset_lookup: Default::default(),
55            seen: Default::default(),
56        }
57    }
58}
59
60pub struct ExportResult<D: Digest> {
61    pub checksum: Option<digest::Output<D>>,
62    #[allow(dead_code)]
63    pub tipset_lookup: Option<anyhow::Result<Hamt<IndexMapBlockstore, TipsetKey, ChainEpoch>>>,
64}
65
66/// Exports a Filecoin snapshot in v1 format
67/// See <https://github.com/filecoin-project/FIPs/blob/98e33b9fa306959aa0131519eb4cc155522b2081/FRCs/frc-0108.md#v1-specification>
68pub async fn export<D: Digest, S: CidHashSetLike + Send + Sync + 'static>(
69    db: &(impl Blockstore + ShallowClone + Unpin + Send + Sync + 'static),
70    tipset: &Tipset,
71    lookup_depth: ChainEpochDelta,
72    writer: impl AsyncWrite + Unpin,
73    options: ExportOptions<S>,
74) -> anyhow::Result<ExportResult<D>> {
75    let roots = tipset.key().to_cids();
76    export_to_forest_car::<D, S>(roots, None, db, tipset, lookup_depth, writer, options).await
77}
78
79/// Exports a Filecoin snapshot in v2 format
80/// See <https://github.com/filecoin-project/FIPs/blob/98e33b9fa306959aa0131519eb4cc155522b2081/FRCs/frc-0108.md#v2-specification>
81pub async fn export_v2<D: Digest, F: Seek + Read, S: CidHashSetLike + Send + Sync + 'static>(
82    db: &(impl Blockstore + ShallowClone + Unpin + Send + Sync + 'static),
83    mut f3: Option<(Cid, F)>,
84    tipset: &Tipset,
85    lookup_depth: ChainEpochDelta,
86    writer: impl AsyncWrite + Unpin,
87    options: ExportOptions<S>,
88) -> anyhow::Result<ExportResult<D>> {
89    // validate f3 data
90    if let Some((f3_cid, f3_data)) = &mut f3 {
91        f3_data.seek(SeekFrom::Start(0))?;
92        let expected_cid = crate::f3::snapshot::get_f3_snapshot_cid(f3_data)?;
93        anyhow::ensure!(
94            f3_cid == &expected_cid,
95            "f3 snapshot integrity check failed, actual cid: {f3_cid}, expected cid: {expected_cid}"
96        );
97    }
98
99    let head = tipset.key().to_cids();
100    let f3_cid = f3.as_ref().map(|(cid, _)| *cid);
101    let snap_meta = FilecoinSnapshotMetadata::new_v2(head, f3_cid);
102    let snap_meta_cbor_encoded = fvm_ipld_encoding::to_vec(&snap_meta)?;
103    let snap_meta_block = CarBlock {
104        cid: Cid::new_v1(
105            DAG_CBOR,
106            MultihashCode::Blake2b256.digest(&snap_meta_cbor_encoded),
107        ),
108        data: snap_meta_cbor_encoded.into(),
109    };
110    let roots = nunny::vec![snap_meta_block.cid];
111    let mut prefix_data_frames = vec![{
112        let mut encoder = forest::new_encoder(forest::DEFAULT_FOREST_CAR_COMPRESSION_LEVEL)?;
113        snap_meta_block.write(&mut encoder)?;
114        anyhow::Ok((
115            vec![snap_meta_block.cid],
116            finalize_frame(forest::DEFAULT_FOREST_CAR_COMPRESSION_LEVEL, &mut encoder)?,
117        ))
118    }];
119
120    if let Some((f3_cid, mut f3_data)) = f3 {
121        let f3_data_len = f3_data.seek(SeekFrom::End(0))?;
122        f3_data.seek(SeekFrom::Start(0))?;
123        prefix_data_frames.push({
124            let mut encoder = forest::new_encoder(forest::DEFAULT_FOREST_CAR_COMPRESSION_LEVEL)?;
125            encoder.write_car_block(f3_cid, f3_data_len, &mut f3_data)?;
126            anyhow::Ok((
127                vec![f3_cid],
128                finalize_frame(forest::DEFAULT_FOREST_CAR_COMPRESSION_LEVEL, &mut encoder)?,
129            ))
130        });
131    }
132
133    export_to_forest_car::<D, S>(
134        roots,
135        Some(prefix_data_frames),
136        db,
137        tipset,
138        lookup_depth,
139        writer,
140        options,
141    )
142    .await
143}
144
145#[allow(clippy::too_many_arguments)]
146async fn export_to_forest_car<D: Digest, S: CidHashSetLike + Send + Sync + 'static>(
147    roots: NonEmpty<Cid>,
148    prefix_data_frames: Option<Vec<anyhow::Result<ForestCarFrame>>>,
149    db: &(impl Blockstore + ShallowClone + Unpin + Send + Sync + 'static),
150    tipset: &Tipset,
151    lookup_depth: ChainEpochDelta,
152    writer: impl AsyncWrite + Unpin,
153    ExportOptions {
154        skip_checksum,
155        include_receipts,
156        include_events,
157        include_tipset_keys,
158        include_tipset_lookup,
159        seen,
160    }: ExportOptions<S>,
161) -> anyhow::Result<ExportResult<D>> {
162    if include_events && !include_receipts {
163        anyhow::bail!("message receipts must be included when events are included");
164    }
165
166    let start = Instant::now();
167    tracing::info!(
168        "Exporting snapshot, epoch={}, depth={lookup_depth}, prefix_frames={}",
169        tipset.epoch(),
170        prefix_data_frames.as_ref().map(|v| v.len()).unwrap_or(0)
171    );
172
173    let stateroot_lookup_limit = tipset.epoch() - lookup_depth;
174
175    // Wrap writer in optional checksum calculator
176    let mut writer = AsyncWriterWithChecksum::<D, _>::new(BufWriter::new(writer), !skip_checksum);
177
178    let (ts_lookup_tx, ts_lookup_handle) = if include_tipset_lookup {
179        let (ts_lookup_tx, ts_lookup_rx) = flume::bounded::<(ChainEpoch, TipsetKey)>(1024);
180        let handle = AbortOnDropHandle::new(tokio::spawn(async move {
181            let mut hamt = Hamt::new_with_bit_width(
182                IndexMapBlockstore::default(),
183                TIPSET_LOOKUP_HAMT_BIT_WIDTH,
184            );
185            while let Ok((epoch, tsk)) = ts_lookup_rx.recv_async().await {
186                hamt.set(epoch, tsk)?;
187            }
188            hamt.flush()?;
189            anyhow::Ok(hamt)
190        }));
191        (Some(ts_lookup_tx), Some(handle))
192    } else {
193        (None, None)
194    };
195
196    // Stream stateroots in range (stateroot_lookup_limit+1)..=tipset.epoch(). Also
197    // stream all block headers until genesis.
198    let (blocks, _drop_guard) = par_buffer(
199        // Queue 1k blocks. This is enough to saturate the compressor and blocks
200        // are small enough that keeping 1k in memory isn't a problem. Average
201        // block size is between 1kb and 2kb.
202        1024,
203        stream_chain(
204            db.shallow_clone(),
205            tipset
206                .shallow_clone()
207                .chain_owned(db.shallow_clone())
208                .inspect(move |ts| {
209                    if let Some(ts_lookup_tx) = &ts_lookup_tx
210                        && ChainIndex::is_tipset_lookup_checkpoint(ts.epoch())
211                    {
212                        _ = ts_lookup_tx.send((ts.epoch(), ts.key().clone()));
213                    }
214                }),
215            stateroot_lookup_limit,
216            seen,
217        )
218        .with_message_receipts(include_receipts)
219        .with_events(include_events)
220        .with_tipset_keys(include_tipset_keys)
221        .track_progress(true),
222    );
223
224    // Encode Ipld key-value pairs in zstd frames
225    let block_frames = forest::Encoder::compress_stream_default(blocks);
226    let frames = futures::stream::iter(prefix_data_frames.unwrap_or_default()).chain(block_frames);
227
228    // Write zstd frames and include a skippable index
229    forest::Encoder::write(&mut writer, roots, frames).await?;
230
231    // Flush to ensure everything has been successfully written
232    tokio::time::timeout(forest::ASYNC_OPS_TIMEOUT, writer.flush())
233        .await
234        .context("`writer.flush` timed out")??;
235
236    let digest = writer.finalize().map_err(|e| Error::Other(e.to_string()))?;
237
238    let tipset_lookup = if let Some(ts_lookup_handle) = ts_lookup_handle {
239        // This join is not I/O-bound: the task finishes once the `par_buffer` producer
240        // exits and drops `ts_lookup_tx`, which `Encoder::write` guarantees by exhausting
241        // the frame stream. The timeout guards against a pipeline lifecycle bug keeping a
242        // sender alive, not against slowness.
243        Some(
244            tokio::time::timeout(forest::ASYNC_OPS_TIMEOUT, ts_lookup_handle)
245                .await
246                .context(
247                    "tipset-lookup task did not finish; is a `ts_lookup_tx` sender still alive?",
248                )??,
249        )
250    } else {
251        None
252    };
253
254    tracing::info!(
255        "Exported snapshot, took {}",
256        humantime::format_duration(start.elapsed())
257    );
258
259    Ok(ExportResult {
260        checksum: digest,
261        tipset_lookup,
262    })
263}
264
265pub async fn export_receipts_events_to_forest_car(
266    db: &(impl Blockstore + ShallowClone + Unpin + Send + Sync + 'static),
267    tipset: &Tipset,
268    lookup_depth: ChainEpochDelta,
269    writer: impl AsyncWrite + Unpin,
270) -> anyhow::Result<()> {
271    let start = Instant::now();
272    tracing::info!(
273        "Exporting message receipts and events snapshot, epoch={}, depth={lookup_depth}",
274        tipset.epoch(),
275    );
276
277    let min_lookup_epoch_exclusive = tipset.epoch() - lookup_depth;
278    let ipld_roots = tokio::task::spawn_blocking({
279        let tipset = tipset.shallow_clone();
280        let db = db.shallow_clone();
281        move || {
282            // With 2k state trees on mainnet, it's `~2k receipt roots` + `~8k event roots` ~= `~10k ipld roots`.
283            let mut ipld_roots = vec![];
284            for ts in tipset
285                .chain(&db)
286                .take_while(|ts| ts.epoch() > min_lookup_epoch_exclusive)
287            {
288                let message_receipts_root = *ts.parent_message_receipts();
289                ipld_roots.push(message_receipts_root);
290                let receipts = Receipt::get_receipts(&db, message_receipts_root).with_context(|| {
291                    format!(
292                        "failed to get receipts, root: {message_receipts_root}, epoch: {}, tipset key: {}",
293                        ts.epoch(),
294                        ts.key(),
295                    )
296                })?;
297                ipld_roots.extend(receipts.into_iter().filter_map(|r| r.events_root()));
298            }
299            anyhow::Ok(ipld_roots)
300        }
301    })
302    .await??;
303
304    let stream = IpldStream::new(db.shallow_clone(), ipld_roots, CidHashSet::default());
305    let mut writer = BufWriter::new(writer);
306    let (blocks, _drop_guard) = par_buffer(
307        // Queue 1k blocks. This is enough to saturate the compressor and blocks
308        // are small enough that keeping 1k in memory isn't a problem. Average
309        // block size is between 1kb and 2kb.
310        1024, stream,
311    );
312    // Encode Ipld key-value pairs in zstd frames
313    let block_frames = forest::Encoder::compress_stream_default(blocks);
314
315    // There's no data root for this snapshot, use a default CID as placeholder.
316    // Note that the output CAR could only be validated with `forest-tool snapshot validate-extended`.
317    // Another option could be including chain spine in the CAR and use head tipset key as root,
318    // whose downside would be bloating the CAR.
319    let roots = nunny::vec![Cid::default()];
320
321    // Write zstd frames and include a skippable index
322    forest::Encoder::write(&mut writer, roots, block_frames).await?;
323
324    // Flush to ensure everything has been successfully written
325    tokio::time::timeout(forest::ASYNC_OPS_TIMEOUT, writer.flush())
326        .await
327        .context("`writer.flush` timed out")??;
328
329    tracing::info!(
330        "Exported message receipts and events snapshot, took {}",
331        humantime::format_duration(start.elapsed())
332    );
333
334    Ok(())
335}