Skip to content

Commit d22a505

Browse files
authored
Prevent consensus from getting stuck in the establish phase (#5277)
- Detects if the consensus process is "stalled". If it is, then we can declare a consensus and end successfully even if we do not have 80% agreement on our proposal. - "Stalled" is defined as: - We have a close time consensus - Each disputed transaction is individually stalled: - It has been in the final "stuck" 95% requirement for at least 2 (avMIN_ROUNDS) "inner rounds" of phaseEstablish, - and either all of the other trusted proposers or this validator, if proposing, have had the same vote(s) for at least 4 (avSTALLED_ROUNDS) "inner rounds", and at least 80% of the validators (including this one, if appropriate) agree about the vote (whether yes or no). - If we have been in the establish phase for more than 10x the previous consensus establish phase's time, then consensus is considered "expired", and we will leave the round, which sends a partial validation (indicating that the node is moving on without validating). Two restrictions avoid prematurely exiting, or having an extended exit in extreme situations. - The 10x time is clamped to be within a range of 15s (ledgerMAX_CONSENSUS) to 120s (ledgerABANDON_CONSENSUS). - If consensus has not had an opportunity to walk through all avalanche states (defined as not going through 8 "inner rounds" of phaseEstablish), then ConsensusState::Expired is treated as ConsensusState::No. - When enough nodes leave the round, any remaining nodes will see they've fallen behind, and move on, too, generally before hitting the timeout. Any validations or partial validations sent during this time will help the consensus process bring the nodes back together.
1 parent 75a2019 commit d22a505

File tree

10 files changed

+688
-79
lines changed

10 files changed

+688
-79
lines changed

Builds/levelization/results/ordering.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ test.consensus > xrpl.basics
4343
test.consensus > xrpld.app
4444
test.consensus > xrpld.consensus
4545
test.consensus > xrpld.ledger
46+
test.consensus > xrpl.json
4647
test.core > test.jtx
4748
test.core > test.toplevel
4849
test.core > test.unit_test

docs/consensus.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ struct ConsensusResult
558558
ConsensusTimer roundTime;
559559
560560
// Indicates state in which consensus ended. Once in the accept phase
561-
// will be either Yes or MovedOn
561+
// will be either Yes or MovedOn or Expired
562562
ConsensusState state = ConsensusState::No;
563563
};
564564

src/test/consensus/Consensus_test.cpp

Lines changed: 373 additions & 10 deletions
Large diffs are not rendered by default.

src/test/csf/Tx.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class Tx
5252
{
5353
}
5454

55-
ID
55+
ID const&
5656
id() const
5757
{
5858
return id_;

src/xrpld/app/misc/NetworkOPs.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#include <xrpld/app/misc/NetworkOPs.h>
3636
#include <xrpld/app/misc/Transaction.h>
3737
#include <xrpld/app/misc/TxQ.h>
38+
#include <xrpld/app/misc/ValidatorKeys.h>
3839
#include <xrpld/app/misc/ValidatorList.h>
3940
#include <xrpld/app/misc/detail/AccountTxPaging.h>
4041
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
@@ -249,6 +250,12 @@ class NetworkOPsImp final : public NetworkOPs
249250
beast::get_abstract_clock<std::chrono::steady_clock>(),
250251
validatorKeys,
251252
app_.logs().journal("LedgerConsensus"))
253+
, validatorPK_(
254+
validatorKeys.keys ? validatorKeys.keys->publicKey
255+
: decltype(validatorPK_){})
256+
, validatorMasterPK_(
257+
validatorKeys.keys ? validatorKeys.keys->masterPublicKey
258+
: decltype(validatorMasterPK_){})
252259
, m_ledgerMaster(ledgerMaster)
253260
, m_job_queue(job_queue)
254261
, m_standalone(standalone)
@@ -732,6 +739,9 @@ class NetworkOPsImp final : public NetworkOPs
732739

733740
RCLConsensus mConsensus;
734741

742+
std::optional<PublicKey> const validatorPK_;
743+
std::optional<PublicKey> const validatorMasterPK_;
744+
735745
ConsensusPhase mLastConsensusPhase;
736746

