Skip to content

Commit cb8678e

Browse files
committed
Account for actual elapsed nomination time in the trigger drift allowance
Integrate the trigger timestamp bookkeeping from 9193e36 and the elapsed-time heuristic from 209e581. Credit steady-clock time through first ballot entry, preserve clock drift fallbacks, and cover slow rounds, delayed callbacks, missing history, and ballot overlap. Keep the existing proposal preparation policy.
1 parent 940f64f commit cb8678e

4 files changed

Lines changed: 271 additions & 26 deletions

File tree

src/herder/HerderImpl.cpp

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,10 @@ HerderImpl::triggerAnchorFromPrepareStart(
13011301
//
13021302
// To get a better sense of which, we also take into account
13031303
// nomination time, so the check becomes timeSinceNetworkLedgerStart > target
1304-
// + nominationBudget, where nomination budget scales with timeouts.
1304+
// + nominationBudget. The budget is measured on the steady clock from the
1305+
// local trigger to entry into ballot. This includes proposal construction
1306+
// and all nomination time, including unfinished rounds and delayed timer
1307+
// callbacks.
13051308
//
13061309
// If we think we're drifting ahead after taking nomination into account, we
13071310
// fall back to prepare-start anchor, which is based on our local clock and
@@ -1338,8 +1341,8 @@ HerderImpl::triggerAnchorFromPrepareStart(
13381341
// not because our system clock drifted. The goal is to avoid falling back to
13391342
// a conservative timer and snowballing the real delay.
13401343
//
1341-
// See scenario 1, but TL;DR we can look at the nomination timeouts and see
1342-
// if the network is slow vs. the node drifting. If nomination is slow, we
1344+
// See scenario 1: measured elapsed time accounts for slow nomination
1345+
// independently of system clock drift. If nomination is slow, we
13431346
// can't fall back to the prepare-apply timer because it would compound the
13441347
// delay. If apply is slow, it doesn't matter which timer we use, they both
13451348
// will result in triggering immediately.
@@ -1361,14 +1364,9 @@ HerderImpl::triggerAnchorFromConsensusCloseTime(
13611364
return fallbackToPrepareStart();
13621365
}
13631366

1364-
// Compare elapsed time on the externalized closeTime timeline with elapsed
1365-
// time on our local prepare-start timeline.
1366-
// Relation, with drift > 0 meaning our clock is ahead of network time:
1367-
//
1368-
// timeSinceNetworkLedgerStart
1369-
// = nominationBudget + timeSinceLocalBallotStart + drift
1370-
//
1371-
// where nominationBudget is the slow-nomination allowance described above.
1367+
// Compare elapsed time since the externalized closeTime with local steady
1368+
// time. Their difference includes clock drift and how early or late our
1369+
// trigger fired relative to the proposer's close time.
13721370
auto externalizedSystemTime = consensusCloseTime.toSystemTime();
13731371
auto currentSystemTime = mApp.getClock().system_now();
13741372
auto timeSinceNetworkLedgerStart =
@@ -1381,29 +1379,39 @@ HerderImpl::triggerAnchorFromConsensusCloseTime(
13811379
std::chrono::duration_cast<std::chrono::milliseconds>(now -
13821380
localBallotStart);
13831381

1382+
auto nominationBudget =
1383+
mHerderSCPDriver.getTriggerToBallotDuration(lastIndex);
1384+
auto logFallback = [&](char const* reason) {
1385+
auto zero = std::chrono::milliseconds::zero();
1386+
CLOG_INFO(
1387+
Herder,
1388+
"Trigger fallback after ledger {}: {}, network elapsed {} "
1389+
"ms, trigger-to-ballot {} ms, ballot elapsed {} ms, local "
1390+
"wait {} ms, network wait {} ms",
1391+
lastIndex, reason, timeSinceNetworkLedgerStart.count(),
1392+
nominationBudget.count(), timeSinceLocalBallotStart.count(),
1393+
std::max(zero, expectedClose - timeSinceLocalBallotStart).count(),
1394+
std::max(zero, expectedClose - timeSinceNetworkLedgerStart)
1395+
.count());
1396+
return fallbackToPrepareStart();
1397+
};
1398+
13841399
// Scenario 2: if system time is behind the local prepare-start timer, the
13851400
// network-based anchor can wedge the node, so use the local fallback.
13861401
if (timeSinceLocalBallotStart > timeSinceNetworkLedgerStart)
13871402
{
1388-
return fallbackToPrepareStart();
1389-
}
1390-
1391-
// Scenario 1: widen the ahead-drift bound by the slow nomination we can
1392-
// explain from the previous slot's timeout count.
1393-
auto nominationTimeouts =
1394-
mHerderSCPDriver.getNominationTimeouts(lastIndex).value_or(0);
1395-
auto nominationBudget = std::chrono::milliseconds::zero();
1396-
for (int64_t round = 1; round <= nominationTimeouts; ++round)
1397-
{
1398-
nominationBudget += mHerderSCPDriver.computeTimeout(
1399-
static_cast<uint32_t>(round), /*isNomination=*/true);
1403+
return logFallback("clock behind");
14001404
}
14011405

1406+
// A timeout counts only after its callback runs, and none are counted
1407+
// after entry into ballot. A slow fetch or busy main thread can therefore
1408+
// delay nomination without increasing the count. Measure elapsed time
1409+
// directly instead of approximating it with completed timeout durations.
14021410
// Scenario 1: if elapsed system time exceeds target plus explainable
14031411
// nomination delay, treat it as clock-ahead drift and use the fallback.
14041412
if (timeSinceNetworkLedgerStart > expectedClose + nominationBudget)
14051413
{
1406-
return fallbackToPrepareStart();
1414+
return logFallback("clock ahead or slow ballot/apply");
14071415
}
14081416

14091417
return now - timeSinceNetworkLedgerStart;
@@ -1864,6 +1872,8 @@ HerderImpl::triggerNextLedger(uint32_t ledgerSeqToTrigger,
18641872
return;
18651873
}
18661874

1875+
mHerderSCPDriver.recordNominationTrigger(ledgerSeqToTrigger);
1876+
18671877
// We pick as next close time the current time unless it's before the last
18681878
// close time. We don't know how much time it will take to reach consensus
18691879
// so this is the most appropriate value to use as closeTime.

src/herder/HerderSCPDriver.cpp

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,16 @@ HerderSCPDriver::getExternalizeLag(NodeID const& id) const
15221522
return n->second.GetSnapshot().get75thPercentile();
15231523
}
15241524

1525+
void
1526+
HerderSCPDriver::recordNominationTrigger(uint64_t slotIndex)
1527+
{
1528+
auto& timing = mSCPExecutionTimes[slotIndex];
1529+
if (!timing.mTriggerStart && !timing.mNominationStart)
1530+
{
1531+
timing.mTriggerStart = mApp.getClock().now();
1532+
}
1533+
}
1534+
15251535
void
15261536
HerderSCPDriver::recordSCPEvent(uint64_t slotIndex, bool isNomination)
15271537
{
@@ -1531,8 +1541,10 @@ HerderSCPDriver::recordSCPEvent(uint64_t slotIndex, bool isNomination)
15311541

15321542
if (isNomination)
15331543
{
1534-
timing.mNominationStart =
1535-
std::make_optional<VirtualClock::time_point>(start);
1544+
if (!timing.mNominationStart)
1545+
{
1546+
timing.mNominationStart = start;
1547+
}
15361548
}
15371549
else
15381550
{
@@ -2042,6 +2054,25 @@ HerderSCPDriver::getNominationTimeouts(uint64_t slotIndex) const
20422054
return std::nullopt;
20432055
}
20442056

2057+
std::chrono::milliseconds
2058+
HerderSCPDriver::getTriggerToBallotDuration(uint64_t slotIndex) const
2059+
{
2060+
auto it = mSCPExecutionTimes.find(slotIndex);
2061+
if (it != mSCPExecutionTimes.end())
2062+
{
2063+
auto const& timing = it->second;
2064+
if (timing.mTriggerStart && timing.mPrepareStart &&
2065+
*timing.mPrepareStart > *timing.mTriggerStart)
2066+
{
2067+
// Stop at ballot even if a local proposal is still being built.
2068+
// Ballot and apply are already covered by the fallback anchor.
2069+
return std::chrono::duration_cast<std::chrono::milliseconds>(
2070+
*timing.mPrepareStart - *timing.mTriggerStart);
2071+
}
2072+
}
2073+
return std::chrono::milliseconds::zero();
2074+
}
2075+
20452076
void
20462077
HerderSCPDriver::markSlotAsRestored(uint64_t slotIndex)
20472078
{

src/herder/HerderSCPDriver.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class HerderSCPDriver : public SCPDriver
5757
}
5858

5959
void recordSCPExecutionMetrics(uint64_t slotIndex);
60+
void recordNominationTrigger(uint64_t slotIndex);
6061
void recordSCPEvent(uint64_t slotIndex, bool isNomination);
6162
void recordSCPExternalizeEvent(uint64_t slotIndex, NodeID const& id,
6263
bool forceUpdateSelf);
@@ -218,6 +219,12 @@ class HerderSCPDriver : public SCPDriver
218219
// Get the number of nomination timeouts that occurred for a given slot
219220
std::optional<int64_t> getNominationTimeouts(uint64_t slotIndex) const;
220221

222+
// Elapsed local time from the trigger through entry into ballot, measured
223+
// on the steady clock. Includes construction and incomplete nomination
224+
// rounds. Missing timing history does not establish any allowance.
225+
std::chrono::milliseconds
226+
getTriggerToBallotDuration(uint64_t slotIndex) const;
227+
221228
#ifdef BUILD_TESTS
222229
RandomEvictionCache<TxSetValidityKey, bool, TxSetValidityKeyHash>&
223230
getTxSetValidityCache()
@@ -298,6 +305,7 @@ class HerderSCPDriver : public SCPDriver
298305

299306
struct SCPTiming
300307
{
308+
std::optional<VirtualClock::time_point> mTriggerStart;
301309
std::optional<VirtualClock::time_point> mNominationStart;
302310
std::optional<VirtualClock::time_point> mPrepareStart;
303311

src/herder/test/HerderTests.cpp

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6535,6 +6535,13 @@ namespace stellar
65356535
class EarlyNominationTestAccess
65366536
{
65376537
public:
6538+
static VirtualClock::time_point
6539+
triggerAnchor(HerderImpl& herder, uint64_t slot)
6540+
{
6541+
return herder.triggerAnchorFromConsensusCloseTime(
6542+
slot, herder.mApp.getClock().now(), std::chrono::seconds(2));
6543+
}
6544+
65386545
static TxSetXDRFrameConstPtr
65396546
prepared(HerderImpl& herder)
65406547
{
@@ -6561,6 +6568,195 @@ class EarlyNominationTestAccess
65616568
};
65626569
}
65636570

6571+
TEST_CASE("trigger fallback credits elapsed time through nomination",
6572+
"[herder][trigger-construction]")
6573+
{
6574+
using namespace std::chrono;
6575+
struct Scenario
6576+
{
6577+
int buildMs;
6578+
int ballotMs;
6579+
int closeMs;
6580+
int clockOffsetMs;
6581+
bool recordTrigger;
6582+
int expectedWaitMs;
6583+
int expectedFallbacks;
6584+
};
6585+
auto scenario =
6586+
GENERATE(Scenario{350, 1900, 2100, 0, true, 0, 0},
6587+
Scenario{350, 1900, 2100, 4000, true, 1800, 1},
6588+
Scenario{350, 1900, 2100, -4000, true, 1800, 1},
6589+
Scenario{350, 1900, 4100, 0, true, 0, 1},
6590+
Scenario{350, 1900, 2100, 0, false, 1800, 1},
6591+
Scenario{350, 1500, 1700, 0, true, 300, 0},
6592+
// A slow first round need not execute a timeout callback
6593+
// before entering ballot. Its elapsed time is still real.
6594+
Scenario{100, 2000, 2250, 0, true, 0, 0},
6595+
// Followers need no construction, but still wait on peers.
6596+
Scenario{0, 2250, 2450, 0, true, 0, 0},
6597+
// Delayed timer callbacks must not hide elapsed time.
6598+
Scenario{100, 6500, 6700, 0, true, 0, 0},
6599+
// Long nomination does not excuse arbitrary clock drift.
6600+
Scenario{100, 6500, 6700, 10000, true, 1800, 1},
6601+
Scenario{100, 6500, 6700, -10000, true, 1800, 1});
6602+
CAPTURE(scenario.buildMs, scenario.closeMs, scenario.clockOffsetMs,
6603+
scenario.recordTrigger);
6604+
VirtualClock clock;
6605+
auto cfg = getTestConfig();
6606+
cfg.HTTP_PORT = 0;
6607+
cfg.MANUAL_CLOSE = true;
6608+
auto app = createTestApplication(clock, cfg);
6609+
auto& herder = static_cast<HerderImpl&>(app->getHerder());
6610+
auto& driver = herder.getHerderSCPDriver();
6611+
auto const slot = app->getLedgerManager().getLastClosedLedgerNum();
6612+
auto const ct = VirtualClock::to_time_t(clock.system_now()) + 1000;
6613+
auto const origin = VirtualClock::from_time_t(ct);
6614+
clock.setCurrentVirtualTime(origin);
6615+
StellarValue value;
6616+
value.closeTime = ct;
6617+
herder.setTrackingSCPState(slot, value, true);
6618+
if (scenario.recordTrigger)
6619+
{
6620+
driver.recordNominationTrigger(slot);
6621+
}
6622+
clock.setCurrentVirtualTime(origin + milliseconds(scenario.buildMs));
6623+
driver.recordSCPEvent(slot, true);
6624+
clock.setCurrentVirtualTime(origin + milliseconds(scenario.ballotMs));
6625+
driver.recordSCPEvent(slot, false);
6626+
clock.setCurrentVirtualTime(origin + milliseconds(scenario.closeMs));
6627+
clock.setSystemTimeOffset(milliseconds(scenario.clockOffsetMs));
6628+
REQUIRE(driver.getNominationTimeouts(slot).value() == 0);
6629+
REQUIRE(driver.getTriggerToBallotDuration(slot) ==
6630+
milliseconds(scenario.recordTrigger ? scenario.ballotMs : 0));
6631+
REQUIRE(driver.getTriggerToBallotDuration(slot + 1) ==
6632+
milliseconds::zero());
6633+
auto metrics = app->getMetrics().GetAllMetrics();
6634+
auto const& fallback = dynamic_cast<medida::Meter const&>(
6635+
*metrics.at({"scp", "trigger", "prepare-start-fallback"}));
6636+
auto const before = fallback.count();
6637+
auto deadline = std::max(
6638+
clock.now(),
6639+
EarlyNominationTestAccess::triggerAnchor(herder, slot) + seconds(2));
6640+
REQUIRE(duration_cast<milliseconds>(deadline - clock.now()).count() ==
6641+
scenario.expectedWaitMs);
6642+
REQUIRE(fallback.count() - before == scenario.expectedFallbacks);
6643+
}
6644+
6645+
TEST_CASE("trigger work is recorded by the proposal path",
6646+
"[herder][trigger-construction]")
6647+
{
6648+
using namespace std::chrono;
6649+
VirtualClock clock;
6650+
auto cfg = getTestConfig();
6651+
cfg.HTTP_PORT = 0;
6652+
cfg.MANUAL_CLOSE = true;
6653+
cfg.QUORUM_SET.threshold = 2;
6654+
cfg.QUORUM_SET.validators = {
6655+
cfg.NODE_SEED.getPublicKey(),
6656+
SecretKey::pseudoRandomForTesting().getPublicKey()};
6657+
auto app = createTestApplication(clock, cfg);
6658+
auto& herder = static_cast<HerderImpl&>(app->getHerder());
6659+
auto& driver = herder.getHerderSCPDriver();
6660+
auto const slot = app->getLedgerManager().getLastClosedLedgerNum() + 1;
6661+
herder.mGetTopTransactionsForTesting = [&](size_t) {
6662+
// A wall-clock adjustment must not change the measured construction
6663+
// duration or replace it with a negative interval.
6664+
clock.setCurrentVirtualTime(clock.now() + milliseconds(400));
6665+
clock.setSystemTimeOffset(seconds(-1));
6666+
return std::vector<TransactionEnvelope>{};
6667+
};
6668+
herder.triggerNextLedger(slot, true);
6669+
// Until ballot starts there is no completed interval to credit.
6670+
REQUIRE(driver.getTriggerToBallotDuration(slot) == milliseconds::zero());
6671+
clock.setCurrentVirtualTime(clock.now() + milliseconds(100));
6672+
driver.recordSCPEvent(slot, false);
6673+
REQUIRE(driver.getTriggerToBallotDuration(slot) == milliseconds(500));
6674+
clock.setCurrentVirtualTime(clock.now() + seconds(1));
6675+
driver.recordNominationTrigger(slot);
6676+
driver.recordSCPEvent(slot, true);
6677+
REQUIRE(driver.getTriggerToBallotDuration(slot) == milliseconds(500));
6678+
herder.mGetTopTransactionsForTesting = nullptr;
6679+
}
6680+
6681+
TEST_CASE("trigger work excludes time after entering ballot",
6682+
"[herder][trigger-construction]")
6683+
{
6684+
using namespace std::chrono;
6685+
VirtualClock clock;
6686+
auto cfg = getTestConfig();
6687+
cfg.HTTP_PORT = 0;
6688+
cfg.MANUAL_CLOSE = true;
6689+
auto app = createTestApplication(clock, cfg);
6690+
auto& driver =
6691+
static_cast<HerderImpl&>(app->getHerder()).getHerderSCPDriver();
6692+
uint64_t const slot = 100;
6693+
driver.recordNominationTrigger(slot);
6694+
clock.setCurrentVirtualTime(clock.now() + milliseconds(100));
6695+
driver.recordSCPEvent(slot, false);
6696+
clock.setCurrentVirtualTime(clock.now() + milliseconds(300));
6697+
driver.recordSCPEvent(slot, true);
6698+
REQUIRE(driver.getTriggerToBallotDuration(slot) == milliseconds(100));
6699+
driver.recordSCPEvent(slot + 1, false);
6700+
clock.setCurrentVirtualTime(clock.now() + milliseconds(100));
6701+
driver.recordNominationTrigger(slot + 1);
6702+
driver.recordSCPEvent(slot + 1, true);
6703+
REQUIRE(driver.getTriggerToBallotDuration(slot + 1) ==
6704+
milliseconds::zero());
6705+
}
6706+
6707+
TEST_CASE("trigger allowance is independent of timeout callback ordering",
6708+
"[herder][trigger-construction]")
6709+
{
6710+
using namespace std::chrono;
6711+
auto const ballotBeforeTimeout = GENERATE(false, true);
6712+
VirtualClock clock;
6713+
auto cfg = getTestConfig();
6714+
cfg.HTTP_PORT = 0;
6715+
cfg.MANUAL_CLOSE = true;
6716+
auto app = createTestApplication(clock, cfg);
6717+
auto& herder = static_cast<HerderImpl&>(app->getHerder());
6718+
auto& driver = herder.getHerderSCPDriver();
6719+
auto const slot = herder.nextConsensusLedgerIndex();
6720+
auto const ct = VirtualClock::to_time_t(clock.system_now()) + 1;
6721+
auto const origin = VirtualClock::from_time_t(ct);
6722+
clock.setCurrentVirtualTime(origin);
6723+
driver.recordNominationTrigger(slot);
6724+
clock.setCurrentVirtualTime(origin + milliseconds(100));
6725+
driver.recordSCPEvent(slot, true);
6726+
bool timerFired = false;
6727+
driver.setupTimer(slot, Slot::NOMINATION_TIMER, seconds(2),
6728+
[&] { timerFired = true; });
6729+
6730+
// Both events become runnable while the main thread is occupied. Entering
6731+
// ballot before dispatching the expired timer suppresses its timeout count.
6732+
clock.setCurrentVirtualTime(origin + milliseconds(2500));
6733+
if (ballotBeforeTimeout)
6734+
{
6735+
driver.recordSCPEvent(slot, false);
6736+
}
6737+
for (int i = 0; i < 100 && !timerFired; ++i)
6738+
{
6739+
clock.crank(false);
6740+
}
6741+
REQUIRE(timerFired);
6742+
REQUIRE(clock.system_now() == origin + milliseconds(2500));
6743+
if (!ballotBeforeTimeout)
6744+
{
6745+
driver.recordSCPEvent(slot, false);
6746+
}
6747+
REQUIRE(driver.getNominationTimeouts(slot) ==
6748+
(ballotBeforeTimeout ? 0 : 1));
6749+
REQUIRE(driver.getTriggerToBallotDuration(slot) == milliseconds(2500));
6750+
6751+
StellarValue value;
6752+
value.closeTime = ct;
6753+
herder.setTrackingSCPState(slot, value, true);
6754+
clock.setCurrentVirtualTime(origin + milliseconds(2700));
6755+
REQUIRE(EarlyNominationTestAccess::triggerAnchor(herder, slot) +
6756+
seconds(2) <=
6757+
clock.now());
6758+
}
6759+
65646760
TEST_CASE("prepare nomination before trigger", "[herder][early-nomination]")
65656761
{
65666762
auto const soroban = GENERATE(false, true);

0 commit comments

Comments
 (0)