Skip to main content

forest/dev/subcommands/
archive_missing_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use ahash::HashSet;
5use anyhow::{Context as _, bail};
6use clap::Args;
7use regex::Regex;
8use serde::Deserialize;
9use std::sync::LazyLock;
10use std::time::SystemTime;
11use url::Url;
12
13use crate::networks::{NetworkChain, calculate_expected_epoch};
14use crate::shim::clock::{ChainEpoch, EPOCH_DURATION_SECONDS};
15use crate::utils::net::global_http_client;
16
17const LIST_BASE: &str = "https://forest-archive.chainsafe.dev/list";
18
19const LITE_INTERVAL: ChainEpoch = 30_000;
20const DIFF_INTERVAL: ChainEpoch = 3_000;
21
22/// Well-known genesis timestamps (Unix seconds).
23const MAINNET_GENESIS_TIMESTAMP: u64 = 1598306400; // 2020-08-24T22:00:00Z
24const CALIBNET_GENESIS_TIMESTAMP: u64 = 1667326380; // 2022-11-01T14:13:00Z
25
26#[derive(Debug, Args)]
27pub struct ArchiveMissingCommand {
28    /// Filecoin network chain (e.g., calibnet, mainnet)
29    #[arg(long, required = true)]
30    chain: NetworkChain,
31    /// Start epoch (inclusive). Defaults to genesis (epoch 0).
32    /// Rounded down to the nearest lite boundary.
33    #[arg(long)]
34    from: Option<ChainEpoch>,
35    /// End epoch (inclusive). Defaults to the current expected epoch minus 3000.
36    /// Rounded up to the next diff boundary.
37    #[arg(long)]
38    to: Option<ChainEpoch>,
39}
40
41#[derive(Debug, Deserialize)]
42struct ListingItem {
43    url: Url,
44}
45
46#[derive(Debug, Deserialize)]
47struct ListingResponse {
48    items: Vec<ListingItem>,
49}
50
51/// Extract height from an archive URL.
52/// Lite: `..._height_30000.forest.car.zst` → 30000
53/// Diff: `..._height_0+3000.forest.car.zst` → 0
54fn extract_height(url: &Url) -> Option<ChainEpoch> {
55    static RE: LazyLock<Regex> =
56        LazyLock::new(|| Regex::new(r"_height_(\d+)").expect("static regex is valid"));
57    let path = url.path();
58    let caps = RE.captures(path)?;
59    caps[1].parse().ok()
60}
61
62/// Parse a JSON listing response into a set of available heights.
63fn parse_listing_heights(data: &ListingResponse) -> HashSet<ChainEpoch> {
64    data.items
65        .iter()
66        .filter_map(|item| extract_height(&item.url))
67        .collect()
68}
69
70/// Compute the required lite snapshot epochs for a given range.
71fn compute_required_lite(from: ChainEpoch, to: ChainEpoch) -> Vec<ChainEpoch> {
72    let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
73    let base_to = (to / LITE_INTERVAL) * LITE_INTERVAL;
74    (base_from..=base_to)
75        .step_by(LITE_INTERVAL as usize)
76        .collect()
77}
78
79/// Compute the required diff snapshot epochs for a given range.
80fn compute_required_diff(from: ChainEpoch, to: ChainEpoch) -> Vec<ChainEpoch> {
81    let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
82    let base_to = (to / LITE_INTERVAL) * LITE_INTERVAL;
83    let diff_to = if to > base_to {
84        ((to - 1) / DIFF_INTERVAL) * DIFF_INTERVAL
85    } else if base_to >= DIFF_INTERVAL {
86        base_to - DIFF_INTERVAL
87    } else {
88        // Range falls within the first lite segment with to on the boundary;
89        // no diffs are needed (the lite snapshot at epoch 0 covers it).
90        return Vec::new();
91    };
92    (base_from..=diff_to)
93        .step_by(DIFF_INTERVAL as usize)
94        .collect()
95}
96
97/// Return the subset of `required` epochs not present in `available`.
98fn find_missing(required: &[ChainEpoch], available: &HashSet<ChainEpoch>) -> Vec<ChainEpoch> {
99    required
100        .iter()
101        .filter(|h| !available.contains(h))
102        .copied()
103        .collect()
104}
105
106/// Fetch the set of available heights for a given network and snapshot type.
107async fn fetch_available_heights(
108    client: &reqwest::Client,
109    network: &str,
110    snapshot_type: &str,
111) -> anyhow::Result<HashSet<ChainEpoch>> {
112    let url = format!("{LIST_BASE}/{network}/{snapshot_type}?format=json");
113    let resp = client
114        .get(&url)
115        .send()
116        .await
117        .context("failed to fetch archive listing")?;
118    if !resp.status().is_success() {
119        bail!("{url}: HTTP {}", resp.status());
120    }
121    let data: ListingResponse = resp
122        .json()
123        .await
124        .context("failed to parse archive listing")?;
125    Ok(parse_listing_heights(&data))
126}
127
128impl ArchiveMissingCommand {
129    pub async fn run(self) -> anyhow::Result<()> {
130        let (network, genesis_ts) = match &self.chain {
131            NetworkChain::Mainnet => ("mainnet", MAINNET_GENESIS_TIMESTAMP),
132            NetworkChain::Calibnet => ("calibnet", CALIBNET_GENESIS_TIMESTAMP),
133            other => bail!("network {other} is not supported"),
134        };
135
136        let now = SystemTime::now()
137            .duration_since(SystemTime::UNIX_EPOCH)?
138            .as_secs();
139        let current_epoch =
140            calculate_expected_epoch(now, genesis_ts, EPOCH_DURATION_SECONDS as u32);
141
142        let from = self.from.unwrap_or(0);
143        let to = self.to.unwrap_or_else(|| current_epoch - DIFF_INTERVAL);
144
145        if from > to {
146            bail!("--from ({from}) must be <= --to ({to})");
147        }
148
149        println!(
150            "Checking {network} epochs {from}..={to} (current network epoch: {current_epoch})"
151        );
152
153        let client = global_http_client();
154
155        println!("Fetching archive listings...");
156        let (available_lite, available_diff) = tokio::try_join!(
157            fetch_available_heights(&client, network, "lite"),
158            fetch_available_heights(&client, network, "diff"),
159        )?;
160
161        println!(
162            "Archive has {} lite and {} diff snapshots.",
163            available_lite.len(),
164            available_diff.len()
165        );
166
167        let required_lite = compute_required_lite(from, to);
168        let required_diff = compute_required_diff(from, to);
169
170        let missing_lite = find_missing(&required_lite, &available_lite);
171        let missing_diff = find_missing(&required_diff, &available_diff);
172
173        let total_required = required_lite.len() + required_diff.len();
174        let total_missing = missing_lite.len() + missing_diff.len();
175
176        if total_missing == 0 {
177            let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
178            println!(
179                "All {total_required} required snapshots are available (epochs {base_from}..={to}).",
180            );
181        } else {
182            println!("\n{total_missing} of {total_required} required snapshots are MISSING:\n");
183            if !missing_lite.is_empty() {
184                println!("  Missing lite snapshots:");
185                for h in &missing_lite {
186                    println!("    lite at height {h}");
187                }
188            }
189            if !missing_diff.is_empty() {
190                println!("  Missing diff snapshots:");
191                for h in &missing_diff {
192                    println!("    diff at height {h}");
193                }
194            }
195            bail!("{total_missing} of {total_required} required snapshots are missing");
196        }
197
198        Ok(())
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_extract_height_lite() {
208        let url = Url::parse(
209            "https://example.com/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst",
210        )
211        .unwrap();
212        assert_eq!(extract_height(&url), Some(3510000));
213    }
214
215    #[test]
216    fn test_extract_height_diff() {
217        let url = Url::parse(
218            "https://example.com/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst",
219        )
220        .unwrap();
221        assert_eq!(extract_height(&url), Some(0));
222        let url = Url::parse(
223            "https://example.com/forest_diff_mainnet_2025-12-24_height_3480000+3000.forest.car.zst",
224        )
225        .unwrap();
226        assert_eq!(extract_height(&url), Some(3480000));
227    }
228
229    #[test]
230    fn test_extract_height_invalid() {
231        let url = Url::parse("https://example.com/not-a-snapshot").unwrap();
232        assert_eq!(extract_height(&url), None);
233    }
234
235    #[test]
236    fn test_compute_required_lite_single_segment() {
237        // Range within one lite segment: only one lite snapshot needed.
238        assert_eq!(compute_required_lite(30_000, 59_999), vec![30_000]);
239    }
240
241    #[test]
242    fn test_compute_required_lite_multiple_segments() {
243        assert_eq!(
244            compute_required_lite(30_000, 90_000),
245            vec![30_000, 60_000, 90_000]
246        );
247    }
248
249    #[test]
250    fn test_compute_required_lite_from_genesis() {
251        assert_eq!(compute_required_lite(0, 60_000), vec![0, 30_000, 60_000]);
252    }
253
254    #[test]
255    fn test_compute_required_lite_rounds_down() {
256        // from=5000 rounds down to 0, to=35000 rounds down to 30000.
257        assert_eq!(compute_required_lite(5_000, 35_000), vec![0, 30_000]);
258    }
259
260    #[test]
261    fn test_compute_required_diff_within_segment() {
262        // from=30000, to=36000 — need diffs from 30000 up to 33000.
263        let diffs = compute_required_diff(30_000, 36_000);
264        assert_eq!(diffs, vec![30_000, 33_000]);
265    }
266
267    #[test]
268    fn test_compute_required_diff_exact_lite_boundary() {
269        // to=60000 is exactly on a lite boundary — need all diffs in the
270        // segment between 30000 and 60000.
271        let diffs = compute_required_diff(30_000, 60_000);
272        assert_eq!(
273            diffs,
274            vec![
275                30_000, 33_000, 36_000, 39_000, 42_000, 45_000, 48_000, 51_000, 54_000, 57_000
276            ]
277        );
278    }
279
280    #[test]
281    fn test_compute_required_diff_cross_segment() {
282        // Spans two lite segments.
283        let diffs = compute_required_diff(57_000, 63_000);
284        // base_from=30000, base_to=60000, diff_to=60000
285        assert_eq!(
286            diffs,
287            vec![
288                30_000, 33_000, 36_000, 39_000, 42_000, 45_000, 48_000, 51_000, 54_000, 57_000,
289                60_000
290            ]
291        );
292    }
293
294    #[test]
295    fn test_find_missing_none() {
296        let required = vec![0, 30_000, 60_000];
297        let available: HashSet<_> = HashSet::from_iter([0, 30_000, 60_000, 90_000]);
298        assert!(find_missing(&required, &available).is_empty());
299    }
300
301    #[test]
302    fn test_find_missing_some() {
303        let required = vec![0, 30_000, 60_000];
304        let available: HashSet<_> = HashSet::from_iter([0, 60_000]);
305        assert_eq!(find_missing(&required, &available), vec![30_000]);
306    }
307
308    #[test]
309    fn test_find_missing_all() {
310        let required = vec![0, 30_000];
311        let available: HashSet<ChainEpoch> = HashSet::default();
312        assert_eq!(find_missing(&required, &available), vec![0, 30_000]);
313    }
314
315    #[test]
316    fn test_parse_listing_heights_from_json() {
317        let json = r#"{
318            "total": 3,
319            "offset": 0,
320            "limit": 0,
321            "items": [
322                {
323                    "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst",
324                    "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst.sha256sum",
325                    "size": 7528742793,
326                    "uploaded": "2026-03-05T00:52:34.198Z"
327                },
328                {
329                    "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-02-22_height_3480000.forest.car.zst",
330                    "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-02-22_height_3480000.forest.car.zst.sha256sum",
331                    "size": 7440018317,
332                    "uploaded": "2026-02-22T23:40:48.106Z"
333                },
334                {
335                    "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2022-11-01_height_0.forest.car.zst",
336                    "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2022-11-01_height_0.forest.car.zst.sha256sum",
337                    "size": 491234,
338                    "uploaded": "2023-08-30T08:54:56.805Z"
339                }
340            ]
341        }"#;
342        let data: ListingResponse = serde_json::from_str(json).unwrap();
343        let heights = parse_listing_heights(&data);
344        assert_eq!(heights, HashSet::from_iter([0, 3_480_000, 3_510_000]));
345    }
346
347    #[test]
348    fn test_parse_listing_heights_with_diffs() {
349        let json = r#"{
350            "total": 2,
351            "offset": 0,
352            "limit": 0,
353            "items": [
354                {
355                    "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2026-03-04_height_3510000+3000.forest.car.zst",
356                    "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2026-03-04_height_3510000+3000.forest.car.zst.sha256sum",
357                    "size": 123456,
358                    "uploaded": "2026-03-05T01:00:00.000Z"
359                },
360                {
361                    "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst",
362                    "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst.sha256sum",
363                    "size": 789012,
364                    "uploaded": "2023-08-30T09:00:00.000Z"
365                }
366            ]
367        }"#;
368        let data: ListingResponse = serde_json::from_str(json).unwrap();
369        let heights = parse_listing_heights(&data);
370        assert_eq!(heights, HashSet::from_iter([0, 3_510_000]));
371    }
372
373    #[test]
374    fn test_end_to_end_missing_detection() {
375        // Simulate checking calibnet epochs 0..=60000.
376        // Available: lite at 0 and 60000 (missing 30000), all diffs present.
377        let available_lite: HashSet<_> = HashSet::from_iter([0, 60_000]);
378        let available_diff: HashSet<_> = (0..60_000).step_by(DIFF_INTERVAL as usize).collect();
379
380        let required_lite = compute_required_lite(0, 60_000);
381        let required_diff = compute_required_diff(0, 60_000);
382
383        let missing_lite = find_missing(&required_lite, &available_lite);
384        let missing_diff = find_missing(&required_diff, &available_diff);
385
386        assert_eq!(missing_lite, vec![30_000]);
387        assert!(missing_diff.is_empty());
388    }
389
390    #[test]
391    fn test_end_to_end_all_present() {
392        let available_lite: HashSet<_> = HashSet::from_iter([0, 30_000, 60_000]);
393        let available_diff: HashSet<_> = (0..60_000).step_by(DIFF_INTERVAL as usize).collect();
394
395        let required_lite = compute_required_lite(0, 60_000);
396        let required_diff = compute_required_diff(0, 60_000);
397
398        assert!(find_missing(&required_lite, &available_lite).is_empty());
399        assert!(find_missing(&required_diff, &available_diff).is_empty());
400    }
401}