1use super::{manifest::*, verify::OutputVerifier};
2use eyre::Result;
3use serde::Serialize;
4use std::{collections::BTreeMap, io::Write, path::Path};
5use tracing::info;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "camelCase")]
10pub struct DownloadPlan {
11 pub schema_version: u8,
13 pub chain_id: u64,
15 pub block: u64,
17 pub total_download_size: u64,
19 pub total_output_size: u64,
21 pub archives: Vec<DownloadPlanArchive>,
23}
24
25impl DownloadPlan {
26 const SCHEMA_VERSION: u8 = 1;
27
28 pub(crate) fn from_planned(manifest: &SnapshotManifest, planned: &PlannedDownloads) -> Self {
29 Self {
30 schema_version: Self::SCHEMA_VERSION,
31 chain_id: manifest.chain_id,
32 block: manifest.block,
33 total_download_size: planned.total_download_size,
34 total_output_size: planned.total_output_size,
35 archives: planned.archives.iter().map(DownloadPlanArchive::from_planned).collect(),
36 }
37 }
38
39 pub fn push_archive(&mut self, archive: DownloadPlanArchive) {
41 self.total_download_size = self.total_download_size.saturating_add(archive.download_size);
42 self.total_output_size = self.total_output_size.saturating_add(archive.output_size);
43 self.archives.push(archive);
44 }
45
46 pub fn write_json(&self, mut writer: impl Write) -> Result<()> {
48 serde_json::to_writer_pretty(&mut writer, self)?;
49 writeln!(writer)?;
50 Ok(())
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct DownloadPlanArchive {
58 pub component: String,
60 pub file_name: String,
62 pub url: String,
64 pub download_size: u64,
66 pub output_size: u64,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub blake3: Option<String>,
71}
72
73impl DownloadPlanArchive {
74 pub fn new(
76 component: impl Into<String>,
77 file_name: impl Into<String>,
78 url: impl Into<String>,
79 download_size: u64,
80 output_size: u64,
81 blake3: Option<String>,
82 ) -> Self {
83 Self {
84 component: component.into(),
85 file_name: file_name.into(),
86 url: url.into(),
87 download_size,
88 output_size,
89 blake3,
90 }
91 }
92
93 fn from_planned(planned: &PlannedArchive) -> Self {
94 Self::new(
95 planned.ty.key(),
96 planned.archive.file_name.clone(),
97 planned.archive.url.clone(),
98 planned.archive.size,
99 planned.archive.output_size(),
100 planned.archive.blake3.clone(),
101 )
102 }
103}
104
105#[derive(Debug, Clone)]
107pub(crate) struct PlannedArchive {
108 pub(crate) ty: SnapshotComponentType,
110 pub(crate) component: String,
112 pub(crate) archive: SnapshotArchive,
114}
115
116#[derive(Debug)]
118pub(crate) struct PlannedDownloads {
119 pub(crate) archives: Vec<PlannedArchive>,
121 pub(crate) total_download_size: u64,
123 pub(crate) total_output_size: u64,
125}
126
127impl PlannedDownloads {
128 pub(crate) const fn total_archives(&self) -> usize {
130 self.archives.len()
131 }
132}
133
134pub(crate) const fn archive_priority_rank(ty: SnapshotComponentType) -> u8 {
136 match ty {
137 SnapshotComponentType::State => 0,
138 SnapshotComponentType::RocksdbIndices => 1,
139 _ => 2,
140 }
141}
142
143#[derive(Debug, Default, Clone, Copy)]
145pub(crate) struct DownloadStartupSummary {
146 pub(crate) reusable: usize,
148 pub(crate) needs_download: usize,
150}
151
152pub(crate) fn summarize_download_startup(
154 all_downloads: &[PlannedArchive],
155 target_dir: &Path,
156 static_files_dir: Option<&Path>,
157) -> Result<DownloadStartupSummary> {
158 let mut summary = DownloadStartupSummary::default();
159 let verifier = OutputVerifier::new(target_dir, static_files_dir);
160
161 for planned in all_downloads {
162 if verifier.verify(&planned.archive.output_files)? {
163 summary.reusable += 1;
164 } else {
165 summary.needs_download += 1;
166 }
167 }
168
169 Ok(summary)
170}
171
172fn selection_archive_distance(
174 selection: &ComponentSelection,
175 snapshot_block: u64,
176) -> Option<Option<u64>> {
177 match selection {
178 ComponentSelection::All => Some(None),
179 ComponentSelection::Distance(distance) => Some(Some(*distance)),
180 ComponentSelection::Since(block) => Some(Some(snapshot_block.saturating_sub(*block) + 1)),
181 ComponentSelection::None => None,
182 }
183}
184
185fn sort_planned_archives(all_downloads: &mut [PlannedArchive]) {
187 all_downloads.sort_by(|a, b| {
188 archive_priority_rank(a.ty)
189 .cmp(&archive_priority_rank(b.ty))
190 .then_with(|| a.component.cmp(&b.component))
191 .then_with(|| a.archive.file_name.cmp(&b.archive.file_name))
192 });
193}
194
195pub(crate) fn collect_planned_archives(
197 manifest: &SnapshotManifest,
198 selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
199) -> Result<PlannedDownloads> {
200 let mut archives = Vec::new();
201 let mut total_download_size = 0;
202 let mut total_output_size = 0;
203
204 for (ty, selection) in selections {
205 let Some(distance) = selection_archive_distance(selection, manifest.block) else {
206 continue;
207 };
208
209 if let Some(ComponentManifest::Chunked(chunked)) = manifest.component(*ty) &&
210 !chunked.chunk_files_are_consistent()
211 {
212 eyre::bail!(
213 "Invalid modular manifest: {} chunk_files length ({}) does not match chunk count ({})",
214 ty.key(),
215 chunked.chunk_files.len(),
216 chunked.num_chunks()
217 );
218 }
219
220 total_download_size += manifest.size_for_distance(*ty, distance);
221 total_output_size += manifest.output_size_for_distance(*ty, distance);
222
223 let snapshot_archives = manifest.snapshot_archives_for_distance(*ty, distance);
224 let component = ty.display_name().to_string();
225 if !snapshot_archives.is_empty() {
226 info!(target: "reth::cli",
227 component = %component,
228 archives = snapshot_archives.len(),
229 selection = %selection,
230 "Queued component for download"
231 );
232 }
233
234 for archive in snapshot_archives {
235 if archive.output_files.is_empty() {
236 eyre::bail!(
237 "Invalid modular manifest: {} is missing plain output checksum metadata",
238 archive.file_name
239 );
240 }
241
242 archives.push(PlannedArchive { ty: *ty, component: component.clone(), archive });
243 }
244 }
245
246 sort_planned_archives(&mut archives);
247 Ok(PlannedDownloads { archives, total_download_size, total_output_size })
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use tempfile::tempdir;
254
255 #[test]
256 fn summarize_download_startup_counts_reusable_and_needs_download() {
257 let dir = tempdir().unwrap();
258 let target_dir = dir.path();
259 let ok_file = target_dir.join("ok.bin");
260 std::fs::write(&ok_file, vec![1_u8; 4]).unwrap();
261 let ok_hash = blake3::hash(&[1_u8; 4]).to_hex().to_string();
262
263 let planned = vec![
264 PlannedArchive {
265 ty: SnapshotComponentType::State,
266 component: "State".to_string(),
267 archive: SnapshotArchive {
268 url: "https://example.com/ok.tar.zst".to_string(),
269 file_name: "ok.tar.zst".to_string(),
270 size: 10,
271 blake3: None,
272 output_files: vec![OutputFileChecksum {
273 path: "ok.bin".to_string(),
274 size: 4,
275 blake3: ok_hash,
276 }],
277 },
278 },
279 PlannedArchive {
280 ty: SnapshotComponentType::Headers,
281 component: "Headers".to_string(),
282 archive: SnapshotArchive {
283 url: "https://example.com/missing.tar.zst".to_string(),
284 file_name: "missing.tar.zst".to_string(),
285 size: 10,
286 blake3: None,
287 output_files: vec![OutputFileChecksum {
288 path: "missing.bin".to_string(),
289 size: 1,
290 blake3: "deadbeef".to_string(),
291 }],
292 },
293 },
294 PlannedArchive {
295 ty: SnapshotComponentType::Transactions,
296 component: "Transactions".to_string(),
297 archive: SnapshotArchive {
298 url: "https://example.com/bad-size.tar.zst".to_string(),
299 file_name: "bad-size.tar.zst".to_string(),
300 size: 10,
301 blake3: None,
302 output_files: vec![],
303 },
304 },
305 ];
306
307 let summary = summarize_download_startup(&planned, target_dir, None).unwrap();
308 assert_eq!(summary.reusable, 1);
309 assert_eq!(summary.needs_download, 2);
310 }
311
312 #[test]
313 fn archive_priority_prefers_state_then_rocksdb() {
314 let mut planned = [
315 PlannedArchive {
316 ty: SnapshotComponentType::Transactions,
317 component: "Transactions".to_string(),
318 archive: SnapshotArchive {
319 url: "u3".to_string(),
320 file_name: "t.tar.zst".to_string(),
321 size: 1,
322 blake3: None,
323 output_files: vec![OutputFileChecksum {
324 path: "a".to_string(),
325 size: 1,
326 blake3: "x".to_string(),
327 }],
328 },
329 },
330 PlannedArchive {
331 ty: SnapshotComponentType::RocksdbIndices,
332 component: "RocksDB Indices".to_string(),
333 archive: SnapshotArchive {
334 url: "u2".to_string(),
335 file_name: "rocksdb_indices.tar.zst".to_string(),
336 size: 1,
337 blake3: None,
338 output_files: vec![OutputFileChecksum {
339 path: "b".to_string(),
340 size: 1,
341 blake3: "y".to_string(),
342 }],
343 },
344 },
345 PlannedArchive {
346 ty: SnapshotComponentType::State,
347 component: "State (mdbx)".to_string(),
348 archive: SnapshotArchive {
349 url: "u1".to_string(),
350 file_name: "state.tar.zst".to_string(),
351 size: 1,
352 blake3: None,
353 output_files: vec![OutputFileChecksum {
354 path: "c".to_string(),
355 size: 1,
356 blake3: "z".to_string(),
357 }],
358 },
359 },
360 ];
361
362 planned.sort_by(|a, b| {
363 archive_priority_rank(a.ty)
364 .cmp(&archive_priority_rank(b.ty))
365 .then_with(|| a.component.cmp(&b.component))
366 .then_with(|| a.archive.file_name.cmp(&b.archive.file_name))
367 });
368
369 assert_eq!(planned[0].ty, SnapshotComponentType::State);
370 assert_eq!(planned[1].ty, SnapshotComponentType::RocksdbIndices);
371 assert_eq!(planned[2].ty, SnapshotComponentType::Transactions);
372 }
373
374 #[test]
375 fn collect_planned_archives_tracks_download_and_output_totals() {
376 let mut components = BTreeMap::new();
377 components.insert(
378 "state".to_string(),
379 ComponentManifest::Single(SingleArchive {
380 file: "state.tar.zst".to_string(),
381 size: 10,
382 decompressed_size: 100,
383 blake3: None,
384 output_files: vec![OutputFileChecksum {
385 path: "db/mdbx.dat".to_string(),
386 size: 100,
387 blake3: "h0".to_string(),
388 }],
389 }),
390 );
391 components.insert(
392 "transactions".to_string(),
393 ComponentManifest::Chunked(ChunkedArchive {
394 blocks_per_file: 500_000,
395 total_blocks: 1_000_000,
396 chunk_sizes: vec![20, 30],
397 chunk_decompressed_sizes: vec![200, 300],
398 chunk_files: vec![],
399 chunk_output_files: vec![
400 vec![OutputFileChecksum {
401 path: "static_files/tx-0".to_string(),
402 size: 200,
403 blake3: "h1".to_string(),
404 }],
405 vec![OutputFileChecksum {
406 path: "static_files/tx-1".to_string(),
407 size: 300,
408 blake3: "h2".to_string(),
409 }],
410 ],
411 }),
412 );
413
414 let manifest = SnapshotManifest {
415 block: 1_000_000,
416 chain_id: 1,
417 storage_version: 2,
418 timestamp: 0,
419 base_url: Some("https://example.com".to_string()),
420 reth_version: None,
421 components,
422 extensions: Default::default(),
423 };
424
425 let selections = BTreeMap::from([
426 (SnapshotComponentType::State, ComponentSelection::All),
427 (SnapshotComponentType::Transactions, ComponentSelection::Distance(500_000)),
428 ]);
429
430 let planned = collect_planned_archives(&manifest, &selections).unwrap();
431
432 assert_eq!(planned.total_download_size, 40);
433 assert_eq!(planned.total_output_size, 400);
434 assert_eq!(planned.archives.len(), 2);
435
436 let plan = DownloadPlan::from_planned(&manifest, &planned);
437 assert_eq!(
438 serde_json::to_value(plan).unwrap(),
439 serde_json::json!({
440 "schemaVersion": 1,
441 "chainId": 1,
442 "block": 1_000_000,
443 "totalDownloadSize": 40,
444 "totalOutputSize": 400,
445 "archives": [
446 {
447 "component": "state",
448 "fileName": "state.tar.zst",
449 "url": "https://example.com/state.tar.zst",
450 "downloadSize": 10,
451 "outputSize": 100
452 },
453 {
454 "component": "transactions",
455 "fileName": "transactions-500000-999999.tar.zst",
456 "url": "https://example.com/transactions-500000-999999.tar.zst",
457 "downloadSize": 30,
458 "outputSize": 300
459 }
460 ]
461 })
462 );
463 }
464
465 #[test]
466 fn collect_planned_archives_rejects_mismatched_chunk_files_length() {
467 let mut components = BTreeMap::new();
468 components.insert(
469 "transactions".to_string(),
470 ComponentManifest::Chunked(ChunkedArchive {
471 blocks_per_file: 500_000,
472 total_blocks: 1_000_000,
473 chunk_sizes: vec![20, 30],
474 chunk_decompressed_sizes: vec![200, 300],
475 chunk_files: vec!["static_files/transactions-0-499999.tar.zst".to_string()],
476 chunk_output_files: vec![
477 vec![OutputFileChecksum {
478 path: "static_files/tx-0".to_string(),
479 size: 200,
480 blake3: "h1".to_string(),
481 }],
482 vec![OutputFileChecksum {
483 path: "static_files/tx-1".to_string(),
484 size: 300,
485 blake3: "h2".to_string(),
486 }],
487 ],
488 }),
489 );
490
491 let manifest = SnapshotManifest {
492 block: 1_000_000,
493 chain_id: 1,
494 storage_version: 2,
495 timestamp: 0,
496 base_url: Some("https://example.com/mainnet".to_string()),
497 reth_version: None,
498 components,
499 extensions: Default::default(),
500 };
501 let selections =
502 BTreeMap::from([(SnapshotComponentType::Transactions, ComponentSelection::All)]);
503
504 let err = collect_planned_archives(&manifest, &selections).unwrap_err();
505 assert!(
506 err.to_string().contains("chunk_files length"),
507 "expected chunk_files length error, got: {err}"
508 );
509 }
510}