Skip to main content

forest/cli/subcommands/
snapshot_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use crate::chain::FilecoinSnapshotVersion;
5use crate::chain_sync::chain_muxer::DEFAULT_RECENT_STATE_ROOTS;
6use crate::cli_shared::snapshot::{self, TrustedVendor};
7use crate::db::car::forest::tmp_exporting_forest_car_path;
8use crate::ipld::ChainExportState;
9use crate::networks::calibnet;
10use crate::prelude::*;
11use crate::rpc::chain::ForestChainExportDiffParams;
12use crate::rpc::types::ApiExportResult;
13use crate::rpc::{self, chain::ForestChainExportParams, prelude::*};
14use crate::shim::policy::policy_constants::CHAIN_FINALITY;
15use chrono::DateTime;
16use clap::Subcommand;
17use indicatif::{ProgressBar, ProgressStyle};
18use std::{path::PathBuf, time::Duration};
19use tokio_util::sync::CancellationToken;
20
21#[derive(Debug, Clone, clap::ValueEnum)]
22pub enum Format {
23    Json,
24    Text,
25}
26
27#[derive(Debug, Subcommand)]
28pub enum SnapshotCommands {
29    /// Export a snapshot of the chain to `<output_path>`
30    Export {
31        /// `./forest_snapshot_{chain}_{year}-{month}-{day}_height_{epoch}.car.zst`.
32        #[arg(short, long, default_value = ".", verbatim_doc_comment)]
33        output_path: PathBuf,
34        /// Skip creating the checksum file.
35        #[arg(long)]
36        skip_checksum: bool,
37        /// Don't write the archive.
38        #[arg(long)]
39        dry_run: bool,
40        /// Tipset to start the export from, default is the chain head
41        #[arg(short, long)]
42        tipset: Option<i64>,
43        /// How many state trees to include. 0 for chain spine with no state trees.
44        #[arg(short, long, default_value_t = DEFAULT_RECENT_STATE_ROOTS)]
45        depth: crate::chain::ChainEpochDelta,
46        /// Snapshot format to export.
47        #[arg(long, value_enum, default_value_t = FilecoinSnapshotVersion::V2)]
48        format: FilecoinSnapshotVersion,
49        /// Also exports an augmented data snapshot that contains message receipts and events
50        #[arg(long)]
51        augmented_snapshot: bool,
52        /// Also exports a tipset lookup HAMT snapshot
53        #[arg(long)]
54        tipset_lookup: bool,
55    },
56    /// Show status of the current export.
57    ExportStatus {
58        /// Wait until it completes and print progress.
59        #[arg(long)]
60        wait: bool,
61        /// Format of the output. `json` or `text`.
62        #[arg(long, value_enum, default_value_t = Format::Text)]
63        format: Format,
64    },
65    /// Cancel the current export.
66    ExportCancel {},
67    /// Export a diff snapshot between `from` and `to` epochs to `<output_path>`
68    ExportDiff {
69        /// `./forest_snapshot_diff_{chain}_{from}_{to}+{depth}.car.zst`.
70        #[arg(short, long, default_value = ".", verbatim_doc_comment)]
71        output_path: PathBuf,
72        /// Epoch to export from
73        #[arg(long)]
74        from: i64,
75        /// Epoch to diff against
76        #[arg(long)]
77        to: i64,
78        /// How many state-roots to include. Lower limit is 900 for `calibnet` and `mainnet`.
79        #[arg(short, long)]
80        depth: Option<crate::chain::ChainEpochDelta>,
81    },
82}
83
84impl SnapshotCommands {
85    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
86        match self {
87            Self::Export {
88                output_path,
89                skip_checksum,
90                dry_run,
91                tipset,
92                depth,
93                format,
94                augmented_snapshot,
95                tipset_lookup,
96            } => {
97                anyhow::ensure!(
98                    depth >= 0,
99                    "--depth must be non-negative; use 0 for spine-only snapshots"
100                );
101
102                if depth < CHAIN_FINALITY {
103                    tracing::warn!(
104                        "Depth {depth} should be no less than CHAIN_FINALITY {CHAIN_FINALITY} to export a valid lite snapshot"
105                    );
106                }
107
108                let raw_network_name = StateNetworkName::call(&client, ()).await?;
109                // For historical reasons and backwards compatibility if snapshot services or their
110                // consumers relied on the `calibnet`, we use `calibnet` as the chain name.
111                let chain_name = if raw_network_name == calibnet::NETWORK_GENESIS_NAME {
112                    calibnet::NETWORK_COMMON_NAME
113                } else {
114                    raw_network_name.as_str()
115                };
116
117                let tipset = if let Some(epoch) = tipset {
118                    // This could take a while when the requested epoch is far behind the chain head
119                    client
120                        .call(
121                            ChainGetTipSetByHeight::request((epoch, Default::default()))?
122                                .with_timeout(Duration::from_secs(60 * 15)),
123                        )
124                        .await?
125                } else {
126                    ChainHead::call(&client, ()).await?
127                };
128
129                let output_path = std::path::absolute(match output_path.is_dir() {
130                    true => output_path.join(snapshot::filename(
131                        TrustedVendor::Forest,
132                        chain_name,
133                        DateTime::from_timestamp(tipset.min_ticket_block().timestamp as i64, 0)
134                            .unwrap_or_default()
135                            .naive_utc()
136                            .date(),
137                        tipset.epoch(),
138                        true,
139                    )),
140                    false => output_path.clone(),
141                })
142                .context("failed to make output path absolute")?;
143
144                let params = ForestChainExportParams {
145                    version: format,
146                    epoch: tipset.epoch(),
147                    recent_roots: depth,
148                    output_path: output_path.clone(),
149                    tipset_keys: tipset.key().clone().into(),
150                    include_receipts: false,
151                    include_events: false,
152                    include_tipset_keys: false,
153                    augmented_snapshot,
154                    tipset_lookup,
155                    skip_checksum,
156                    dry_run,
157                };
158
159                let pb = ProgressBar::new_spinner().with_style(
160                    ProgressStyle::with_template(
161                        "{spinner} {msg} {binary_total_bytes} written in {elapsed} ({binary_bytes_per_sec})",
162                    )
163                    .expect("indicatif template must be valid"),
164                ).with_message(format!("Exporting v{} snapshot to {} ...", format as u64, output_path.display()));
165                pb.enable_steady_tick(std::time::Duration::from_millis(80));
166                let handle = tokio::spawn({
167                    let path = tmp_exporting_forest_car_path(&output_path);
168                    let pb = pb.clone();
169                    let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
170                    async move {
171                        loop {
172                            interval.tick().await;
173                            if let Ok(meta) = std::fs::metadata(&path) {
174                                pb.set_position(meta.len());
175                            }
176                        }
177                    }
178                });
179
180                // Manually construct RpcRequest because snapshot export could
181                // take a few hours on mainnet
182                let export_result = client
183                    .call(ForestChainExport::request((params,))?.with_timeout(Duration::MAX))
184                    .await?;
185
186                handle.abort();
187                pb.finish();
188                _ = handle.await;
189
190                match export_result {
191                    ApiExportResult::Done => {
192                        println!("Export completed.");
193                    }
194                    ApiExportResult::Cancelled => {
195                        println!("Export cancelled.");
196                    }
197                }
198                Ok(())
199            }
200            Self::ExportStatus { wait, format } => {
201                let result = client
202                    .call(
203                        ForestChainExportStatus::request(())?.with_timeout(Duration::from_secs(30)),
204                    )
205                    .await?;
206                // A terminal state from a past export is reported as-is, never as the
207                // watched export's outcome.
208                if !wait || result.state != ChainExportState::Running {
209                    match format {
210                        Format::Text => println!("{result}"),
211                        Format::Json => println!("{}", serde_json::to_string_pretty(&result)?),
212                    }
213                    return Ok(());
214                }
215                let watched_start_time = result.start_time;
216                let elapsed = chrono::Utc::now()
217                    .signed_duration_since(watched_start_time.unwrap_or_default())
218                    .to_std()
219                    .unwrap_or(Duration::ZERO);
220                let pb = ProgressBar::new(10000)
221                    .with_elapsed(elapsed)
222                    .with_message("Exporting");
223                pb.set_style(
224                    ProgressStyle::with_template(
225                        "[{elapsed_precise}] [{wide_bar}] {percent}% {msg} ",
226                    )
227                    .expect("indicatif template must be valid")
228                    .progress_chars("#>-"),
229                );
230                let last = loop {
231                    let result = client
232                        .call(
233                            ForestChainExportStatus::request(())?
234                                .with_timeout(Duration::from_secs(30)),
235                        )
236                        .await?;
237                    if result.start_time != watched_start_time {
238                        // The export slot was reused: the watched export is over, but its
239                        // outcome has been overwritten by the newer export's.
240                        pb.abandon_with_message("Export ended; another export has taken its place");
241                        return Ok(());
242                    }
243                    let position = (result.progress.clamp(0.0, 1.0) * 10000.0).trunc() as u64;
244                    pb.set_position(position);
245
246                    if result.state != ChainExportState::Running {
247                        break result;
248                    }
249                    tokio::time::sleep(Duration::from_millis(500)).await;
250                };
251                match last.state {
252                    ChainExportState::Succeeded => pb.finish_with_message("Export completed"),
253                    ChainExportState::Cancelled => pb.abandon_with_message("Export cancelled"),
254                    _ => {
255                        pb.abandon_with_message("Export failed");
256                        anyhow::bail!(
257                            "export failed: {}",
258                            last.error.as_deref().unwrap_or("unknown error")
259                        );
260                    }
261                }
262
263                Ok(())
264            }
265            Self::ExportCancel {} => {
266                let result = client
267                    .call(
268                        ForestChainExportCancel::request(())?.with_timeout(Duration::from_secs(30)),
269                    )
270                    .await?;
271                if result {
272                    println!("Export cancelled.");
273                } else {
274                    println!("No export in progress to cancel.");
275                }
276                Ok(())
277            }
278            Self::ExportDiff {
279                output_path,
280                from,
281                to,
282                depth,
283            } => {
284                let raw_network_name = StateNetworkName::call(&client, ()).await?;
285
286                // For historical reasons and backwards compatibility if snapshot services or their
287                // consumers relied on the `calibnet`, we use `calibnet` as the chain name.
288                let chain_name = if raw_network_name == calibnet::NETWORK_GENESIS_NAME {
289                    calibnet::NETWORK_COMMON_NAME
290                } else {
291                    raw_network_name.as_str()
292                };
293
294                let depth = depth.unwrap_or_else(|| from - to);
295                anyhow::ensure!(depth > 0, "depth must be positive");
296
297                let output_path = std::path::absolute(match output_path.is_dir() {
298                    true => output_path.join(format!(
299                        "forest_snapshot_diff_{chain_name}_{from}_{to}+{depth}.car.zst"
300                    )),
301                    false => output_path.clone(),
302                })
303                .context("failed to make output path absolute")?;
304
305                let params = ForestChainExportDiffParams {
306                    output_path: output_path.clone(),
307                    from,
308                    to,
309                    depth,
310                };
311
312                let pb = ProgressBar::new_spinner().with_style(
313                    ProgressStyle::with_template(
314                        "{spinner} {msg} {binary_total_bytes} written in {elapsed} ({binary_bytes_per_sec})",
315                    )
316                    .expect("indicatif template must be valid"),
317                ).with_message(format!("Exporting {} ...", output_path.display()));
318                pb.enable_steady_tick(std::time::Duration::from_millis(80));
319                let cancellation_token = CancellationToken::new();
320                // Make sure token is cancelled on error path
321                let _cancellation_token_drop_guard = cancellation_token.drop_guard_ref();
322                let handle = tokio::spawn({
323                    let cancellation_token = cancellation_token.clone();
324                    let path = tmp_exporting_forest_car_path(&output_path);
325                    let pb = pb.clone();
326                    let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
327                    async move {
328                        while !cancellation_token.is_cancelled() {
329                            interval.tick().await;
330                            if let Ok(meta) = std::fs::metadata(&path) {
331                                pb.set_position(meta.len());
332                            }
333                        }
334                    }
335                });
336                // Manually construct RpcRequest because snapshot export could
337                // take a few hours on mainnet
338                let export_result = client
339                    .call(ForestChainExportDiff::request((params,))?.with_timeout(Duration::MAX))
340                    .await?;
341                // cancel before `handle.await` to avoid deadlock
342                cancellation_token.cancel();
343                pb.finish();
344                _ = handle.await;
345
346                match export_result {
347                    ApiExportResult::Done => {
348                        println!("Export completed.");
349                    }
350                    ApiExportResult::Cancelled => {
351                        println!("Export cancelled.");
352                    }
353                }
354                Ok(())
355            }
356        }
357    }
358}