Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ BITCOIN_TESTS =\
test/txvalidation_tests.cpp \
test/txvalidationcache_tests.cpp \
test/uint256_tests.cpp \
test/unordered_lru_cache_tests.cpp \
test/util_tests.cpp \
test/validation_block_tests.cpp \
test/validation_chainstate_tests.cpp \
Expand Down
12 changes: 6 additions & 6 deletions src/chainlock/handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ class ChainlockHandler final : public CValidationInterface
//! Number of recently seen CLSIG hashes retained once `seenChainLocks` is pruned.
static constexpr size_t SEEN_CHAINLOCKS_RETAINED_SIZE{1024};
//! Size `seenChainLocks` may grow to before the next insertion prunes it back down to
//! SEEN_CHAINLOCKS_RETAINED_SIZE. Pruning sorts every entry, and CLSIG hashes are recorded
//! before the signature is verified, so pruning on each insertion past the retained size
//! lets a peer turn a stream of unique CLSIG hashes into a stream of O(n log n) sorts under
//! `cs`. Pruning only after twice the retained size amortises that cost over the entries
//! dropped in a single batch, at the price of a larger transient cache. The 2x ratio matches
//! the default in unordered_lru_cache.
//! SEEN_CHAINLOCKS_RETAINED_SIZE. Pruning partitions every entry, and CLSIG hashes are
//! recorded before the signature is verified, so pruning on each insertion past the retained
//! size lets a peer turn a stream of unique CLSIG hashes into a stream of full-map partition
//! passes under `cs`. Pruning only after twice the retained size amortises that cost over the
//! entries dropped in a single batch, at the price of a larger transient cache. The 2x ratio
//! matches the default in unordered_lru_cache.
static constexpr size_t SEEN_CHAINLOCKS_PRUNE_AFTER_SIZE{2 * SEEN_CHAINLOCKS_RETAINED_SIZE};

const CBlockIndex* lastNotifyChainLockBlockIndex GUARDED_BY(cs){nullptr};
Expand Down
26 changes: 13 additions & 13 deletions src/limitedmap.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ class unordered_limitedmap
public:
//! nMaxSizeIn is the number of elements retained after a prune. nPruneAfterSizeIn is the size
//! the map may grow to before the next insertion prunes it; it defaults to nMaxSizeIn, which
//! means prune() -- and therefore a sort of every element -- runs on *every* insertion past
//! nMaxSizeIn. Callers whose keys are attacker-supplied should pass a larger value (e.g.
//! 2 * nMaxSizeIn) so that sorting is amortised over a batch of evictions instead.
//! means prune() -- and therefore a partition of every element -- runs on *every* insertion
//! past nMaxSizeIn. Callers whose keys are attacker-supplied should pass a larger value (e.g.
//! 2 * nMaxSizeIn) so that partitioning is amortised over a batch of evictions instead.
explicit unordered_limitedmap(size_type nMaxSizeIn, size_type nPruneAfterSizeIn = 0)
{
assert(nMaxSizeIn > 0);
Expand Down Expand Up @@ -97,20 +97,20 @@ class unordered_limitedmap
return;
}

std::vector<iterator> sortedIterators;
sortedIterators.reserve(map.size());
std::vector<iterator> iterators;
iterators.reserve(map.size());
for (auto it = map.begin(); it != map.end(); ++it) {
sortedIterators.emplace_back(it);
iterators.emplace_back(it);
}
std::sort(sortedIterators.begin(), sortedIterators.end(), [](const iterator& it1, const iterator& it2) {
return it1->second < it2->second;
});

size_type tooMuch = map.size() - nMaxSize;
assert(tooMuch > 0);
sortedIterators.resize(tooMuch);
// nPruneAfterSize >= nMaxSize > 0 keeps tooMuch inside the vector, which nth_element relies on
assert(tooMuch > 0 && tooMuch < iterators.size());
// Only the entries below the eviction boundary have to be identified, their relative order does not matter
std::nth_element(iterators.begin(), iterators.begin() + tooMuch, iterators.end(),
[](const iterator& it1, const iterator& it2) { return it1->second < it2->second; });
iterators.resize(tooMuch);