737747
LedgerMaster& m_ledgerMaster;
@@ -1917,6 +1927,23 @@ NetworkOPsImp::beginConsensus(
19171927
bool
19181928
NetworkOPsImp::processTrustedProposal(RCLCxPeerPos peerPos)
19191929
{
1930+
auto const& peerKey = peerPos.publicKey();
1931+
if (validatorPK_ == peerKey || validatorMasterPK_ == peerKey)
1932+
{
1933+
// Could indicate a operator misconfiguration where two nodes are
1934+
// running with the same validator key configured, so this isn't fatal,
1935+
// and it doesn't necessarily indicate peer misbehavior. But since this
1936+
// is a trusted message, it could be a very big deal. Either way, we
1937+
// don't want to relay the proposal. Note that the byzantine behavior
1938+
// detection in handleNewValidation will notify other peers.
1939+
UNREACHABLE(
1940+
"ripple::NetworkOPsImp::processTrustedProposal : received own "
1941+
"proposal");
1942+
JLOG(m_journal.error())
1943+
<< "Received a TRUSTED proposal signed with my key from a peer";
1944+
return false;
1945+
}
1946+
19201947
return mConsensus.peerProposal(app_.timeKeeper().closeTime(), peerPos);
19211948
}
19221949

src/xrpld/consensus/Consensus.cpp

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ checkConsensusReached(
109109
bool count_self,
110110
std::size_t minConsensusPct,
111111
bool reachedMax,
112+
bool stalled,
112113
std::unique_ptr<std::stringstream> const& clog)
113114
{
114115
CLOG(clog) << "checkConsensusReached params: agreeing: " << agreeing
@@ -138,6 +139,17 @@ checkConsensusReached(
138139
return false;
139140
}
140141

142+
// We only get stalled when every disputed transaction unequivocally has 80%
143+
// (minConsensusPct) agreement, either for or against. That is: either under
144+
// 20% or over 80% consensus (repectively "nay" or "yay"). This prevents
145+
// manipulation by a minority of byzantine peers of which transactions make
146+
// the cut to get into the ledger.
147+
if (stalled)
148+
{
149+
CLOG(clog) << "consensus stalled. ";
150+
return true;
151+
}
152+
141153
if (count_self)
142154
{
143155
++agreeing;
@@ -147,6 +159,7 @@ checkConsensusReached(
147159
}
148160

149161
std::size_t currentPercentage = (agreeing * 100) / total;
162+
150163
CLOG(clog) << "currentPercentage: " << currentPercentage;
151164
bool const ret = currentPercentage >= minConsensusPct;
152165
if (ret)
@@ -168,6 +181,7 @@ checkConsensus(
168181
std::size_t currentFinished,
169182
std::chrono::milliseconds previousAgreeTime,
170183
std::chrono::milliseconds currentAgreeTime,
184+
bool stalled,
171185
ConsensusParms const& parms,
172186
bool proposing,
173187
beast::Journal j,
@@ -181,7 +195,7 @@ checkConsensus(
181195
<< " minimum duration to reach consensus: "
182196
<< parms.ledgerMIN_CONSENSUS.count() << "ms"
183197
<< " max consensus time " << parms.ledgerMAX_CONSENSUS.count()
184-
<< "s"
198+
<< "ms"
185199
<< " minimum consensus percentage: " << parms.minCONSENSUS_PCT
186200
<< ". ";
187201

@@ -211,10 +225,12 @@ checkConsensus(
211225
proposing,
212226
parms.minCONSENSUS_PCT,
213227
currentAgreeTime > parms.ledgerMAX_CONSENSUS,
228+
stalled,
214229
clog))
215230
{
216-
JLOG(j.debug()) << "normal consensus";
217-
CLOG(clog) << "reached. ";
231+
JLOG((stalled ? j.warn() : j.debug()))
232+
<< "normal consensus" << (stalled ? ", but stalled" : "");
233+
CLOG(clog) << "reached" << (stalled ? ", but stalled." : ".");
218234
return ConsensusState::Yes;
219235
}
220236

@@ -226,13 +242,27 @@ checkConsensus(
226242
false,
227243
parms.minCONSENSUS_PCT,
228244
currentAgreeTime > parms.ledgerMAX_CONSENSUS,
245+
false,
229246
clog))
230247
{
231248
JLOG(j.warn()) << "We see no consensus, but 80% of nodes have moved on";
232249
CLOG(clog) << "We see no consensus, but 80% of nodes have moved on";
233250
return ConsensusState::MovedOn;
234251
}
235252

253+
std::chrono::milliseconds const maxAgreeTime =
254+
previousAgreeTime * parms.ledgerABANDON_CONSENSUS_FACTOR;
255+
if (currentAgreeTime > std::clamp(
256+
maxAgreeTime,
257+
parms.ledgerMAX_CONSENSUS,
258+
parms.ledgerABANDON_CONSENSUS))
259+
{
260+
JLOG(j.warn()) << "consensus taken too long";
261+
CLOG(clog) << "Consensus taken too long. ";
262+
// Note the Expired result may be overridden by the caller.
263+
return ConsensusState::Expired;
264+
}
265+
236266
// no consensus yet
237267
JLOG(j.trace()) << "no consensus";
238268
CLOG(clog) << "No consensus. ";

src/xrpld/consensus/Consensus.h

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
#include <xrpl/beast/utility/Journal.h>
3232
#include <xrpl/json/json_writer.h>
3333

34+
#include <algorithm>
3435
#include <chrono>
3536
#include <deque>
3637
#include <optional>
@@ -81,6 +82,10 @@ shouldCloseLedger(
8182
last ledger
8283
@param currentAgreeTime how long, in milliseconds, we've been trying to
8384
agree
85+
@param stalled the network appears to be stalled, where
86+
neither we nor our peers have changed their vote on any disputes in a
87+
while. This is undesirable, and will cause us to end consensus
88+
without 80% agreement.
8489
@param parms Consensus constant parameters
8590
@param proposing whether we should count ourselves
8691
@param j journal for logging
@@ -94,6 +99,7 @@ checkConsensus(
9499
std::size_t currentFinished,
95100
std::chrono::milliseconds previousAgreeTime,
96101
std::chrono::milliseconds currentAgreeTime,
102+
bool stalled,
97103
ConsensusParms const& parms,
98104
bool proposing,
99105
beast::Journal j,
@@ -574,6 +580,9 @@ class Consensus
574580

575581
NetClock::duration closeResolution_ = ledgerDefaultTimeResolution;
576582

583+
ConsensusParms::AvalancheState closeTimeAvalancheState_ =
584+
ConsensusParms::init;
585+
577586
// Time it took for the last consensus round to converge
578587
std::chrono::milliseconds prevRoundTime_;
579588

@@ -599,6 +608,13 @@ class Consensus
599608
std::optional<Result> result_;
600609
ConsensusCloseTimes rawCloseTimes_;
601610

611+
// The number of calls to phaseEstablish where none of our peers
612+
// have changed any votes on disputed transactions.
613+
std::size_t peerUnchangedCounter_ = 0;
614+
615+
// The total number of times we have called phaseEstablish
616+
std::size_t establishCounter_ = 0;
617+
602618
//-------------------------------------------------------------------------
603619
// Peer related consensus data
604620

@@ -696,6 +712,7 @@ Consensus<Adaptor>::startRoundInternal(
696712
previousLedger_ = prevLedger;
697713
result_.reset();
698714
convergePercent_ = 0;
715+
closeTimeAvalancheState_ = ConsensusParms::init;
699716
haveCloseTimeConsensus_ = false;
700717
openTime_.reset(clock_.now());
701718
currPeerPositions_.clear();
@@ -1351,6 +1368,9 @@ Consensus<Adaptor>::phaseEstablish(
13511368
// can only establish consensus if we already took a stance
13521369
XRPL_ASSERT(result_, "ripple::Consensus::phaseEstablish : result is set");
13531370

1371+
++peerUnchangedCounter_;
1372+
++establishCounter_;
1373+
13541374
using namespace std::chrono;
13551375
ConsensusParms const& parms = adaptor_.parms();
13561376

@@ -1417,6 +1437,8 @@ Consensus<Adaptor>::closeLedger(std::unique_ptr<std::stringstream> const& clog)
14171437
phase_ = ConsensusPhase::establish;
14181438
JLOG(j_.debug()) << "transitioned to ConsensusPhase::establish";
14191439
rawCloseTimes_.self = now_;
1440+
peerUnchangedCounter_ = 0;
1441+
establishCounter_ = 0;
14201442

14211443
result_.emplace(adaptor_.onClose(previousLedger_, now_, mode_.get()));
14221444
result_->roundTime.reset(clock_.now());
@@ -1550,16 +1572,11 @@ Consensus<Adaptor>::updateOurPositions(
15501572
}
15511573
else
15521574
{
1553-
int neededWeight;
1554-
1555-
if (convergePercent_ < parms.avMID_CONSENSUS_TIME)
1556-
neededWeight = parms.avINIT_CONSENSUS_PCT;
1557-
else if (convergePercent_ < parms.avLATE_CONSENSUS_TIME)
1558-
neededWeight = parms.avMID_CONSENSUS_PCT;
1559-
else if (convergePercent_ < parms.avSTUCK_CONSENSUS_TIME)
1560-
neededWeight = parms.avLATE_CONSENSUS_PCT;
1561-
else
1562-
neededWeight = parms.avSTUCK_CONSENSUS_PCT;
1575+
// We don't track rounds for close time, so just pass 0s
1576+
auto const [neededWeight, newState] = getNeededWeight(
1577+
parms, closeTimeAvalancheState_, convergePercent_, 0, 0);
1578+
if (newState)
1579+
closeTimeAvalancheState_ = *newState;
15631580
CLOG(clog) << "neededWeight " << neededWeight << ". ";
15641581

15651582
int participants = currPeerPositions_.size();
@@ -1681,7 +1698,8 @@ Consensus<Adaptor>::haveConsensus(
16811698
}
16821699
else
16831700
{
1684-
JLOG(j_.debug()) << nodeId << " has " << peerProp.position();
1701+
JLOG(j_.debug()) << "Proposal disagreement: Peer " << nodeId
1702+
<< " has " << peerProp.position();
16851703
++disagree;
16861704
}
16871705
}
@@ -1691,6 +1709,17 @@ Consensus<Adaptor>::haveConsensus(
16911709
JLOG(j_.debug()) << "Checking for TX consensus: agree=" << agree
16921710
<< ", disagree=" << disagree;
16931711

1712+
ConsensusParms const& parms = adaptor_.parms();
1713+
// Stalling is BAD
1714+
bool const stalled = haveCloseTimeConsensus_ &&
1715+
std::ranges::all_of(result_->disputes,
1716+
[this, &parms](auto const& dispute) {
1717+
return dispute.second.stalled(
1718+
parms,
1719+
mode_.get() == ConsensusMode::proposing,
1720+
peerUnchangedCounter_);
1721+
});
1722+
16941723
// Determine if we actually have consensus or not
16951724
result_->state = checkConsensus(
16961725
prevProposers_,
@@ -1699,7 +1728,8 @@ Consensus<Adaptor>::haveConsensus(
16991728
currentFinished,
17001729
prevRoundTime_,
17011730
result_->roundTime.read(),
1702-
adaptor_.parms(),
1731+
stalled,
1732+
parms,
17031733
mode_.get() == ConsensusMode::proposing,
17041734
j_,
17051735
clog);
@@ -1710,6 +1740,33 @@ Consensus<Adaptor>::haveConsensus(
17101740
return false;
17111741
}
17121742

1743+
// Consensus has taken far too long. Drop out of the round.
1744+
if (result_->state == ConsensusState::Expired)
1745+
{
1746+
static auto const minimumCounter =
1747+
parms.avalancheCutoffs.size() * parms.avMIN_ROUNDS;
1748+
std::stringstream ss;
1749+
if (establishCounter_ < minimumCounter)
1750+
{
1751+
// If each round of phaseEstablish takes a very long time, we may
1752+
// "expire" before we've given consensus enough time at each
1753+
// avalanche level to actually come to a consensus. In that case,
1754+
// keep trying. This should only happen if there are an extremely
1755+
// large number of disputes such that each round takes an inordinate
1756+
// amount of time.
1757+
1758+
ss << "Consensus time has expired in round " << establishCounter_
1759+
<< "; continue until round " << minimumCounter << ". "
1760+
<< Json::Compact{getJson(false)};
1761+
JLOG(j_.error()) << ss.str();
1762+
CLOG(clog) << ss.str() << ". ";
1763+
return false;
1764+
}
1765+
ss << "Consensus expired. " << Json::Compact{getJson(true)};
1766+
JLOG(j_.error()) << ss.str();
1767+
CLOG(clog) << ss.str() << ". ";
1768+
leaveConsensus(clog);
1769+
}
17131770
// There is consensus, but we need to track if the network moved on
17141771
// without us.
17151772
if (result_->state == ConsensusState::MovedOn)
@@ -1802,8 +1859,9 @@ Consensus<Adaptor>::createDisputes(
18021859
{
18031860
Proposal_t const& peerProp = peerPos.proposal();
18041861
auto const cit = acquired_.find(peerProp.position());
1805-
if (cit != acquired_.end())
1806-
dtx.setVote(nodeId, cit->second.exists(txID));
1862+
if (cit != acquired_.end() &&
1863+
dtx.setVote(nodeId, cit->second.exists(txID)))
1864+
peerUnchangedCounter_ = 0;
18071865
}
18081866
adaptor_.share(dtx.tx());
18091867

@@ -1828,7 +1886,8 @@ Consensus<Adaptor>::updateDisputes(NodeID_t const& node, TxSet_t const& other)
18281886
for (auto& it : result_->disputes)
18291887
{
18301888
auto& d = it.second;
1831-
d.setVote(node, other.exists(d.tx().id()));
1889+
if (d.setVote(node, other.exists(d.tx().id())))
1890+
peerUnchangedCounter_ = 0;
18321891
}
18331892
}
18341893

0 commit comments

Comments
 (0)