fix(consensus): skip RNG wait when quorum is impossible and base threshold on active UNL

When fewer participants are present than the quorum threshold, skip the
RNG commit wait immediately instead of waiting the full pipeline timeout.
Also base the quorum on activeUNLNodeIds_ (UNL Report with fallback)
instead of the full trusted key set, so the denominator reflects who is
actually active on the network.
This commit is contained in:
Nicholas Dudfield
2026-02-06 14:34:28 +07:00
parent a9dffd38ff
commit 960808b172
2 changed files with 49 additions and 12 deletions

View File

@@ -1149,9 +1149,28 @@ RCLConsensus::Adaptor::updateOperatingMode(std::size_t const positions) const
std::size_t
RCLConsensus::Adaptor::quorumThreshold() const
{
auto [quorum, trustedKeys] = getQuorumKeys();
// Use 80% quorum for RNG commit/reveal
return (trustedKeys.size() * 80 + 99) / 100;
// Base quorum on the active UNL — the same set used by
// isActiveUNLMember() to filter RNG data. This comes from the
// UNL Report (in-ledger) with fallback to the trusted key list.
// If a node drops off the UNL Report, the denominator shrinks
// and quorum becomes achievable with fewer participants.
//
// TODO: This needs more careful thought. Open questions:
// - Should there be a minimum absolute count (e.g. at least 3
// committers) to prevent weak entropy from tiny partitions?
// - Is 80% the right percentage for RNG, or should it differ
// from the tx consensus threshold?
// - What happens if the UNL Report is stale and over-counts
// active validators? The "impossible quorum" early-exit in
// Consensus.h mitigates the worst case (no delay), but the
// node still falls back to ZERO entropy.
// - On a fresh network with no UNL Report, the fallback is
// getTrustedMasterKeys() which includes all configured
// validators — possibly including offline ones.
auto const base = activeUNLNodeIds_.size();
if (base == 0)
return 1; // safety: need at least one commit
return (base * 80 + 99) / 100;
}
bool

View File

@@ -1440,20 +1440,38 @@ Consensus<Adaptor>::phaseEstablish()
// consensus close logic and nodes inject partial/zero entropy
// while others are still collecting — causing ledger mismatches.
//
// However, if we've already converged on the txSet (which we
// have — haveConsensus() passed above) and there aren't enough
// participants to ever reach quorum, skip immediately. With
// 3 nodes and quorum=3, losing one node means 2/3 commits
// forever — waiting 3s per round just delays recovery.
//
// NOTE: Late-joining nodes (e.g. restarting after a crash)
// cannot help here. They enter the round as proposing=false
// and onClose() skips commitment generation for non-proposers.
// It takes at least one full round of observing before
// consensus promotes them to proposing. So waiting beyond
// a few seconds is pointless — use rngPIPELINE_TIMEOUT (3s)
// rather than ledgerMAX_CONSENSUS (10s) to avoid penalizing
// the recovery path.
// consensus promotes them to proposing.
{
bool timeout =
result_->roundTime.read() > parms.rngPIPELINE_TIMEOUT;
if (!timeout)
return; // Wait for more commits
// On timeout: fall through to normal close (zero entropy)
// participants = peers + ourselves
auto const participants = currPeerPositions_.size() + 1;
auto const threshold = adaptor_.quorumThreshold();
bool const impossible = participants < threshold;
if (impossible)
{
JLOG(j_.debug())
<< "RNG: skipping commit wait (participants="
<< participants << " < threshold=" << threshold << ")";
// Fall through to close with zero entropy
}
else
{
bool timeout =
result_->roundTime.read() > parms.rngPIPELINE_TIMEOUT;
if (!timeout)
return; // Wait for more commits
// On timeout: fall through to normal close (zero entropy)
}
}
}
else if (estState_ == EstablishState::ConvergingCommit)