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