From dd21641cbfae6fafc8de104c0a88007e6eec4e6b Mon Sep 17 00:00:00 2001 From: Mathieu Baudet <1105398+ma2bd@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:12:31 +0200 Subject: [PATCH 1/2] Revert multi-leader jitter (#6289), matching the testnet_conway revert (#6433). --- CLI.md | 1 - linera-base/src/ownership.rs | 142 ++------------------- linera-client/src/client_options.rs | 9 -- linera-core/src/client/chain_client/mod.rs | 50 +------- web/@linera/client/src/client.rs | 5 +- web/@linera/client/src/index.ts | 7 +- web/@linera/client/src/lib.rs | 14 -- 7 files changed, 13 insertions(+), 215 deletions(-) diff --git a/CLI.md b/CLI.md index 0c361ad2a022..72f44f48c0d5 100644 --- a/CLI.md +++ b/CLI.md @@ -174,7 +174,6 @@ Client implementation and command-line tool for the Linera blockchain Default value: `3600000` * `--wait-for-outgoing-messages` — Whether to wait until a quorum of validators has confirmed that all sent cross-chain messages have been delivered * `--allow-fast-blocks` — Whether to allow creating blocks in the fast round. Fast blocks have lower latency but must be used carefully so that there are never any conflicting fast block proposals -* `--disable-multi-leader-jitter` — Disable the multi-leader jitter delay. By default, when proposing in a multi-leader round with index `>= 1`, the client waits a deterministic delay derived from the owner and round before re-proposing. This spreads out concurrent proposals from honest clients; the owner with the lowest `hash(owner, round)` still proposes immediately * `--long-lived-services` — (EXPERIMENTAL) Whether application services can persist in some cases between queries * `--blanket-message-policy ` — The policy for handling incoming messages diff --git a/linera-base/src/ownership.rs b/linera-base/src/ownership.rs index 715511b2cfdc..09dcef70b800 100644 --- a/linera-base/src/ownership.rs +++ b/linera-base/src/ownership.rs @@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ - crypto::{BcsHashable, CryptoHash}, data_types::{Round, TimeDelta}, doc_scalar, identifiers::AccountOwner, @@ -40,10 +39,9 @@ pub struct TimeoutConfig { /// The duration of the fast round. #[debug(skip_if = Option::is_none)] pub fast_round_duration: Option, - /// The duration of the first multi-leader and single-leader rounds. + /// The duration of the first single-leader and all multi-leader rounds. pub base_timeout: TimeDelta, - /// The duration by which the timeout increases after each multi-leader or - /// single-leader round. + /// The duration by which the timeout increases after each single-leader round. pub timeout_increment: TimeDelta, /// The age of an incoming tracked or protected message after which the validators start /// transitioning the chain to fallback mode. @@ -175,7 +173,11 @@ impl ChainOwnership { } match round { Round::Fast => tc.fast_round_duration, - Round::MultiLeader(r) | Round::SingleLeader(r) | Round::Validator(r) => { + Round::MultiLeader(r) if r.saturating_add(1) == self.multi_leader_rounds => { + Some(tc.base_timeout) + } + Round::MultiLeader(_) => None, + Round::SingleLeader(r) | Round::Validator(r) => { let increment = tc.timeout_increment.saturating_mul(u64::from(r)); Some(tc.base_timeout.saturating_add(increment)) } @@ -227,73 +229,8 @@ impl ChainOwnership { pub fn is_super_owner_no_regular_owners(&self, owner: &AccountOwner) -> bool { self.owners.is_empty() && self.super_owners.contains(owner) } - - /// Returns whether `owner` has the lowest `hash(owner, round)` among the eligible - /// multi-leader proposers, and should therefore propose immediately rather than wait - /// out a jitter delay. Returns `false` for `open_multi_leader_rounds`, where the set - /// of proposers is unbounded. - /// - /// This is a gentle-clients convention; it is not enforced by the protocol. - fn is_preferred_multi_leader_proposer(&self, owner: &AccountOwner, round_index: u32) -> bool { - if self.open_multi_leader_rounds || !self.can_propose_in_multi_leader_round(owner) { - return false; - } - let our_priority = multi_leader_priority(owner, round_index); - self.all_owners().all(|other| { - other == owner || multi_leader_priority(other, round_index) >= our_priority - }) - } - - /// Returns the deterministic delay this owner should wait before proposing in `round`, - /// to spread out concurrent proposals from honest clients. The preferred owner returns - /// `TimeDelta::ZERO`; others return `hash(owner, round) mod round_duration`. Returns - /// `None` outside of multi-leader rounds, in the first multi-leader round (where - /// honest clients all attempt to propose immediately), and `Some(ZERO)` if the round - /// has no configured timeout. - pub fn multi_leader_proposal_delay( - &self, - owner: &AccountOwner, - round: Round, - ) -> Option { - let Round::MultiLeader(round_index) = round else { - return None; - }; - if round_index == 0 { - return None; - } - let round_duration = self.round_timeout(round).unwrap_or(TimeDelta::ZERO); - if round_duration == TimeDelta::ZERO - || self.is_preferred_multi_leader_proposer(owner, round_index) - { - return Some(TimeDelta::ZERO); - } - let priority = multi_leader_priority(owner, round_index); - let prefix = <[u8; 8]>::try_from(&priority.as_bytes().as_slice()[..8]) - .expect("hash is at least 8 bytes long"); - let hash_u64 = u64::from_le_bytes(prefix); - Some(TimeDelta::from_micros( - hash_u64 % round_duration.as_micros(), - )) - } } -/// Returns the deterministic priority of `owner` in the multi-leader round with the -/// given index. The owner with the lowest priority is preferred to propose first. -fn multi_leader_priority(owner: &AccountOwner, round_index: u32) -> CryptoHash { - CryptoHash::new(&MultiLeaderPriorityInput { - round: round_index, - owner: *owner, - }) -} - -#[derive(Serialize, Deserialize)] -struct MultiLeaderPriorityInput { - round: u32, - owner: AccountOwner, -} - -impl BcsHashable<'_> for MultiLeaderPriorityInput {} - /// Errors that can happen when attempting to manage a chain (close it, change ownership, or /// change application permissions). #[derive(Clone, Copy, Debug, Error, WitStore, WitType)] @@ -342,14 +279,11 @@ mod tests { ownership.round_timeout(Round::Fast), Some(TimeDelta::from_secs(5)) ); + assert_eq!(ownership.round_timeout(Round::MultiLeader(8)), None); assert_eq!( - ownership.round_timeout(Round::MultiLeader(0)), + ownership.round_timeout(Round::MultiLeader(9)), Some(TimeDelta::from_secs(10)) ); - assert_eq!( - ownership.round_timeout(Round::MultiLeader(8)), - Some(TimeDelta::from_secs(18)) - ); assert_eq!( ownership.round_timeout(Round::SingleLeader(0)), Some(TimeDelta::from_secs(10)) @@ -363,64 +297,6 @@ mod tests { Some(TimeDelta::from_secs(18)) ); } - - #[test] - fn test_multi_leader_proposal_delay() { - let owner_a = AccountOwner::from(Ed25519SecretKey::generate().public()); - let owner_b = AccountOwner::from(Ed25519SecretKey::generate().public()); - let owner_c = AccountOwner::from(Ed25519SecretKey::generate().public()); - let mut ownership = ChainOwnership::multiple( - [(owner_a, 100), (owner_b, 100), (owner_c, 100)], - 10, - TimeoutConfig { - fast_round_duration: None, - base_timeout: TimeDelta::from_secs(10), - timeout_increment: TimeDelta::ZERO, - fallback_duration: TimeDelta::MAX, - }, - ); - - // No jitter in MultiLeader(0): all clients race; lowest-hash recovery kicks in - // only from MultiLeader(1) onwards. - for owner in [owner_a, owner_b, owner_c] { - assert_eq!( - ownership.multi_leader_proposal_delay(&owner, Round::MultiLeader(0)), - None - ); - } - - // Outside multi-leader rounds, no delay is computed. - assert_eq!( - ownership.multi_leader_proposal_delay(&owner_a, Round::SingleLeader(1)), - None - ); - - // In MultiLeader(1) exactly one owner is preferred (delay = 0); the others - // get a deterministic, bounded delay. - let delays = [owner_a, owner_b, owner_c].map(|owner| { - ownership - .multi_leader_proposal_delay(&owner, Round::MultiLeader(1)) - .expect("delay should be defined in a multi-leader round") - }); - let zero_count = delays.iter().filter(|d| **d == TimeDelta::ZERO).count(); - assert_eq!( - zero_count, 1, - "exactly one owner should be the preferred proposer" - ); - for delay in delays { - assert!(delay < TimeDelta::from_secs(10)); - } - - // Open multi-leader rounds have no fixed proposer set; nobody is preferred, - // so every owner waits its own deterministic jitter. - ownership.open_multi_leader_rounds = true; - for owner in [owner_a, owner_b, owner_c] { - let delay = ownership - .multi_leader_proposal_delay(&owner, Round::MultiLeader(1)) - .expect("delay should be defined in a multi-leader round"); - assert!(delay > TimeDelta::ZERO && delay < TimeDelta::from_secs(10)); - } - } } doc_scalar!(ChainOwnership, "Represents the owner(s) of a chain"); diff --git a/linera-client/src/client_options.rs b/linera-client/src/client_options.rs index 61a72f39edb8..c7b730dde52b 100644 --- a/linera-client/src/client_options.rs +++ b/linera-client/src/client_options.rs @@ -151,14 +151,6 @@ pub struct Options { #[arg(long)] pub allow_fast_blocks: bool, - /// Disable the multi-leader jitter delay. By default, when proposing in a multi-leader - /// round with index `>= 1`, the client waits a deterministic delay derived from the - /// owner and round before re-proposing. This spreads out concurrent proposals from - /// honest clients; the owner with the lowest `hash(owner, round)` still proposes - /// immediately. - #[arg(long)] - pub disable_multi_leader_jitter: bool, - /// (EXPERIMENTAL) Whether application services can persist in some cases between queries. #[arg(long)] pub long_lived_services: bool, @@ -377,7 +369,6 @@ impl Options { max_concurrent_batch_downloads: self.max_concurrent_batch_downloads, max_joined_tasks: self.max_joined_tasks, allow_fast_blocks: self.allow_fast_blocks, - multi_leader_jitter: !self.disable_multi_leader_jitter, notification_circuit_breaker_initial_probe_interval: self .notification_circuit_breaker_initial_probe_interval, notification_circuit_breaker_max_probe_interval: self diff --git a/linera-core/src/client/chain_client/mod.rs b/linera-core/src/client/chain_client/mod.rs index 7a943d6b396a..472590994ed0 100644 --- a/linera-core/src/client/chain_client/mod.rs +++ b/linera-core/src/client/chain_client/mod.rs @@ -21,7 +21,7 @@ use linera_base::{ crypto::{signer, CryptoHash, Signer, ValidatorPublicKey}, data_types::{ Amount, ApplicationPermissions, ArithmeticError, Blob, BlobContent, BlockHeight, - ChainDescription, Epoch, MessagePolicy, Round, TimeDelta, Timestamp, + ChainDescription, Epoch, MessagePolicy, Round, Timestamp, }, ensure, identifiers::{ @@ -120,11 +120,6 @@ pub struct Options { /// Whether to allow creating blocks in the fast round. Fast blocks have lower latency but /// must be used carefully so that there are never any conflicting fast block proposals. pub allow_fast_blocks: bool, - /// Whether to apply the multi-leader jitter delay before proposing in a multi-leader - /// round with index `>= 1`, to spread out concurrent proposals across honest clients. - /// The owner with the lowest `hash(owner, round)` still proposes immediately. The - /// jitter only takes effect when the round has a configured timeout. - pub multi_leader_jitter: bool, /// Initial probe interval for the notification circuit breaker. When a validator's /// notification stream exhausts retries, the circuit breaker waits this long before /// probing again. Doubles on each failed probe. @@ -178,7 +173,6 @@ impl Options { max_concurrent_batch_downloads: DEFAULT_MAX_CONCURRENT_BATCH_DOWNLOADS, max_joined_tasks: 100, allow_fast_blocks: false, - multi_leader_jitter: false, notification_circuit_breaker_initial_probe_interval: Duration::from_secs(300), notification_circuit_breaker_max_probe_interval: Duration::from_secs(3600), max_event_stream_queries: DEFAULT_MAX_EVENT_STREAM_QUERIES, @@ -2393,13 +2387,6 @@ impl ChainClient { .map(|v| (AccountOwner::from(v.account_public_key), v.votes)) .collect(); if manager.should_propose(identity, round, seed, ¤t_committee) { - if let Some(wait_until) = self.multi_leader_jitter_target(info, identity, round) { - return Ok(Either::Right(RoundTimeout { - timestamp: wait_until, - current_round: round, - next_block_height: info.next_block_height, - })); - } return Ok(Either::Left(round)); } if let Some(timeout) = info.round_timeout() { @@ -2410,41 +2397,6 @@ impl ChainClient { )) } - /// Returns the timestamp at which `owner` should propose in `round`, to spread out - /// concurrent proposals from honest clients in a multi-leader round. Returns `None` if - /// the owner should propose immediately (either because the round is not a multi-leader - /// round, the owner is the preferred proposer, or the jitter target is already in the past). - /// - /// The delay is deterministic per `(owner, round)` and is anchored at the round's start - /// time when known, so that retrying after an interrupting notification does not extend - /// the wait further. - fn multi_leader_jitter_target( - &self, - info: &ChainInfo, - owner: &AccountOwner, - round: Round, - ) -> Option { - if !self.options.multi_leader_jitter { - return None; - } - let ownership = &info.manager.ownership; - let delay = ownership.multi_leader_proposal_delay(owner, round)?; - if delay == TimeDelta::ZERO { - return None; - } - let now = self.storage_client().clock().current_time(); - let round_start = if round == info.manager.current_round { - match (info.manager.round_timeout, ownership.round_timeout(round)) { - (Some(end), Some(duration)) => end.saturating_sub(duration), - _ => now, - } - } else { - now - }; - let propose_at = round_start.saturating_add(delay); - (propose_at > now).then_some(propose_at) - } - /// Discards the pending block proposal, if any, so that a fresh block can be /// proposed instead of retrying the queued one. /// diff --git a/web/@linera/client/src/client.rs b/web/@linera/client/src/client.rs index 835459d5a32b..9e8a07f00e06 100644 --- a/web/@linera/client/src/client.rs +++ b/web/@linera/client/src/client.rs @@ -60,10 +60,7 @@ impl Client { const BLOCK_CACHE_SIZE: usize = 5000; const EXECUTION_STATE_CACHE_SIZE: usize = 10000; - let mut options = options.unwrap_or_default(); - if crate::multi_leader_jitter_disabled() { - options.disable_multi_leader_jitter = true; - } + let options = options.unwrap_or_default(); wallet.lock().await?; let mut storage = storage::get_storage(&wallet.name()).await?; diff --git a/web/@linera/client/src/index.ts b/web/@linera/client/src/index.ts index a88025a5a1fa..61272856885a 100644 --- a/web/@linera/client/src/index.ts +++ b/web/@linera/client/src/index.ts @@ -16,16 +16,13 @@ function isBrokenSafari(): boolean { export async function initialize(options?: wasm.InitializeOptions) { if (window.location) { - // Allow overriding the application's log filters using the `LINERA_LOG`, - // `LINERA_PROFILING`, and `LINERA_DISABLE_MULTI_LEADER_JITTER` search params, - // for debugging. + // Allow overriding the application's log filters using the `LINERA_LOG` and + // `LINERA_PROFILING` search params, for debugging. const params = new URL(window.location.href).searchParams; const log = params.get('LINERA_LOG'); if (!options) options = {}; if (params.get('LINERA_PROFILING')) options.profiling = true; - if (params.get('LINERA_DISABLE_MULTI_LEADER_JITTER')) - options.disableMultiLeaderJitter = true; if (log) options.log = log; } diff --git a/web/@linera/client/src/lib.rs b/web/@linera/client/src/lib.rs index fae8b9a1a8d2..9ac9ef2a0338 100644 --- a/web/@linera/client/src/lib.rs +++ b/web/@linera/client/src/lib.rs @@ -63,20 +63,9 @@ pub struct InitializeOptions { pub log: String, /// Whether to enable performance reporting through the Performance API. pub profiling: bool, - /// Disable the multi-leader jitter delay applied by clients before re-proposing - /// in multi-leader rounds with index `>= 1`. Settable via the - /// `LINERA_DISABLE_MULTI_LEADER_JITTER` URL search param for debugging. - pub disable_multi_leader_jitter: bool, } static INITIALIZED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); -static DISABLE_MULTI_LEADER_JITTER: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Returns whether the multi-leader jitter delay should be disabled for all chain -/// clients in this session, as configured by [`initialize`]. Defaults to `false`. -pub(crate) fn multi_leader_jitter_disabled() -> bool { - DISABLE_MULTI_LEADER_JITTER.get().copied().unwrap_or(false) -} #[wasm_bindgen] pub fn initialize(options: Option) { @@ -90,9 +79,6 @@ pub fn initialize(options: Option) { } let options = options.unwrap_or_default(); - DISABLE_MULTI_LEADER_JITTER - .set(options.disable_multi_leader_jitter) - .ok(); // If no log filter is provided, disable the user application log by default, to avoid // overwhelming the console with logs from the client library itself. let log_filter = if options.log.is_empty() { From 6173d675d349848ec6881316e32292675a997d01 Mon Sep 17 00:00:00 2001 From: Mathieu Baudet <1105398+ma2bd@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:12:32 +0200 Subject: [PATCH 2/2] Make the genesis-blob leader-timeout test set up a round that times out without multi-leader jitter. --- linera-core/src/unit_tests/client_tests.rs | 11 ++++++++++- linera-core/src/unit_tests/test_utils.rs | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/linera-core/src/unit_tests/client_tests.rs b/linera-core/src/unit_tests/client_tests.rs index b288562bdf03..6d426998f389 100644 --- a/linera-core/src/unit_tests/client_tests.rs +++ b/linera-core/src/unit_tests/client_tests.rs @@ -2623,7 +2623,16 @@ where let signer = InMemorySigner::new(None); let clock = storage_builder.clock().clone(); let mut builder = TestBuilder::new(storage_builder, 4, 0, signer).await?; - let client = builder.add_root_chain(1, Amount::from_tokens(10)).await?; + // Give the chain a single multi-leader round at genesis so that its initial round + // (`MultiLeader(0)`) times out: without multi-leader jitter, earlier multi-leader rounds + // do not time out. The chain still has no blocks of its own, which is the case this + // regression test exercises: requesting a timeout certificate then only fetches the + // chain's genesis description blob. + let client = builder + .add_root_chain_with_ownership(1, Amount::from_tokens(10), |owner| { + ChainOwnership::multiple([(owner, 100)], 1, TimeoutConfig::default()) + }) + .await?; // Advance the clock past the (default 10s) round timeout. clock.set(Timestamp::from(20_000_000)); diff --git a/linera-core/src/unit_tests/test_utils.rs b/linera-core/src/unit_tests/test_utils.rs index eb583baaae33..1d19f5358d88 100644 --- a/linera-core/src/unit_tests/test_utils.rs +++ b/linera-core/src/unit_tests/test_utils.rs @@ -1013,6 +1013,21 @@ where &mut self, index: u32, balance: Amount, + ) -> anyhow::Result> { + self.add_root_chain_with_ownership(index, balance, ChainOwnership::single) + .await + } + + /// Creates the root chain with the given `index` and a genesis ownership built from its + /// freshly generated owner key, and returns a client for it. + /// + /// Root chain 0 is the admin chain and needs to be initialized first, otherwise its balance + /// is automatically set to zero. + pub async fn add_root_chain_with_ownership( + &mut self, + index: u32, + balance: Amount, + make_ownership: impl FnOnce(AccountOwner) -> ChainOwnership, ) -> anyhow::Result> { // Make sure the admin chain is initialized. if self.admin_description.is_none() && index != 0 { @@ -1021,7 +1036,7 @@ where let origin = ChainOrigin::Root(index); let public_key = self.signer.generate_new(); let open_chain_config = InitialChainConfig { - ownership: ChainOwnership::single(public_key.into()), + ownership: make_ownership(public_key.into()), epoch: Epoch(0), balance, application_permissions: ApplicationPermissions::default(),