use std::{ num::{NonZeroU64, NonZeroUsize}, time::Duration, }; use testing_framework_core::{ scenario::{Builder as CoreScenarioBuilder, NodeControlCapability}, topology::configs::wallet::WalletConfig, }; use crate::{ expectations::ConsensusLiveness, workloads::{chaos::RandomRestartWorkload, da, transaction}, }; macro_rules! non_zero_rate_fn { ($name:ident, $message:literal) => { const fn $name(rate: u64) -> NonZeroU64 { match NonZeroU64::new(rate) { Some(value) => value, None => panic!($message), } } }; } non_zero_rate_fn!( transaction_rate_checked, "transaction rate must be non-zero" ); non_zero_rate_fn!(channel_rate_checked, "channel rate must be non-zero"); non_zero_rate_fn!(blob_rate_checked, "blob rate must be non-zero"); /// Extension methods for building test scenarios with common patterns. pub trait ScenarioBuilderExt: Sized { /// Configure a transaction flow workload. fn transactions(self) -> TransactionFlowBuilder; /// Configure a transaction flow workload via closure. fn transactions_with( self, f: impl FnOnce(TransactionFlowBuilder) -> TransactionFlowBuilder, ) -> CoreScenarioBuilder; /// Configure a data-availability workload. fn da(self) -> DataAvailabilityFlowBuilder; /// Configure a data-availability workload via closure. fn da_with( self, f: impl FnOnce(DataAvailabilityFlowBuilder) -> DataAvailabilityFlowBuilder, ) -> CoreScenarioBuilder; #[must_use] /// Attach a consensus liveness expectation. fn expect_consensus_liveness(self) -> Self; #[must_use] /// Seed deterministic wallets with total funds split across `users`. fn initialize_wallet(self, total_funds: u64, users: usize) -> Self; } impl ScenarioBuilderExt for CoreScenarioBuilder { fn transactions(self) -> TransactionFlowBuilder { TransactionFlowBuilder::new(self) } fn transactions_with( self, f: impl FnOnce(TransactionFlowBuilder) -> TransactionFlowBuilder, ) -> CoreScenarioBuilder { f(self.transactions()).apply() } fn da(self) -> DataAvailabilityFlowBuilder { DataAvailabilityFlowBuilder::new(self) } fn da_with( self, f: impl FnOnce(DataAvailabilityFlowBuilder) -> DataAvailabilityFlowBuilder, ) -> CoreScenarioBuilder { f(self.da()).apply() } fn expect_consensus_liveness(self) -> Self { self.with_expectation(ConsensusLiveness::default()) } fn initialize_wallet(self, total_funds: u64, users: usize) -> Self { let user_count = NonZeroUsize::new(users).expect("wallet user count must be non-zero"); let wallet = WalletConfig::uniform(total_funds, user_count); self.with_wallet_config(wallet) } } /// Builder for transaction workloads. pub struct TransactionFlowBuilder { builder: CoreScenarioBuilder, rate: NonZeroU64, users: Option, } impl TransactionFlowBuilder { const fn default_rate() -> NonZeroU64 { transaction_rate_checked(1) } const fn new(builder: CoreScenarioBuilder) -> Self { Self { builder, rate: Self::default_rate(), users: None, } } #[must_use] /// Set transaction submission rate per block (panics on zero). pub const fn rate(mut self, rate: u64) -> Self { self.rate = transaction_rate_checked(rate); self } #[must_use] /// Set transaction submission rate per block. pub const fn rate_per_block(mut self, rate: NonZeroU64) -> Self { self.rate = rate; self } #[must_use] /// Limit how many users will submit transactions. pub const fn users(mut self, users: usize) -> Self { match NonZeroUsize::new(users) { Some(value) => self.users = Some(value), None => panic!("transaction user count must be non-zero"), } self } #[must_use] /// Attach the transaction workload to the scenario. pub fn apply(mut self) -> CoreScenarioBuilder { let workload = transaction::Workload::with_rate(self.rate.get()) .expect("transaction rate must be non-zero") .with_user_limit(self.users); self.builder = self.builder.with_workload(workload); self.builder } } /// Builder for data availability workloads. pub struct DataAvailabilityFlowBuilder { builder: CoreScenarioBuilder, channel_rate: NonZeroU64, blob_rate: NonZeroU64, } impl DataAvailabilityFlowBuilder { const fn default_channel_rate() -> NonZeroU64 { channel_rate_checked(1) } const fn default_blob_rate() -> NonZeroU64 { blob_rate_checked(1) } const fn new(builder: CoreScenarioBuilder) -> Self { Self { builder, channel_rate: Self::default_channel_rate(), blob_rate: Self::default_blob_rate(), } } #[must_use] /// Set channel publish rate per block (panics on zero). pub const fn channel_rate(mut self, rate: u64) -> Self { self.channel_rate = channel_rate_checked(rate); self } #[must_use] /// Set channel publish rate per block. pub const fn channel_rate_per_block(mut self, rate: NonZeroU64) -> Self { self.channel_rate = rate; self } #[must_use] /// Set blob publish rate (per block). pub const fn blob_rate(mut self, rate: u64) -> Self { self.blob_rate = blob_rate_checked(rate); self } #[must_use] /// Set blob publish rate per block. pub const fn blob_rate_per_block(mut self, rate: NonZeroU64) -> Self { self.blob_rate = rate; self } #[must_use] pub fn apply(mut self) -> CoreScenarioBuilder { let count = (self.channel_rate.get() * self.blob_rate.get()) as usize; let workload = da::Workload::with_channel_count(count.max(1)); self.builder = self.builder.with_workload(workload); self.builder } } /// Chaos helpers for scenarios that can control nodes. pub trait ChaosBuilderExt: Sized { /// Entry point into chaos workloads. fn chaos(self) -> ChaosBuilder; /// Configure chaos via closure. fn chaos_with( self, f: impl FnOnce(ChaosBuilder) -> CoreScenarioBuilder, ) -> CoreScenarioBuilder; } impl ChaosBuilderExt for CoreScenarioBuilder { fn chaos(self) -> ChaosBuilder { ChaosBuilder { builder: self } } fn chaos_with( self, f: impl FnOnce(ChaosBuilder) -> CoreScenarioBuilder, ) -> CoreScenarioBuilder { f(self.chaos()) } } /// Chaos workload builder root. /// /// Start with `chaos()` on a scenario builder, then select a workload variant /// such as `restart()`. pub struct ChaosBuilder { builder: CoreScenarioBuilder, } impl ChaosBuilder { /// Finish without adding a chaos workload. #[must_use] pub fn apply(self) -> CoreScenarioBuilder { self.builder } /// Configure a random restarts chaos workload. #[must_use] pub fn restart(self) -> ChaosRestartBuilder { ChaosRestartBuilder { builder: self.builder, min_delay: Duration::from_secs(10), max_delay: Duration::from_secs(30), target_cooldown: Duration::from_secs(60), include_validators: true, include_executors: true, } } } pub struct ChaosRestartBuilder { builder: CoreScenarioBuilder, min_delay: Duration, max_delay: Duration, target_cooldown: Duration, include_validators: bool, include_executors: bool, } impl ChaosRestartBuilder { #[must_use] /// Set the minimum delay between restart operations. pub fn min_delay(mut self, delay: Duration) -> Self { assert!(!delay.is_zero(), "chaos restart min delay must be non-zero"); self.min_delay = delay; self } #[must_use] /// Set the maximum delay between restart operations. pub fn max_delay(mut self, delay: Duration) -> Self { assert!(!delay.is_zero(), "chaos restart max delay must be non-zero"); self.max_delay = delay; self } #[must_use] /// Cooldown to allow between restarts for a target node. pub fn target_cooldown(mut self, cooldown: Duration) -> Self { assert!( !cooldown.is_zero(), "chaos restart target cooldown must be non-zero" ); self.target_cooldown = cooldown; self } #[must_use] /// Include validators in the restart target set. pub const fn include_validators(mut self, enabled: bool) -> Self { self.include_validators = enabled; self } #[must_use] /// Include executors in the restart target set. pub const fn include_executors(mut self, enabled: bool) -> Self { self.include_executors = enabled; self } #[must_use] /// Finalize the chaos restart workload and attach it to the scenario. pub fn apply(mut self) -> CoreScenarioBuilder { assert!( self.min_delay <= self.max_delay, "chaos restart min delay must not exceed max delay" ); assert!( self.target_cooldown >= self.min_delay, "chaos restart target cooldown must be >= min delay" ); assert!( self.include_validators || self.include_executors, "chaos restart requires at least one node group" ); let workload = RandomRestartWorkload::new( self.min_delay, self.max_delay, self.target_cooldown, self.include_validators, self.include_executors, ); self.builder = self.builder.with_workload(workload); self.builder } }