Skip to content

Commit 798915f

Browse files
authored
Fix a bug where clients don't recover from conflicting proposals. (#3892)
## Motivation If two clients make conflicting proposals, it can happen that both of them have their own proposal in their local node, so when retrying, they will retry in the same round, because their own pending proposal isn't treated as a "conflict". ## Proposal Use the next round, even if the proposal is our own. ## Test Plan The added test reproduces the issue. ## Release Plan - These changes should be backported to the latest `devnet` branch, then - be released in a new SDK. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist)
1 parent 25aaea3 commit 798915f

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

linera-core/src/client/mod.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2361,7 +2361,7 @@ impl<Env: Environment> ChainClient<Env> {
23612361
// Using the round number during execution counts as an oracle.
23622362
// Accessing the round number in single-leader rounds where we are not the leader
23632363
// is not currently supported.
2364-
let round = match Self::round_for_new_proposal(&info, &identity, &proposed_block, true)? {
2364+
let round = match Self::round_for_new_proposal(&info, &identity, true)? {
23652365
Either::Left(round) => round.multi_leader(),
23662366
Either::Right(_) => None,
23672367
};
@@ -2716,7 +2716,7 @@ impl<Env: Environment> ChainClient<Env> {
27162716
// Use the round number assuming there are oracle responses.
27172717
// Using the round number during execution counts as an oracle.
27182718
let proposed_block = pending_proposal.block;
2719-
let round = match Self::round_for_new_proposal(&info, &owner, &proposed_block, true)? {
2719+
let round = match Self::round_for_new_proposal(&info, &owner, true)? {
27202720
Either::Left(round) => round.multi_leader(),
27212721
Either::Right(_) => None,
27222722
};
@@ -2731,12 +2731,7 @@ impl<Env: Environment> ChainClient<Env> {
27312731

27322732
let has_oracle_responses = block.has_oracle_responses();
27332733
let (proposed_block, outcome) = block.into_proposal();
2734-
let round = match Self::round_for_new_proposal(
2735-
&info,
2736-
&owner,
2737-
&proposed_block,
2738-
has_oracle_responses,
2739-
)? {
2734+
let round = match Self::round_for_new_proposal(&info, &owner, has_oracle_responses)? {
27402735
Either::Left(round) => round,
27412736
Either::Right(timeout) => return Ok(ClientOutcome::WaitForTimeout(timeout)),
27422737
};
@@ -2855,16 +2850,17 @@ impl<Env: Environment> ChainClient<Env> {
28552850
fn round_for_new_proposal(
28562851
info: &ChainInfo,
28572852
identity: &AccountOwner,
2858-
block: &ProposedBlock,
28592853
has_oracle_responses: bool,
28602854
) -> Result<Either<Round, RoundTimeout>, ChainClientError> {
28612855
let manager = &info.manager;
28622856
// If there is a conflicting proposal in the current round, we can only propose if the
28632857
// next round can be started without a timeout, i.e. if we are in a multi-leader round.
28642858
// Similarly, we cannot propose a block that uses oracles in the fast round.
2865-
let conflict = manager.requested_proposed.as_ref().is_some_and(|proposal| {
2866-
proposal.content.round == manager.current_round && proposal.content.block != *block
2867-
}) || (manager.current_round.is_fast() && has_oracle_responses);
2859+
let conflict = manager
2860+
.requested_proposed
2861+
.as_ref()
2862+
.is_some_and(|proposal| proposal.content.round == manager.current_round)
2863+
|| (manager.current_round.is_fast() && has_oracle_responses);
28682864
let round = if !conflict {
28692865
manager.current_round
28702866
} else if let Some(round) = manager

linera-core/src/unit_tests/client_tests.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,85 @@ where
15321532
Ok(())
15331533
}
15341534

1535+
#[test_case(MemoryStorageBuilder::default(); "memory")]
1536+
#[cfg_attr(feature = "storage-service", test_case(ServiceStorageBuilder::new().await; "storage_service"))]
1537+
#[cfg_attr(feature = "rocksdb", test_case(RocksDbStorageBuilder::new().await; "rocks_db"))]
1538+
#[cfg_attr(feature = "dynamodb", test_case(DynamoDbStorageBuilder::default(); "dynamo_db"))]
1539+
#[cfg_attr(feature = "scylladb", test_case(ScyllaDbStorageBuilder::default(); "scylla_db"))]
1540+
#[test_log::test(tokio::test)]
1541+
async fn test_conflicting_proposals<B>(storage_builder: B) -> anyhow::Result<()>
1542+
where
1543+
B: StorageBuilder,
1544+
{
1545+
let mut signer = InMemorySigner::new(None);
1546+
let owner2 = signer.generate_new().into();
1547+
let mut builder = TestBuilder::new(storage_builder, 4, 0, &mut signer).await?;
1548+
let client1 = builder.add_root_chain(1, Amount::ONE).await?;
1549+
let chain_id = client1.chain_id();
1550+
let owner1 = client1.public_key().await?.into();
1551+
let owner_change_op = Operation::system(SystemOperation::ChangeOwnership {
1552+
super_owners: Vec::new(),
1553+
owners: vec![(owner1, 50), (owner2, 50)],
1554+
multi_leader_rounds: 10,
1555+
open_multi_leader_rounds: false,
1556+
timeout_config: TimeoutConfig::default(),
1557+
});
1558+
client1
1559+
.execute_operation(owner_change_op.clone())
1560+
.await
1561+
.unwrap();
1562+
let mut client2 = builder
1563+
.make_client(chain_id, client1.block_hash(), BlockHeight::from(1))
1564+
.await?;
1565+
client2.set_preferred_owner(owner2);
1566+
client2.synchronize_from_validators().await.unwrap();
1567+
1568+
// Client 1 makes a proposal to only validators 0 and 1.
1569+
builder
1570+
.set_fault_type([2, 3], FaultType::OfflineWithInfo)
1571+
.await;
1572+
assert!(client1
1573+
.burn(AccountOwner::CHAIN, Amount::from_millis(1))
1574+
.await
1575+
.is_err());
1576+
1577+
// Client 2's proposal reaches only 2 and 3.
1578+
builder
1579+
.set_fault_type([0, 1], FaultType::OfflineWithInfo)
1580+
.await;
1581+
builder.set_fault_type([2, 3], FaultType::Honest).await;
1582+
assert!(client2
1583+
.burn(AccountOwner::CHAIN, Amount::from_millis(2))
1584+
.await
1585+
.is_err());
1586+
1587+
// TODO(#3894): Make test_utils deterministic.
1588+
// The following condition is currently satisfied most of the time, but in some cases
1589+
// the faulty validators return an error before the honest ones process the proposal.
1590+
1591+
// for i in 0..4 {
1592+
// let info = builder
1593+
// .node(i)
1594+
// .chain_info_with_manager_values(chain_id)
1595+
// .await?;
1596+
// assert_eq!(
1597+
// AccountOwner::from(info.manager.requested_proposed.unwrap().public_key),
1598+
// if i < 2 { owner1 } else { owner2 },
1599+
// );
1600+
// }
1601+
1602+
// Once all validators are functional again, a new proposal should succeed.
1603+
builder
1604+
.set_fault_type([0, 1, 2, 3], FaultType::Honest)
1605+
.await;
1606+
1607+
client1.synchronize_from_validators().await.unwrap();
1608+
client1.publish_data_blob(b"foo".to_vec()).await?;
1609+
1610+
assert_eq!(client1.next_block_height(), BlockHeight::from(3));
1611+
Ok(())
1612+
}
1613+
15351614
#[test_case(MemoryStorageBuilder::default(); "memory")]
15361615
#[cfg_attr(feature = "storage-service", test_case(ServiceStorageBuilder::new().await; "storage_service"))]
15371616
#[cfg_attr(feature = "rocksdb", test_case(RocksDbStorageBuilder::new().await; "rocks_db"))]

0 commit comments

Comments
 (0)