for (auto& it : sortedIterators) {
for (auto& it : iterators) {
map.erase(it);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/limitedmap_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ BOOST_AUTO_TEST_CASE(limitedmap_test)
// A map constructed with a prune-after size larger than its retained size must not prune on
// every insertion past the retained size. Instead it is allowed to grow up to the prune-after
// size and is then pruned back down to the retained size in a single batch. This amortises the
// cost of prune() -- which sorts every element -- over many insertions.
// cost of prune() -- which partitions every element -- over many insertions.
BOOST_AUTO_TEST_CASE(limitedmap_prune_after_size_test)
{
constexpr int RETAINED_SIZE{10};
Expand Down
180 changes: 180 additions & 0 deletions src/test/unordered_lru_cache_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 The Dash Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#include <unordered_lru_cache.h>

#include <test/util/setup_common.h>

#include <boost/test/unit_test.hpp>

using IntCache = unordered_lru_cache<int, int, std::hash<int>>;

BOOST_FIXTURE_TEST_SUITE(unordered_lru_cache_tests, BasicTestingSetup)

BOOST_AUTO_TEST_CASE(no_truncation_below_threshold)
{
// the default truncate threshold is twice the max size
IntCache cache(10);
BOOST_CHECK_EQUAL(cache.max_size(), 10U);

for (int i = 0; i < 20; i++) {
cache.insert(i, i);
}

// reaching the threshold is not enough to trigger truncation
for (int i = 0; i < 20; i++) {
BOOST_CHECK(cache.exists(i));
}
}

BOOST_AUTO_TEST_CASE(truncation_keeps_most_recent)
{
IntCache cache(10);

// exceeding the threshold truncates down to the max size
for (int i = 0; i < 21; i++) {
cache.insert(i, i);
}

for (int i = 0; i < 21; i++) {
BOOST_CHECK_EQUAL(cache.exists(i), i >= 11);
}

// the retained values are intact
for (int i = 11; i < 21; i++) {
int value{0};
BOOST_CHECK(cache.get(i, value));
BOOST_CHECK_EQUAL(value, i);
}
}

BOOST_AUTO_TEST_CASE(truncation_honors_explicit_threshold)
{
IntCache cache(5, 6);
BOOST_CHECK_EQUAL(cache.max_size(), 5U);

for (int i = 0; i < 6; i++) {
cache.insert(i, i);
}
for (int i = 0; i < 6; i++) {
BOOST_CHECK(cache.exists(i));
}

cache.insert(6, 6);
for (int i = 0; i < 7; i++) {
BOOST_CHECK_EQUAL(cache.exists(i), i >= 2);
}
}

BOOST_AUTO_TEST_CASE(get_refreshes_recency)
{
IntCache cache(4);

// fill up to the threshold without triggering truncation
for (int i = 0; i < 8; i++) {
cache.insert(i, i);
}

// make the oldest entry the most recently used one
int value{0};
BOOST_CHECK(cache.get(0, value));
BOOST_CHECK_EQUAL(value, 0);

// this insert exceeds the threshold and truncates
cache.insert(8, 8);

// the refreshed entry survives, the entries it outranks do not
for (int i = 0; i < 9; i++) {
const bool expected = i == 0 || i >= 6;
BOOST_CHECK_EQUAL(cache.exists(i), expected);
}
}

BOOST_AUTO_TEST_CASE(exists_refreshes_recency)
{
IntCache cache(4);

for (int i = 0; i < 8; i++) {
cache.insert(i, i);
}

BOOST_CHECK(cache.exists(1));

cache.insert(8, 8);

for (int i = 0; i < 9; i++) {
const bool expected = i == 1 || i >= 6;
BOOST_CHECK_EQUAL(cache.exists(i), expected);
}
}

BOOST_AUTO_TEST_CASE(erase_and_clear)
{
IntCache cache(10);

for (int i = 0; i < 5; i++) {
cache.insert(i, i);
}

cache.erase(2);
BOOST_CHECK(!cache.exists(2));
BOOST_CHECK(cache.exists(1));

// erasing an absent key is a no-op
cache.erase(2);
cache.erase(100);
BOOST_CHECK(cache.exists(1));

cache.clear();
for (int i = 0; i < 5; i++) {
BOOST_CHECK(!cache.exists(i));
}

// the cache is still usable afterwards
cache.insert(7, 7);
int value{0};
BOOST_CHECK(cache.get(7, value));
BOOST_CHECK_EQUAL(value, 7);
}

BOOST_AUTO_TEST_CASE(emplace_inserts_and_overwrites)
{
IntCache cache(10);
int value{0};

cache.emplace(1, 10);
BOOST_CHECK(cache.get(1, value));
BOOST_CHECK_EQUAL(value, 10);

// emplacing a key that is already present replaces its value
cache.emplace(1, 20);
BOOST_CHECK(cache.get(1, value));
BOOST_CHECK_EQUAL(value, 20);

cache.insert(1, 30);
BOOST_CHECK(cache.get(1, value));
BOOST_CHECK_EQUAL(value, 30);
}

BOOST_AUTO_TEST_CASE(overwrite_does_not_grow_map)
{
IntCache cache(4);

// fill up to the threshold without triggering truncation
for (int i = 0; i < 8; i++) {
cache.insert(i, i);
}

// overwriting an existing key must not grow the map, so this stays at the threshold rather than exceeding it
cache.insert(0, 100);
for (int i = 0; i < 8; i++) {
BOOST_CHECK(cache.exists(i));
}

int value{0};
BOOST_CHECK(cache.get(0, value));
BOOST_CHECK_EQUAL(value, 100);
}

BOOST_AUTO_TEST_SUITE_END()
6 changes: 4 additions & 2 deletions src/unordered_lru_cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class unordered_lru_cache
{
// either specify maxSize through template arguments or the constructor and fail otherwise
assert(_maxSize != 0);
// truncate_if_needed() only runs past truncateThreshold, so this is what keeps maxSize inside the vector
assert(truncateThreshold >= maxSize);
Comment on lines 29 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: [prior-codex-1] Constructor precondition (truncateThreshold >= maxSize) enforced only via debug-only assert

This PR's std::nth_element swap (commit 99beec4) genuinely changed the safety characteristics of misusing this constructor. Before the swap, std::sort plus an erase loop starting at maxSize meant a configuration like cache(10, 5) was memory-safe even though it never evicted correctly (the vector reaches size 6, the loop starting at i=10 never executes). After the swap, the same misuse reaches std::nth_element(vec.begin(), vec.begin() + maxSize, vec.end(), ...) with vec.size() < maxSize, forming an out-of-range middle iterator — undefined behavior. The follow-up commit at this head (bccccf1) only adds a comment explaining the precondition; it does not clamp or otherwise enforce it outside of assert(). Verified: no call site in src/ currently constructs unordered_lru_cache/Uint256LruHashMap with a threshold below maxSize (checked all instantiations in creditpool.cpp, mnhftx.h, instantsend/db.h, llmq/*.h, saltedhasher.h). Verified: this repository's own CMakeLists.txt sets no explicit CMAKE_BUILD_TYPE/NDEBUG handling, so a standard -DCMAKE_BUILD_TYPE=Release configuration relies on CMake's built-in default flags, which do define NDEBUG and disable assert() — the risk is not purely theoretical. That said, this exactly mirrors the pre-existing, previously-unflagged assert(nPruneAfterSize >= nMaxSize) contract in limitedmap.h, and the maintainer explicitly weighed and rejected clamping in favor of a hard-fail-on-misconfiguration precondition (PR discussion, comment 3679365777). Given no live caller trips this and it matches established codebase convention, this doesn't block the PR, but it's worth clamping or adding a regression test (cache(10, 5) insert-past-threshold) so a future caller who mismatches the two constructor arguments fails safely in both debug and release builds rather than only in debug builds.

Suggested change
{
// either specify maxSize through template arguments or the constructor and fail otherwise
assert(_maxSize != 0);
// truncate_if_needed() only runs past truncateThreshold, so this is what keeps maxSize inside the vector
assert(truncateThreshold >= maxSize);
explicit unordered_lru_cache(size_t _maxSize = MaxSize, size_t _truncateThreshold = TruncateThreshold) :
maxSize(_maxSize),
truncateThreshold(std::max(_truncateThreshold == 0 ? _maxSize * 2 : _truncateThreshold, _maxSize))
{
// either specify maxSize through template arguments or the constructor and fail otherwise
assert(_maxSize != 0);
}

source: ['claude', 'codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

asserts are not disabled in release. This assert WILL trigger in release if there is an issue. Maybe you can argue this could still open us to a DoS etc if we write buggy code for this. But the assert will trigger and we won't get to undefined behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this update — [prior-codex-1] Constructor precondition (truncateThreshold >= maxSize) enforced only via debug-only assert no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

}
Comment on lines 29 to 34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Preserve smaller truncation thresholds

The constructor previously accepted configurations such as cache(10, 5). Although the old implementation began sorting after the sixth insertion, it did not erase anything until the size exceeded maxSize, so the configuration remained safe and effectively behaved as if the threshold were 10. The new assertion rejects it only in assertion-enabled builds; with NDEBUG, the sixth insertion reaches std::nth_element(vec.begin(), vec.begin() + 10, vec.end(), ...) with a six-element vector, forming an out-of-range iterator and invoking undefined behavior. Clamp the effective threshold to maxSize instead of relying on a debug-only assertion, and add a regression test for a threshold below the maximum.

Suggested change
{
// either specify maxSize through template arguments or the constructor and fail otherwise
assert(_maxSize != 0);
assert(truncateThreshold >= maxSize);
}
explicit unordered_lru_cache(size_t _maxSize = MaxSize, size_t _truncateThreshold = TruncateThreshold) :
maxSize(_maxSize),
truncateThreshold(std::max(_truncateThreshold == 0 ? _maxSize * 2 : _truncateThreshold, _maxSize))
{
// either specify maxSize through template arguments or the constructor and fail otherwise
assert(_maxSize != 0);
}

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is required because;
std::nth_element requires its "nth" iterator to satisfy first <= nth <= last. If truncateThreshold < maxSize, it's possible for truncate_if_needed() to be entered (cacheMap.size() > truncateThreshold) while cacheMap.size() < maxSize — meaning vec.begin() + maxSize lands past vec.end(). That's undefined behavior (out-of-bounds iterator arithmetic fed to nth_element), not just a logic bug.

Under the old std::sort-based code this misconfiguration was harmless: sorting doesn't care about maxSize at all, and the eviction loop for (i = maxSize; i < vec.size(); i++) would simply not execute if maxSize > vec.size() — a silent no-op, not memory-unsafe.

weirdly setting the truncate threshold to a max of various things makes no sense. Better to assert imo

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in bccccf1Preserve smaller truncation thresholds no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.


size_t max_size() const { return maxSize; }
Expand Down Expand Up @@ -101,8 +103,8 @@ class unordered_lru_cache
for (auto it = cacheMap.begin(); it != cacheMap.end(); ++it) {
vec.emplace_back(it);
}
// sort by last access time (descending order)
std::sort(vec.begin(), vec.end(), [](const Iterator& it1, const Iterator& it2) {
// partition by last access time (descending order), the entries to keep end up in the first maxSize slots
std::nth_element(vec.begin(), vec.begin() + maxSize, vec.end(), [](const Iterator& it1, const Iterator& it2) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve smaller truncation thresholds

For constructor calls such as cache(10, 5), which the previous implementation handled, the sixth insertion builds a six-element vector and then forms vec.begin() + 10, an out-of-range iterator that causes undefined behavior in release builds; debug builds instead abort at the new assertion. The previous sort-and-erase path retained entries until the size exceeded maxSize, so skip partitioning while cacheMap.size() <= maxSize or otherwise preserve this previously accepted configuration.

Useful? React with 👍 / 👎.

return it1->second.second > it2->second.second;
});

Expand Down
1 change: 1 addition & 0 deletions test/util/data/non-backported.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ src/test/evo*.cpp
src/test/llmq*.cpp
src/test/masternode_payments_tests.cpp
src/test/spork_tests.cpp
src/test/unordered_lru_cache_tests.cpp
src/test/util/llmq_tests.h
src/test/governance*.cpp
src/unordered_lru_cache.h
Expand Down