From 0b607113dc39223581987f7e534b7e6f4caa8e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:08:43 +0100 Subject: [PATCH] refactor(era): make era count in era file name optional (#20292) --- crates/era-utils/src/export.rs | 6 ++ crates/era-utils/tests/it/genesis.rs | 2 +- crates/era/src/common/file_ops.rs | 83 ++++++++++++++++++++- crates/era/src/era/types/group.rs | 103 ++++++++++++++++----------- crates/era/src/era1/types/group.rs | 87 ++++++++++++---------- 5 files changed, 199 insertions(+), 82 deletions(-) diff --git a/crates/era-utils/src/export.rs b/crates/era-utils/src/export.rs index 0502f0e2ea..db8538d3c4 100644 --- a/crates/era-utils/src/export.rs +++ b/crates/era-utils/src/export.rs @@ -150,6 +150,12 @@ where let era1_id = Era1Id::new(&config.network, start_block, block_count as u32) .with_hash(historical_root); + let era1_id = if config.max_blocks_per_file == MAX_BLOCKS_PER_ERA1 as u64 { + era1_id + } else { + era1_id.with_era_count() + }; + debug!("Final file name {}", era1_id.to_file_name()); let file_path = config.dir.join(era1_id.to_file_name()); let file = std::fs::File::create(&file_path)?; diff --git a/crates/era-utils/tests/it/genesis.rs b/crates/era-utils/tests/it/genesis.rs index 0c35c458aa..700fb1f006 100644 --- a/crates/era-utils/tests/it/genesis.rs +++ b/crates/era-utils/tests/it/genesis.rs @@ -24,7 +24,7 @@ fn test_export_with_genesis_only() { assert!(file_path.exists(), "Exported file should exist on disk"); let file_name = file_path.file_name().unwrap().to_str().unwrap(); assert!( - file_name.starts_with("mainnet-00000-00001-"), + file_name.starts_with("mainnet-00000-"), "File should have correct prefix with era format" ); assert!(file_name.ends_with(".era1"), "File should have correct extension"); diff --git a/crates/era/src/common/file_ops.rs b/crates/era/src/common/file_ops.rs index 1a1e1defb7..3938f9ebe8 100644 --- a/crates/era/src/common/file_ops.rs +++ b/crates/era/src/common/file_ops.rs @@ -30,8 +30,11 @@ pub trait EraFileFormat: Sized { /// Era file identifiers pub trait EraFileId: Clone { - /// Convert to standardized file name - fn to_file_name(&self) -> String; + /// File type for this identifier + const FILE_TYPE: EraFileType; + + /// Number of items, slots for `era`, blocks for `era1`, per era + const ITEMS_PER_ERA: u64; /// Get the network name fn network_name(&self) -> &str; @@ -41,6 +44,43 @@ pub trait EraFileId: Clone { /// Get the count of items fn count(&self) -> u32; + + /// Get the optional hash identifier + fn hash(&self) -> Option<[u8; 4]>; + + /// Whether to include era count in filename + fn include_era_count(&self) -> bool; + + /// Calculate era number + fn era_number(&self) -> u64 { + self.start_number() / Self::ITEMS_PER_ERA + } + + /// Calculate the number of eras spanned per file. + /// + /// If the user can decide how many slots/blocks per era file there are, we need to calculate + /// it. Most of the time it should be 1, but it can never be more than 2 eras per file + /// as there is a maximum of 8192 slots/blocks per era file. + fn era_count(&self) -> u64 { + if self.count() == 0 { + return 0; + } + let first_era = self.era_number(); + let last_number = self.start_number() + self.count() as u64 - 1; + let last_era = last_number / Self::ITEMS_PER_ERA; + last_era - first_era + 1 + } + + /// Convert to standardized file name. + fn to_file_name(&self) -> String { + Self::FILE_TYPE.format_filename( + self.network_name(), + self.era_number(), + self.hash(), + self.include_era_count(), + self.era_count(), + ) + } } /// [`StreamReader`] for reading era-format files @@ -154,6 +194,37 @@ impl EraFileType { } } + /// Generate era file name. + /// + /// Standard format: `--.` + /// See also + /// + /// With era count (for custom exports): + /// `---.` + pub fn format_filename( + &self, + network_name: &str, + era_number: u64, + hash: Option<[u8; 4]>, + include_era_count: bool, + era_count: u64, + ) -> String { + let hash = format_hash(hash); + + if include_era_count { + format!( + "{}-{:05}-{:05}-{}{}", + network_name, + era_number, + era_count, + hash, + self.extension() + ) + } else { + format!("{}-{:05}-{}{}", network_name, era_number, hash, self.extension()) + } + } + /// Detect file type from URL /// By default, it assumes `Era` type pub fn from_url(url: &str) -> Self { @@ -164,3 +235,11 @@ impl EraFileType { } } } + +/// Format hash as hex string, or placeholder if none +pub fn format_hash(hash: Option<[u8; 4]>) -> String { + match hash { + Some(h) => format!("{:02x}{:02x}{:02x}{:02x}", h[0], h[1], h[2], h[3]), + None => "00000000".to_string(), + } +} diff --git a/crates/era/src/era/types/group.rs b/crates/era/src/era/types/group.rs index 5051ddae47..2536c0394c 100644 --- a/crates/era/src/era/types/group.rs +++ b/crates/era/src/era/types/group.rs @@ -3,7 +3,7 @@ //! See also use crate::{ - common::file_ops::EraFileId, + common::file_ops::{EraFileId, EraFileType}, e2s::types::{Entry, IndexEntry, SLOT_INDEX}, era::types::consensus::{CompressedBeaconState, CompressedSignedBeaconBlock}, }; @@ -163,12 +163,22 @@ pub struct EraId { /// Optional hash identifier for this file /// First 4 bytes of the last historical root in the last state in the era file pub hash: Option<[u8; 4]>, + + /// Whether to include era count in filename + /// It is used for custom exports when we don't use the max number of items per file + include_era_count: bool, } impl EraId { /// Create a new [`EraId`] pub fn new(network_name: impl Into, start_slot: u64, slot_count: u32) -> Self { - Self { network_name: network_name.into(), start_slot, slot_count, hash: None } + Self { + network_name: network_name.into(), + start_slot, + slot_count, + hash: None, + include_era_count: false, + } } /// Add a hash identifier to [`EraId`] @@ -177,32 +187,18 @@ impl EraId { self } - /// Calculate which era number the file starts at - pub const fn era_number(&self) -> u64 { - self.start_slot / SLOTS_PER_HISTORICAL_ROOT - } - - // Helper function to calculate the number of eras per era1 file, - // If the user can decide how many blocks per era1 file there are, we need to calculate it. - // Most of the time it should be 1, but it can never be more than 2 eras per file - // as there is a maximum of 8192 blocks per era1 file. - const fn calculate_era_count(&self) -> u64 { - if self.slot_count == 0 { - return 0; - } - - let first_era = self.era_number(); - - // Calculate the actual last slot number in the range - let last_slot = self.start_slot + self.slot_count as u64 - 1; - // Find which era the last block belongs to - let last_era = last_slot / SLOTS_PER_HISTORICAL_ROOT; - // Count how many eras we span - last_era - first_era + 1 + /// Include era count in filename, for custom slot-per-file exports + pub const fn with_era_count(mut self) -> Self { + self.include_era_count = true; + self } } impl EraFileId for EraId { + const FILE_TYPE: EraFileType = EraFileType::Era; + + const ITEMS_PER_ERA: u64 = SLOTS_PER_HISTORICAL_ROOT; + fn network_name(&self) -> &str { &self.network_name } @@ -214,24 +210,13 @@ impl EraFileId for EraId { fn count(&self) -> u32 { self.slot_count } - /// Convert to file name following the era file naming: - /// `---.era` - /// - /// See also - fn to_file_name(&self) -> String { - let era_number = self.era_number(); - let era_count = self.calculate_era_count(); - if let Some(hash) = self.hash { - format!( - "{}-{:05}-{:05}-{:02x}{:02x}{:02x}{:02x}.era", - self.network_name, era_number, era_count, hash[0], hash[1], hash[2], hash[3] - ) - } else { - // era spec format with placeholder hash when no hash available - // Format: `---00000000.era` - format!("{}-{:05}-{:05}-00000000.era", self.network_name, era_number, era_count) - } + fn hash(&self) -> Option<[u8; 4]> { + self.hash + } + + fn include_era_count(&self) -> bool { + self.include_era_count } } @@ -399,4 +384,40 @@ mod tests { let parsed_offset = index.offsets[0]; assert_eq!(parsed_offset, -1024); } + + #[test_case::test_case( + EraId::new("mainnet", 0, 8192).with_hash([0x4b, 0x36, 0x3d, 0xb9]), + "mainnet-00000-4b363db9.era"; + "Mainnet era 0" + )] + #[test_case::test_case( + EraId::new("mainnet", 8192, 8192).with_hash([0x40, 0xcf, 0x2f, 0x3c]), + "mainnet-00001-40cf2f3c.era"; + "Mainnet era 1" + )] + #[test_case::test_case( + EraId::new("mainnet", 0, 8192), + "mainnet-00000-00000000.era"; + "Without hash" + )] + fn test_era_id_file_naming(id: EraId, expected_file_name: &str) { + let actual_file_name = id.to_file_name(); + assert_eq!(actual_file_name, expected_file_name); + } + + // File naming with era-count, for custom exports + #[test_case::test_case( + EraId::new("mainnet", 0, 8192).with_hash([0x4b, 0x36, 0x3d, 0xb9]).with_era_count(), + "mainnet-00000-00001-4b363db9.era"; + "Mainnet era 0 with count" + )] + #[test_case::test_case( + EraId::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(), + "mainnet-00000-00002-abcdef12.era"; + "Spanning two eras with count" + )] + fn test_era_id_file_naming_with_era_count(id: EraId, expected_file_name: &str) { + let actual_file_name = id.to_file_name(); + assert_eq!(actual_file_name, expected_file_name); + } } diff --git a/crates/era/src/era1/types/group.rs b/crates/era/src/era1/types/group.rs index 0f1b8cabd5..4d3a049aa6 100644 --- a/crates/era/src/era1/types/group.rs +++ b/crates/era/src/era1/types/group.rs @@ -3,7 +3,7 @@ //! See also use crate::{ - common::file_ops::EraFileId, + common::file_ops::{EraFileId, EraFileType}, e2s::types::{Entry, IndexEntry}, era1::types::execution::{Accumulator, BlockTuple, MAX_BLOCKS_PER_ERA1}, }; @@ -105,6 +105,10 @@ pub struct Era1Id { /// Optional hash identifier for this file /// First 4 bytes of the last historical root in the last state in the era file pub hash: Option<[u8; 4]>, + + /// Whether to include era count in filename + /// It is used for custom exports when we don't use the max number of items per file + pub include_era_count: bool, } impl Era1Id { @@ -114,7 +118,13 @@ impl Era1Id { start_block: BlockNumber, block_count: u32, ) -> Self { - Self { network_name: network_name.into(), start_block, block_count, hash: None } + Self { + network_name: network_name.into(), + start_block, + block_count, + hash: None, + include_era_count: false, + } } /// Add a hash identifier to [`Era1Id`] @@ -123,21 +133,17 @@ impl Era1Id { self } - // Helper function to calculate the number of eras per era1 file, - // If the user can decide how many blocks per era1 file there are, we need to calculate it. - // Most of the time it should be 1, but it can never be more than 2 eras per file - // as there is a maximum of 8192 blocks per era1 file. - const fn calculate_era_count(&self, first_era: u64) -> u64 { - // Calculate the actual last block number in the range - let last_block = self.start_block + self.block_count as u64 - 1; - // Find which era the last block belongs to - let last_era = last_block / MAX_BLOCKS_PER_ERA1 as u64; - // Count how many eras we span - last_era - first_era + 1 + /// Include era count in filename, for custom block-per-file exports + pub const fn with_era_count(mut self) -> Self { + self.include_era_count = true; + self } } impl EraFileId for Era1Id { + const FILE_TYPE: EraFileType = EraFileType::Era1; + + const ITEMS_PER_ERA: u64 = MAX_BLOCKS_PER_ERA1 as u64; fn network_name(&self) -> &str { &self.network_name } @@ -149,24 +155,13 @@ impl EraFileId for Era1Id { fn count(&self) -> u32 { self.block_count } - /// Convert to file name following the era file naming: - /// `---.era(1)` - /// - /// See also - fn to_file_name(&self) -> String { - // Find which era the first block belongs to - let era_number = self.start_block / MAX_BLOCKS_PER_ERA1 as u64; - let era_count = self.calculate_era_count(era_number); - if let Some(hash) = self.hash { - format!( - "{}-{:05}-{:05}-{:02x}{:02x}{:02x}{:02x}.era1", - self.network_name, era_number, era_count, hash[0], hash[1], hash[2], hash[3] - ) - } else { - // era spec format with placeholder hash when no hash available - // Format: `---00000000.era1` - format!("{}-{:05}-{:05}-00000000.era1", self.network_name, era_number, era_count) - } + + fn hash(&self) -> Option<[u8; 4]> { + self.hash + } + + fn include_era_count(&self) -> bool { + self.include_era_count } } @@ -314,35 +309,51 @@ mod tests { #[test_case::test_case( Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]), - "mainnet-00000-00001-5ec1ffb8.era1"; + "mainnet-00000-5ec1ffb8.era1"; "Mainnet era 0" )] #[test_case::test_case( Era1Id::new("mainnet", 8192, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]), - "mainnet-00001-00001-5ecb9bf9.era1"; + "mainnet-00001-5ecb9bf9.era1"; "Mainnet era 1" )] #[test_case::test_case( Era1Id::new("sepolia", 0, 8192).with_hash([0x90, 0x91, 0x84, 0x72]), - "sepolia-00000-00001-90918472.era1"; + "sepolia-00000-90918472.era1"; "Sepolia era 0" )] #[test_case::test_case( Era1Id::new("sepolia", 155648, 8192).with_hash([0xfa, 0x77, 0x00, 0x19]), - "sepolia-00019-00001-fa770019.era1"; + "sepolia-00019-fa770019.era1"; "Sepolia era 19" )] #[test_case::test_case( Era1Id::new("mainnet", 1000, 100), - "mainnet-00000-00001-00000000.era1"; + "mainnet-00000-00000000.era1"; "ID without hash" )] #[test_case::test_case( Era1Id::new("sepolia", 101130240, 8192).with_hash([0xab, 0xcd, 0xef, 0x12]), - "sepolia-12345-00001-abcdef12.era1"; + "sepolia-12345-abcdef12.era1"; "Large block number era 12345" )] - fn test_era1id_file_naming(id: Era1Id, expected_file_name: &str) { + fn test_era1_id_file_naming(id: Era1Id, expected_file_name: &str) { + let actual_file_name = id.to_file_name(); + assert_eq!(actual_file_name, expected_file_name); + } + + // File naming with era-count, for custom exports + #[test_case::test_case( + Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]).with_era_count(), + "mainnet-00000-00001-5ec1ffb8.era1"; + "Mainnet era 0 with count" + )] + #[test_case::test_case( + Era1Id::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(), + "mainnet-00000-00002-abcdef12.era1"; + "Spanning two eras with count" + )] + fn test_era1_id_file_naming_with_era_count(id: Era1Id, expected_file_name: &str) { let actual_file_name = id.to_file_name(); assert_eq!(actual_file_name, expected_file_name); }