Skip to content

Commit 0340fcd

Browse files
fix: bound DMN list/diff caches to stop getmnlistd memory exhaustion
Keep a hard cap on recent masternode lists and diffs so unauthenticated historical GETMNLISTDIFF traffic cannot grow memory without bound between blocks. Add a small LRU tier for stale mini-snapshots so repeated requests for heights older than the recency window stay cheap after first warm-up, instead of re-walking up to ~575 diffs under cs_main on every call. Extend the unit test past the recency and diff-cap windows, and document the change in release notes.
1 parent 416294a commit 0340fcd

4 files changed

Lines changed: 119 additions & 25 deletions

File tree

doc/release-notes-7485.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
P2P and network
2+
---------------
3+
4+
- Bound in-memory masternode list caches so unauthenticated historical
5+
`GETMNLISTDIFF` requests can no longer grow memory without limit between
6+
blocks. Recent lists are capped by height-aware eviction; stale historical
7+
mini-snapshots are kept in a small LRU cache so repeated requests do not
8+
re-read large on-disk snapshots on every call. (#7485)

src/evo/deterministicmns.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,7 @@ bool CDeterministicMNManager::UndoBlock(gsl::not_null<const CBlockIndex*> pindex
742742

743743
mnListsCache.erase(blockHash);
744744
mnListDiffsCache.erase(blockHash);
745+
EraseStaleList(blockHash);
745746
}
746747
if (diff.HasChanges()) {
747748
CDeterministicMNList curList{prevList};
@@ -826,6 +827,7 @@ void CDeterministicMNManager::CacheMNList(const uint256& block_hash, const CDete
826827
{
827828
AssertLockHeld(cs);
828829
if (!ShouldRetainCacheHeight(list.GetHeight())) {
830+
CacheStaleMNList(block_hash, list);
829831
return;
830832
}
831833
// Prefer emplace over assign: CDeterministicMNList::operator= locks m_cached_sml_mutex
@@ -836,6 +838,46 @@ void CDeterministicMNManager::CacheMNList(const uint256& block_hash, const CDete
836838
}
837839
}
838840

841+
void CDeterministicMNManager::CacheStaleMNList(const uint256& block_hash, const CDeterministicMNList& list)
842+
{
843+
AssertLockHeld(cs);
844+
if (auto it = mnStaleListsCache.find(block_hash); it != mnStaleListsCache.end()) {
845+
mnStaleListsLru.splice(mnStaleListsLru.begin(), mnStaleListsLru, it->second.lru_it);
846+
return;
847+
}
848+
mnStaleListsLru.push_front(block_hash);
849+
mnStaleListsCache.emplace(block_hash, StaleListEntry{list, mnStaleListsLru.begin()});
850+
while (mnStaleListsCache.size() > MAX_STALE_CACHE_LISTS) {
851+
const uint256& evict_hash = mnStaleListsLru.back();
852+
if (auto evict_it = mnStaleListsCache.find(evict_hash); evict_it != mnStaleListsCache.end()) {
853+
mnStaleListsCache.erase(evict_it);
854+
}
855+
mnStaleListsLru.pop_back();
856+
}
857+
}
858+
859+
std::optional<CDeterministicMNList> CDeterministicMNManager::GetStaleList(const uint256& block_hash)
860+
{
861+
AssertLockHeld(cs);
862+
const auto it = mnStaleListsCache.find(block_hash);
863+
if (it == mnStaleListsCache.end()) {
864+
return std::nullopt;
865+
}
866+
mnStaleListsLru.splice(mnStaleListsLru.begin(), mnStaleListsLru, it->second.lru_it);
867+
return it->second.list;
868+
}
869+
870+
void CDeterministicMNManager::EraseStaleList(const uint256& block_hash)
871+
{
872+
AssertLockHeld(cs);
873+
const auto it = mnStaleListsCache.find(block_hash);
874+
if (it == mnStaleListsCache.end()) {
875+
return;
876+
}
877+
mnStaleListsLru.erase(it->second.lru_it);
878+
mnStaleListsCache.erase(it);
879+
}
880+
839881
void CDeterministicMNManager::CacheMNListDiff(const uint256& block_hash, CDeterministicMNListDiff diff)
840882
{
841883
AssertLockHeld(cs);
@@ -868,6 +910,11 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n
868910
break;
869911
}
870912

913+
if (auto stale = GetStaleList(pindex->GetBlockHash())) {
914+
snapshot = std::move(*stale);
915+
break;
916+
}
917+
871918
if (m_evoDb.Read(std::make_pair(DB_LIST_SNAPSHOT, pindex->GetBlockHash()), snapshot)) {
872919
// Use the list; only retain it in the cache if it is tip-recent.
873920
CacheMNList(pindex->GetBlockHash(), snapshot);

src/evo/deterministicmns.h

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
#include <algorithm>
2525
#include <atomic>
2626
#include <limits>
27+
#include <list>
2728
#include <numeric>
29+
#include <optional>
2830
#include <stdexcept>
2931
#include <unordered_map>
3032
#include <utility>
@@ -746,21 +748,30 @@ class CDeterministicMNManager
746748
// Hard caps on the in-memory caches. CleanupCache() alone is not enough: it only
747749
// runs once a new block has arrived, so between blocks an unauthenticated peer
748750
// spamming getmnlistd for historical blocks could append entries without bound
749-
// (a full mainnet list is several MB). Admission is bounded two ways: entries
750-
// older than the window CleanupCache would drop anyway are not retained at all,
751-
// and these caps evict the lowest-height entries when exceeded.
751+
// (a full mainnet list is several MB). List admission uses two tiers:
752+
// - Recent tier (mnListsCache): tip-recent heights only, lowest-height-first
753+
// eviction, cap MAX_CACHE_LISTS. Attacker stale traffic never enters here.
754+
// - Stale tier (mnStaleListsCache): LRU, cap MAX_STALE_CACHE_LISTS. Keeps a
755+
// bounded working set of historical mini-snapshots so repeated stale
756+
// getmnlistd requests do not re-read a multi-MB disk snapshot on every call.
752757
// Lists are rebuilt by applying up to DISK_SNAPSHOT_PERIOD - 1 diffs from the
753758
// previous on-disk snapshot, so block validation / invalidation spanning a
754759
// snapshot boundary can legitimately keep up to two snapshot periods of lists
755-
// (per-block lists plus mini-snapshots) resident. Size the cap to hold that
756-
// whole window so bounding admission never slows the (dis)connect hot path;
760+
// (per-block lists plus mini-snapshots) resident. Size the recent cap to hold
761+
// that whole window so bounding admission never slows the (dis)connect hot path;
757762
// it is well above honest steady-state usage (tip + live quorum bases +
758763
// mini-snapshots within LIST_DIFFS_CACHE_SIZE of the tip).
759764
static constexpr size_t MAX_CACHE_LISTS = static_cast<size_t>(DISK_SNAPSHOT_PERIOD) * 2;
765+
// One 576-block snapshot interval of mini-snapshots (18) plus margin.
766+
static constexpr size_t MAX_STALE_CACHE_LISTS = 32;
760767
// Diffs are small; allow a full recency window plus a margin for one rebuild walk.
761768
static constexpr size_t MAX_CACHE_DIFFS = static_cast<size_t>(LIST_DIFFS_CACHE_SIZE) + 64;
762769

763770
private:
771+
struct StaleListEntry {
772+
CDeterministicMNList list;
773+
std::list<uint256>::iterator lru_it;
774+
};
764775
Mutex cs;
765776
Mutex cs_cleanup;
766777
// We have performed CleanupCache() on this height.
@@ -774,6 +785,8 @@ class CDeterministicMNManager
774785

775786
Uint256HashMap<CDeterministicMNList> mnListsCache GUARDED_BY(cs);
776787
Uint256HashMap<CDeterministicMNListDiff> mnListDiffsCache GUARDED_BY(cs);
788+
std::list<uint256> mnStaleListsLru GUARDED_BY(cs);
789+
Uint256HashMap<StaleListEntry> mnStaleListsCache GUARDED_BY(cs);
777790
const CBlockIndex* tipIndex GUARDED_BY(cs) {nullptr};
778791
const CBlockIndex* m_initial_snapshot_index GUARDED_BY(cs) {nullptr};
779792

@@ -808,6 +821,11 @@ class CDeterministicMNManager
808821
LOCK(cs);
809822
return mnListDiffsCache.size();
810823
}
824+
size_t GetStaleListCacheSize() EXCLUSIVE_LOCKS_REQUIRED(!cs)
825+
{
826+
LOCK(cs);
827+
return mnStaleListsCache.size();
828+
}
811829

812830
// Test if given TX is a ProRegTx which also contains the collateral at index n
813831
static bool IsProTxWithCollateral(const CTransactionRef& tx, uint32_t n);
@@ -852,6 +870,9 @@ class CDeterministicMNManager
852870
[[nodiscard]] bool ShouldRetainCacheHeight(int height) EXCLUSIVE_LOCKS_REQUIRED(cs);
853871
void CacheMNList(const uint256& block_hash, const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(cs);
854872
void CacheMNListDiff(const uint256& block_hash, CDeterministicMNListDiff diff) EXCLUSIVE_LOCKS_REQUIRED(cs);
873+
void CacheStaleMNList(const uint256& block_hash, const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(cs);
874+
[[nodiscard]] std::optional<CDeterministicMNList> GetStaleList(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(cs);
875+
void EraseStaleList(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(cs);
855876
void EnforceListsCacheLimit() EXCLUSIVE_LOCKS_REQUIRED(cs);
856877
void EnforceDiffsCacheLimit() EXCLUSIVE_LOCKS_REQUIRED(cs);
857878

src/test/evo_deterministicmns_tests.cpp

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3102,20 +3102,18 @@ BOOST_AUTO_TEST_CASE(mn_lists_cache_bounded)
31023102
dmnman.UpdatedBlockTip(tip_index());
31033103
dmnman.DoMaintenance();
31043104

3105-
// Mine more than the hard cap without running cleanup — mirrors the
3106-
// attacker window between blocks when getmnlistd populates the cache.
3107-
constexpr size_t n_blocks = CDeterministicMNManager::MAX_CACHE_LISTS + 64;
3105+
// Mine past the recency window and diff cap without running cleanup — mirrors
3106+
// the attacker window between blocks when getmnlistd populates the cache.
3107+
constexpr size_t recency_window = CDeterministicMNManager::MAX_CACHE_DIFFS - 64;
3108+
constexpr size_t n_blocks = recency_window + (40 * 32);
31083109
for (size_t i = 0; i < n_blocks; ++i) {
31093110
setup.CreateAndProcessBlock({}, coinbase_pk);
31103111
dmnman.UpdatedBlockTip(tip_index());
31113112
}
31123113

31133114
// Record the expected list for a spread of historical heights, and for every
3114-
// height in the lowest 64 of the range. Eviction is lowest-height-first, so
3115-
// after the descending sweep below (which touches MAX_CACHE_LISTS newer
3116-
// heights after each of these), the lowest-64 entries are guaranteed to have
3117-
// been evicted; re-querying them proves eviction never changes what is
3118-
// returned.
3115+
// height in the lowest 64 of the range. Eviction is lowest-height-first in
3116+
// the recent tier; stale heights land in the LRU stale tier instead.
31193117
const CBlockIndex* tip = tip_index();
31203118
BOOST_REQUIRE(tip != nullptr);
31213119
const int lowest_height = tip->nHeight - static_cast<int>(n_blocks) + 1;
@@ -3133,30 +3131,50 @@ BOOST_AUTO_TEST_CASE(mn_lists_cache_bounded)
31333131
}
31343132
BOOST_REQUIRE(expected.size() > 64);
31353133

3136-
// Now exercise GetListForBlock over every distinct historical height — the
3134+
// Exercise GetListForBlock over every distinct historical height — the
31373135
// getmnlistd / BuildSimplifiedMNListDiff path an unauthenticated peer drives.
31383136
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); --h) {
31393137
const CBlockIndex* pindex = tip->GetAncestor(h);
31403138
BOOST_REQUIRE(pindex != nullptr);
31413139
(void)dmnman.GetListForBlock(pindex);
31423140
}
31433141

3144-
// Pre-fix: each ProcessBlock / historical load appends freely → size > MAX.
3145-
// Post-fix: insert-time retention + hard cap keep the cache bounded.
3146-
// (The diffs cache stays far below its cap here — its bound only bites on
3147-
// walks that read stale diffs back from disk — so only the lists cap is
3148-
// driven past its limit by this test.)
31493142
const size_t list_cache_size = dmnman.GetListCacheSize();
3143+
const size_t diff_cache_size = dmnman.GetListDiffsCacheSize();
3144+
const size_t stale_cache_size = dmnman.GetStaleListCacheSize();
31503145
BOOST_TEST_MESSAGE("mnListsCache size after sweep: " << list_cache_size);
3146+
BOOST_TEST_MESSAGE("mnListDiffsCache size after sweep: " << diff_cache_size);
3147+
BOOST_TEST_MESSAGE("mnStaleListsCache size after sweep: " << stale_cache_size);
3148+
31513149
BOOST_CHECK_MESSAGE(list_cache_size <= CDeterministicMNManager::MAX_CACHE_LISTS,
31523150
strprintf("mnListsCache size %zu exceeds hard cap %zu", list_cache_size,
31533151
CDeterministicMNManager::MAX_CACHE_LISTS));
3154-
BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), CDeterministicMNManager::MAX_CACHE_DIFFS);
3155-
3156-
// The cache is pure memoisation: bounding it must not change any result. The
3157-
// lowest-64 entries recorded above have certainly been evicted by now, so
3158-
// they are recomputed from disk here — the values must still match what the
3159-
// cache returned above.
3152+
BOOST_CHECK_LE(diff_cache_size, CDeterministicMNManager::MAX_CACHE_DIFFS);
3153+
// n_blocks ProcessBlock admissions exceed MAX_CACHE_DIFFS; the cap must have trimmed.
3154+
BOOST_CHECK_MESSAGE(diff_cache_size >= CDeterministicMNManager::MAX_CACHE_DIFFS - 64,
3155+
strprintf("mnListDiffsCache size %zu never approached cap %zu", diff_cache_size,
3156+
CDeterministicMNManager::MAX_CACHE_DIFFS));
3157+
3158+
// Stale heights from the sweep must have been routed to the LRU stale tier.
3159+
BOOST_CHECK_GT(stale_cache_size, 0U);
3160+
BOOST_CHECK_LE(stale_cache_size, CDeterministicMNManager::MAX_STALE_CACHE_LISTS);
3161+
3162+
// Repeat access to one stale interval must be cache-served, not a full re-walk.
3163+
const int stale_target_height = tip->nHeight - static_cast<int>(recency_window) - 100;
3164+
BOOST_REQUIRE(stale_target_height >= 0);
3165+
const CBlockIndex* stale_pindex = tip->GetAncestor(stale_target_height);
3166+
BOOST_REQUIRE(stale_pindex != nullptr);
3167+
const auto stale_first = dmnman.GetListForBlock(stale_pindex);
3168+
const size_t stale_size_after_first = dmnman.GetStaleListCacheSize();
3169+
const CBlockIndex* stale_neighbor = tip->GetAncestor(stale_target_height + 16);
3170+
BOOST_REQUIRE(stale_neighbor != nullptr);
3171+
const auto stale_neighbor_result = dmnman.GetListForBlock(stale_neighbor);
3172+
const auto stale_second = dmnman.GetListForBlock(stale_pindex);
3173+
BOOST_CHECK(stale_first == stale_second);
3174+
BOOST_CHECK(stale_neighbor_result == dmnman.GetListForBlock(stale_neighbor));
3175+
BOOST_CHECK_LE(dmnman.GetStaleListCacheSize(), stale_size_after_first + 1);
3176+
3177+
// The cache is pure memoisation: bounding it must not change any result.
31603178
for (const auto& [pindex, want] : expected) {
31613179
const auto got = dmnman.GetListForBlock(pindex);
31623180
BOOST_CHECK_MESSAGE(got == want,

0 commit comments

Comments
 (0)