Skip to content

Conversation

@drebelsky
Copy link
Contributor

@drebelsky drebelsky commented Nov 25, 2025

Resolves #4902.

@drebelsky drebelsky changed the title Populate Soroban in-memory state in a background thread DRAFT: Populate Soroban in-memory state in a background thread Nov 25, 2025
@bboston7
Copy link
Contributor

Add a form of catchup that works for this use case

I think it's worth prototyping this, especially if it cleans up some of the weirdness in LedgerApplyManager.

Maybe add another explicit state to LedgerManager(Impl)

This is definitely worth doing. I don't love the implicit state tied into mBooting when we already have a State enum.

Copy link
Contributor

@marta-lokhova marta-lokhova left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a stab at this! I agree with most concerns listed in the PR description. Added some suggestions on how to address these.

// other methods accessing the stream while populating in memory soroban
// state
XDRInputFileStream stream;
stream.open(mBucket->getFilename().string());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems suspicious: race accessing the stream suggests we're using the same stream for eviction scanning and state population, which doesn't sound right.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eviction scan also opens its own stream

// Open new stream for eviction scan to not interfere with BucketListDB load
// streams
XDRInputFileStream stream{};
stream.open(mBucket->getFilename());

I believe this races with getEntryAtOffset.

// more and let the node gracefully go into catchup.
releaseAssert(mLastQueuedToApply >= lcl);
if (nextToApply - lcl >= MAX_EXTERNALIZE_LEDGER_APPLY_DRIFT)
if (nextToApply - lcl >= MAX_EXTERNALIZE_LEDGER_APPLY_DRIFT && false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&& false disables the condition, so this should be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was a temporary hack so that we don't fall into long form catchup after populating in-memory state.

ZoneScoped;
if (mApp.getLedgerManager().isBooting())
{
mApp.postOnBackgroundThread(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the time we get to applyLedger, we should never be in rebuilding state. Applying ledger enforces that the state is complete and valid. Let's implement the wait in specific places, where we anticipate core to be in rebuilding state. Specifically, on startup (loadLastKnownLedger) and when catchup is done (setLastClosedLedger).

Config const& config);

State mState;
bool mIsBooting{false};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please introduce a new state to State enum instead of this bool.

Copy link
Contributor

@marta-lokhova marta-lokhova Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably good to define/enforce valid state machine transitions as well: booting -> sync, sync <-> catchup, boot -> catchup, boot -> rebuild, catchup -> rebuild, sync <-> rebuild.


State mState;
bool mIsBooting{false};
std::condition_variable mBootingCV;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mutex and cv aren't needed here: LM posts rebuild task to background, then background posts a callback to main. The callback can set LM state back to either "synced" or "catching up". Then LedgerApplyManager can apply all buffered ledgers whenever a new buffered ledger comes in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, these are only here for the waitForBoot that ApplyLedgerWork was using, but rethinking that path, anyway.

assertSetupPhase();
// We don't use assertSetupPhase() because we don't expect the thread
// invariant to hold
releaseAssert(mPhase == Phase::SETTING_UP_STATE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the case, assertSetupPhase should be changed to support the new feature.

mBootingLock.unlock();
mApp.postOnBackgroundThread(
[=] {
mApplyState.populateInMemorySorobanState(snapshot,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should ensure that mApplyState is never touched while LM is in rebuilding state. I think markEndOfSetupPhase already does that, but wanted to confirm.

if (mApp.getLedgerManager().isBooting())
{
mTMP_TODO = true;
return ProcessLedgerResult::WAIT_TO_APPLY_BUFFERED_OR_CATCHUP;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New state in ProcessLedgerResult is needed: WAIT_FOR_STATE_REBUILD or something like that.

mSyncingLedgers.emplace(lastReceivedLedgerSeq, ledgerData);
mLargestLedgerSeqHeard =
std::max(mLargestLedgerSeqHeard, lastReceivedLedgerSeq);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mTMP_TODO is not needed: you can anchor the replay condition on LM's state. Specifically, we should never ever attempt ledger replay when LM is rebuilding. Could we add an early exit here to and log something like "not attempting application: LM is rebuilding".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was so we could hit the fast catchup path in the case where we're populating in-memory state. Named poorly (because it was pretty hacky), but it is measuring whether we were booting in the last call to this method. This way the first time we are able to apply ledgers, we try to: right now that path (tryApplySyncingLedgers) is only hit when we receive the ledger that's next in line (to handle holes in reception). In this version, I didn't put the early exit just to keep the same mSyncingLedgers log message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see - yeah let's clean this up. I think simplest would be to make tryApplySyncingLedgers no-op when there are gaps and always call it anyway (it might already be doing that).

mBootingLock.lock();
mIsBooting = true;
mBootingLock.unlock();
mApp.postOnBackgroundThread(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that using low-priority thread isn't right here. How about ledgerApplyThread? We should never be applying during rebuild anyway.

@drebelsky
Copy link
Contributor Author

drebelsky commented Dec 5, 2025

Still thinking on the state machine. In particular, mShouldReportOnMain and the difference between LM_BOOTING_STATE and LM_BOOTING_CATCHUP_STATE feels suboptimal. The main complication comes from offline catchup.

// to apply mSyncingLedgers and possibly get back in sync
if (!mCatchupWork && lastReceivedLedgerSeq == *mLastQueuedToApply + 1)
if (!mCatchupWork &&
mSyncingLedgers.begin()->first == *mLastQueuedToApply + 1 &&
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this condition is quite right, although the previous condition also doesn't work if we want to apply the LedgerApplyManagerImpl ledgers once we've gone from BOOTING to BOOTED

@drebelsky
Copy link
Contributor Author

Putting some notes on the various states. Fundamentally, while we're rebuilding/populating in-memory Soroban state, we don't want to apply. There are some considerations around what to do when certain transitions happen when we are still populating the in-memory state (e.g., what should we do if we're catching up at the time). The two previous commits do two different takes on this. I think both are somewhat incomplete, although, it is perhaps also worth considering what set if this state is "internal" to LedgerManagerImpl and shouldn't (necessarily) be exposed via getState() (e.g., the distinction between the various phases of boot)

Places where setState is called:

  • LedgerManagerImpl::moveToSynced: outside of tests, called in
    • LedgerManagerImpl::ledgerCloseComplete (shouldn't happen if we're not applying)
    • HerderImpl::bootstrap when mConfig.FORCE_SCP is set, I'm not quite sure how this should interact with what's there
    • HerderImpl::setInSyncAndTriggerNextLedger (called in ApplicationImpl::manualClose when mConfig.FORCE_SCP and mConfig.MANUAL_CLOSE are unset)
  • LedgerManagerImpl::startCatchup (only called during offline catchup)
  • LedgerManagerImpl::loadLastKnownLedgerInternal (called in startup path)
  • LedgerManagerImpl::setLastClosedLedger
  // NB: this method is a sort of half-apply that runs on main thread and
  // updates LCL without apply having happened any txs. It's only relevant
  // when finishing the _bucket-apply_ phase of catchup (which is not
  // transaction-apply, it's like "load any bucket state into the DB").
  • LedgerManagerImpl::valueExternalized, when call to LedgerApplyManager::processLedger() returns ProcessLedgerResult::WAIT_TO_APPLY_BUFFERED_OR_CATCHUP

Places (outside of LedgerManagerImpl) where getState is called

  • LedgerApplyManager::processLedger
  • HerderImpl::setInSyncAndTriggerNextLedger
  • ApplicationImpl::getState
  • LedgerManager::isBooting (called in ApplyLedgerWork)
  • LedgerManager::isSynced used in
    • HerderImpl::lastClosedLedgerIncreased (under a releaseAssert)
    • HerderImpl::setupTriggerNextLedger (under a releaseAssert)
    • HerderImpl::triggerNextLedger
    • setAuthenticatedLedgerHashPair
    • Peer::recvMessage

Prior to this PR we have three states: booting, catching up, and in sync. This PR adds the BOOTED state so that we don't mark as "in sync" or "catching up" or apply ledgers until we've finished populating state. Note also that we need to distinguish between booting and booting (populating state) during catchup for LedgerApplyManagerImpl::processLedger (hence LM_BOOTING_CATCHUP_STATE).

The commit one before the current revision allows catchup to happen while we're still doing the initial rebuild, which leads to some additional complexity (mShouldReportOnMain so we don't inadvertently switch to the wrong state when the initial rebuild finishes). The current revision avoids this by never going into catch up unless we're already booted (although, this leads to some oddity in that startCatchup will still start the catch-up flow). So, I suppose one pertinent question is whether in the case that a node is offline for a while (so that it will definitely have to do catchup) if the extra delay to wait for state rebuild first is too long.

@drebelsky
Copy link
Contributor Author

drebelsky commented Dec 11, 2025

Notes on most recent commit:

  • After discussion, we chose to focus on the restart case—that is, we're only concerned about online non-bucket apply catchup for buffering ledgers (this simplifies the number of states from ~8 -> 4)
  • I left the customization of the invariant assertion in LedgerManagerImpl::ApplyState::populateInMemorySorobanState because it felt cleaner than the alternative to me (otherwise, we have to modify threadInvariant to have a reference to LedgerManager to check if the state is currently BOOTING, but mAppConnector won't let us get ledger manager when we're not on the main thread)
  • Instead of blocking startCatchup (offline catchup), Currently, waitForLedgerManager is duplicated between ApplyBucketsWork and ApplyLedgersWork. I couldn't think of a good/clean way to block on waiting for booted inside startCatchup/catchup, so I moved the logic here, instead. I wasn't sure that this made sense to refactor into a method on LedgerManager, but I'm happy to change that if others think that's better. Also worth noting that practically, the case shouldn't get hit in ApplyBucketsWork (if we're applying buckets, we're starting from genesis, and downloading/verifying buckets should be more expensive than populating 0 in-memory state).
  • I couldn't recall what we had decided to do for LedgerApplyManagerImpl::MAX_EXTERNALIZE_LEDGER_APPLY_DRIFT

@drebelsky drebelsky marked this pull request as ready for review December 11, 2025 20:43
@drebelsky drebelsky changed the title DRAFT: Populate Soroban in-memory state in a background thread Populate Soroban in-memory state in a background thread Dec 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core restarts are slow and result in catchup due to core in-memory state populating on startup

3 participants