diff --git a/category/core/CMakeLists.txt b/category/core/CMakeLists.txt
index cf90b7baf5..146a2649c1 100644
--- a/category/core/CMakeLists.txt
+++ b/category/core/CMakeLists.txt
@@ -121,6 +121,7 @@ add_library(
"tl_tid.c"
"tl_tid.h"
"detail/start_lifetime_as_polyfill.hpp"
+ "lru/cache_stats.hpp"
"lru/lru_cache.hpp"
"lru/static_lru_cache.hpp"
"mem/batch_mem_pool.hpp"
diff --git a/category/core/lru/cache_stats.hpp b/category/core/lru/cache_stats.hpp
new file mode 100644
index 0000000000..f7347c29a4
--- /dev/null
+++ b/category/core/lru/cache_stats.hpp
@@ -0,0 +1,111 @@
+// Copyright (C) 2025-26 Category Labs, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+#pragma once
+
+#include
+
+#include
+#include
+
+MONAD_NAMESPACE_BEGIN
+
+struct CacheStatsSnapshot
+{
+ uint64_t hits{0};
+ uint64_t misses{0};
+ uint64_t evictions{0}; // entries dropped to stay within the cache's bound
+};
+
+// Counters only ever increase, so a negative difference means the two
+// snapshots came from different caches. Saturate rather than wrap: a window
+// reading zero is a legible "no activity", where a wrap prints ~1.8e19 into
+// the block metrics line and overflows the percentage back to a plausible 0%.
+constexpr uint64_t
+monotonic_delta(uint64_t const now, uint64_t const base) noexcept
+{
+ return now > base ? now - base : 0;
+}
+
+constexpr CacheStatsSnapshot
+operator-(CacheStatsSnapshot const &a, CacheStatsSnapshot const &b) noexcept
+{
+ return {
+ .hits = monotonic_delta(a.hits, b.hits),
+ .misses = monotonic_delta(a.misses, b.misses),
+ .evictions = monotonic_delta(a.evictions, b.evictions)};
+}
+
+// Reports cache activity over a window — a block, for the metrics log line —
+// without disturbing the underlying counters, which stay cumulative. Before
+// the first reset the window covers the whole history.
+//
+// The baseline is not atomic: reset() and since() must run on one thread, and
+// both snapshots must come from the same cache.
+class CacheStatsWindow
+{
+ CacheStatsSnapshot baseline_{};
+
+public:
+ void reset(CacheStatsSnapshot const &now) noexcept
+ {
+ baseline_ = now;
+ }
+
+ CacheStatsSnapshot since(CacheStatsSnapshot const &now) const noexcept
+ {
+ return now - baseline_;
+ }
+};
+
+// Hit/miss/eviction counts for the life of the cache object. Reads are
+// non-destructive, so independent readers do not consume each other's counts.
+//
+// The counters are relaxed because they are statistics, not synchronization —
+// they order nothing and a reader tolerates a slightly stale total. The three
+// loads in snapshot() are independent, so a snapshot is not a single instant;
+// each counter is monotonic, which is all the window subtraction needs.
+class CacheStats
+{
+ std::atomic hits_{0};
+ std::atomic misses_{0};
+ std::atomic evictions_{0};
+
+public:
+ void record_hit() noexcept
+ {
+ hits_.fetch_add(1, std::memory_order_relaxed);
+ }
+
+ void record_miss() noexcept
+ {
+ misses_.fetch_add(1, std::memory_order_relaxed);
+ }
+
+ void record_eviction() noexcept
+ {
+ evictions_.fetch_add(1, std::memory_order_relaxed);
+ }
+
+ CacheStatsSnapshot snapshot() const noexcept
+ {
+ return {
+ .hits = hits_.load(std::memory_order_relaxed),
+ .misses = misses_.load(std::memory_order_relaxed),
+ .evictions = evictions_.load(std::memory_order_relaxed)};
+ }
+};
+
+MONAD_NAMESPACE_END
diff --git a/category/core/lru/lru_cache.hpp b/category/core/lru/lru_cache.hpp
index 7022ad08e6..44bb3383f6 100644
--- a/category/core/lru/lru_cache.hpp
+++ b/category/core/lru/lru_cache.hpp
@@ -16,6 +16,7 @@
#pragma once
#include
+#include
#include
#include
@@ -52,23 +53,7 @@ class LruCache
Mutex mutex_;
HashMap hmap_;
Pool pool_;
-
-/// STATS MACROS
-#ifdef MONAD_LRU_CACHE_STATS
- #define STATS_EVENT_EVICT() stats_.event_evict()
- #define STATS_EVENT_FIND_HIT() stats_.event_find_hit()
- #define STATS_EVENT_FIND_MISS() stats_.event_find_miss()
- #define STATS_EVENT_INSERT_FOUND() stats_.event_insert_found()
- #define STATS_EVENT_INSERT_NEW() stats_.event_insert_new()
- #define STATS_EVENT_UPDATE_LRU() stats_.event_update_lru()
-#else
- #define STATS_EVENT_EVICT()
- #define STATS_EVENT_FIND_HIT()
- #define STATS_EVENT_FIND_MISS()
- #define STATS_EVENT_INSERT_FOUND()
- #define STATS_EVENT_INSERT_NEW()
- #define STATS_EVENT_UPDATE_LRU()
-#endif
+ CacheStats stats_;
public:
using ConstAccessor = HashMap::const_accessor;
@@ -92,10 +77,10 @@ class LruCache
bool find(ConstAccessor &acc, Key const &key)
{
if (!hmap_.find(acc, key)) {
- STATS_EVENT_FIND_MISS();
+ stats_.record_miss();
return false;
}
- STATS_EVENT_FIND_HIT();
+ stats_.record_hit();
ListNode *const node = acc->second.node_;
try_update_lru(node);
return true;
@@ -106,7 +91,6 @@ class LruCache
Accessor acc;
HashMapKeyValue const hmkv(key, HashMapValue(value, nullptr));
if (!hmap_.insert(acc, hmkv)) {
- STATS_EVENT_INSERT_FOUND();
acc->second.value_ = value;
ListNode *const node = acc->second.node_;
try_update_lru(node);
@@ -136,7 +120,6 @@ class LruCache
{
if (node->check_lru_time()) {
std::unique_lock const l(mutex_);
- STATS_EVENT_UPDATE_LRU();
lru_.update_lru(node);
}
}
@@ -147,7 +130,6 @@ class LruCache
bool const evicted = (sz >= max_size_) && evict();
{
std::unique_lock const l(mutex_);
- STATS_EVENT_INSERT_NEW();
lru_.push_front(node);
}
if (!evicted) {
@@ -171,12 +153,12 @@ class LruCache
ListNode *target;
{
std::unique_lock const l(mutex_);
- STATS_EVENT_EVICT();
target = lru_.evict();
}
if (!target) {
return false;
}
+ stats_.record_eviction();
Accessor acc;
bool const found = hmap_.find(acc, target->key_);
MONAD_ASSERT(found);
@@ -306,96 +288,11 @@ class LruCache
}
}; /// HashMapValue
-/// STATS
-#undef STATS_EVENT_EVICT
-#undef STATS_EVENT_FIND_HIT
-#undef STATS_EVENT_FIND_MISS
-#undef STATS_EVENT_INSERT_FOUND
-#undef STATS_EVENT_INSERT_NEW
-#undef STATS_EVENT_UPDATE_LRU
-
public:
- std::string print_stats()
+ CacheStatsSnapshot stats() const noexcept
{
- std::string str =
- std::format("{:8}", size_.load(std::memory_order_acquire));
-#ifdef MONAD_LRU_CACHE_STATS
- str += " / " + stats_.print_stats();
-#endif
- return str;
+ return stats_.snapshot();
}
-
-private:
-#ifdef MONAD_LRU_CACHE_STATS
- /// CacheStats
- struct CacheStats
- {
- std::atomic n_find_hit_{0};
- std::atomic n_find_miss_{0};
- std::atomic n_insert_found_{0};
- uint64_t n_insert_new_{0};
- uint64_t n_evict_{0};
- uint64_t n_update_lru_{0};
-
- void event_find_hit()
- {
- n_find_hit_.fetch_add(1, std::memory_order_release);
- }
-
- void event_find_miss()
- {
- n_find_miss_.fetch_add(1, std::memory_order_release);
- }
-
- void event_insert_found()
- {
- n_insert_found_.fetch_add(1, std::memory_order_release);
- }
-
- void event_insert_new()
- {
- ++n_insert_new_;
- }
-
- void event_evict()
- {
- ++n_evict_;
- }
-
- void event_update_lru()
- {
- ++n_update_lru_;
- }
-
- void clear_stats()
- {
- // Not called concurrently with cache operations.
- n_find_hit_.store(0, std::memory_order_release);
- n_find_miss_.store(0, std::memory_order_release);
- n_insert_found_.store(0, std::memory_order_release);
- n_insert_new_ = 0;
- n_evict_ = 0;
- n_update_lru_ = 0;
- }
-
- std::string print_stats()
- {
- std::string str = std::format(
- "{:6} {:6} - {:6} {:6} - {:6} - {:6}",
- n_find_hit_.load(std::memory_order_acquire),
- n_find_miss_.load(std::memory_order_acquire),
- n_insert_found_.load(std::memory_order_acquire),
- n_insert_new_,
- n_evict_,
- n_update_lru_);
- clear_stats();
- return str;
- }
- }; /// CacheStats
-
- CacheStats stats_;
-#endif /// MONAD_LRU_CACHE_STATS
-
}; /// LruCache
MONAD_NAMESPACE_END
diff --git a/category/core/lru/lru_cache_test.cpp b/category/core/lru/lru_cache_test.cpp
new file mode 100644
index 0000000000..3705b68f2e
--- /dev/null
+++ b/category/core/lru/lru_cache_test.cpp
@@ -0,0 +1,149 @@
+// Copyright (C) 2025-26 Category Labs, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+#include
+#include
+
+#include
+
+using Cache = monad::LruCache;
+
+TEST(lru_cache_test, find_counts_hit_and_miss)
+{
+ Cache cache{4};
+ Cache::ConstAccessor acc;
+
+ EXPECT_FALSE(cache.find(acc, 1));
+ cache.insert(1, 0x111);
+ ASSERT_TRUE(cache.find(acc, 1));
+
+ auto const stats = cache.stats();
+ EXPECT_EQ(stats.hits, 1u);
+ EXPECT_EQ(stats.misses, 1u);
+}
+
+TEST(lru_cache_test, stats_are_not_reset_by_reading_them)
+{
+ Cache cache{4};
+ Cache::ConstAccessor acc;
+
+ cache.insert(1, 0x111);
+ ASSERT_TRUE(cache.find(acc, 1));
+
+ auto const first = cache.stats();
+ auto const second = cache.stats();
+ EXPECT_EQ(first.hits, 1u);
+ EXPECT_EQ(second.hits, first.hits);
+ EXPECT_EQ(second.misses, first.misses);
+ EXPECT_EQ(second.evictions, first.evictions);
+}
+
+TEST(lru_cache_test, counts_evictions_once_capacity_is_exceeded)
+{
+ Cache cache{2};
+
+ cache.insert(1, 0x111);
+ cache.insert(2, 0x222);
+ EXPECT_EQ(cache.stats().evictions, 0u);
+
+ cache.insert(3, 0x333);
+ EXPECT_EQ(cache.size(), 2u);
+ EXPECT_EQ(cache.stats().evictions, 1u);
+}
+
+TEST(lru_cache_test, overwriting_an_existing_key_does_not_evict)
+{
+ Cache cache{2};
+
+ cache.insert(1, 0x111);
+ cache.insert(1, 0x222);
+
+ EXPECT_EQ(cache.size(), 1u);
+ EXPECT_EQ(cache.stats().evictions, 0u);
+}
+
+TEST(cache_stats_window_test, reports_activity_since_the_last_reset)
+{
+ Cache cache{4};
+ Cache::ConstAccessor acc;
+ monad::CacheStatsWindow window;
+
+ cache.insert(1, 0x111);
+ ASSERT_TRUE(cache.find(acc, 1));
+ window.reset(cache.stats());
+
+ ASSERT_TRUE(cache.find(acc, 1));
+ EXPECT_FALSE(cache.find(acc, 2));
+
+ EXPECT_EQ(cache.stats().hits, 2u);
+ EXPECT_EQ(cache.stats().misses, 1u);
+
+ auto const since = window.since(cache.stats());
+ EXPECT_EQ(since.hits, 1u);
+ EXPECT_EQ(since.misses, 1u);
+}
+
+// A counter can only go backwards if a window is used against the wrong cache.
+// Saturating keeps that bug legible as "no activity" instead of ~1.8e19.
+TEST(cache_stats_window_test, a_backwards_counter_saturates_instead_of_wrapping)
+{
+ monad::CacheStatsSnapshot const earlier{
+ .hits = 5, .misses = 5, .evictions = 5};
+ monad::CacheStatsSnapshot const later{
+ .hits = 10, .misses = 10, .evictions = 10};
+
+ auto const forward = later - earlier;
+ EXPECT_EQ(forward.hits, 5u);
+
+ auto const backward = earlier - later;
+ EXPECT_EQ(backward.hits, 0u);
+ EXPECT_EQ(backward.misses, 0u);
+ EXPECT_EQ(backward.evictions, 0u);
+}
+
+TEST(cache_stats_window_test, before_any_reset_the_window_is_the_whole_history)
+{
+ Cache cache{4};
+ Cache::ConstAccessor acc;
+ monad::CacheStatsWindow const window;
+
+ cache.insert(1, 0x111);
+ ASSERT_TRUE(cache.find(acc, 1));
+
+ EXPECT_EQ(window.since(cache.stats()).hits, 1u);
+}
+
+TEST(lru_cache_test, clear_does_not_disturb_the_counters)
+{
+ Cache cache{4};
+
+ // Scoped: an accessor holds a lock on its element, and clear() destroys
+ // the element under it.
+ {
+ Cache::ConstAccessor acc;
+ EXPECT_FALSE(cache.find(acc, 1));
+ }
+ cache.insert(1, 0x111);
+ {
+ Cache::ConstAccessor acc;
+ ASSERT_TRUE(cache.find(acc, 1));
+ }
+
+ cache.clear();
+
+ auto const stats = cache.stats();
+ EXPECT_EQ(stats.hits, 1u);
+ EXPECT_EQ(stats.misses, 1u);
+}
diff --git a/category/core/lru/static_lru_cache.hpp b/category/core/lru/static_lru_cache.hpp
index 0ed560f3d6..f1efe8daee 100644
--- a/category/core/lru/static_lru_cache.hpp
+++ b/category/core/lru/static_lru_cache.hpp
@@ -17,6 +17,7 @@
#include
#include
+#include
#include
@@ -52,6 +53,8 @@ class static_lru_cache
boost::intrusive::list active_list_;
boost::intrusive::list free_list_;
Map map_;
+ // Derived caches evict on their own bound too, so they record here.
+ CacheStats stats_;
public:
using ConstAccessor = Map::const_iterator;
@@ -93,6 +96,7 @@ class static_lru_cache
else { // reuse the last node in active_list_
auto const list_it = std::prev(active_list_.end());
erased_value = list_it->val;
+ stats_.record_eviction();
map_.erase(list_it->key);
node = &*list_it;
active_list_.erase(list_it);
@@ -111,17 +115,32 @@ class static_lru_cache
{
acc = map_.find(key);
if (acc == map_.end()) {
+ stats_.record_miss();
return false;
}
+ stats_.record_hit();
update_lru(acc->second);
return true;
}
+ // Existence check that is not a lookup: it neither counts nor updates the
+ // LRU order. For assertions and invariant checks, where find() would
+ // otherwise record a miss for a read nobody made.
+ bool contains(Key const &key) const noexcept
+ {
+ return map_.find(key) != map_.end();
+ }
+
size_t size() const noexcept
{
return map_.size();
}
+ CacheStatsSnapshot stats() const noexcept
+ {
+ return stats_.snapshot();
+ }
+
void clear() noexcept
{
map_.clear();
diff --git a/category/core/lru/static_lru_test.cpp b/category/core/lru/static_lru_test.cpp
index 4065a005bc..474d6b6882 100644
--- a/category/core/lru/static_lru_test.cpp
+++ b/category/core/lru/static_lru_test.cpp
@@ -141,3 +141,53 @@ TEST(static_lru_test, clear)
lru.insert(5, "world");
EXPECT_EQ(lru.size(), 1);
}
+
+TEST(static_lru_test, counts_hits_misses_and_evictions)
+{
+ using LruCache = monad::static_lru_cache;
+ LruCache lru(2);
+ LruCache::ConstAccessor acc;
+
+ EXPECT_FALSE(lru.find(acc, 1));
+ lru.insert(1, 0x111);
+ lru.insert(2, 0x222);
+ ASSERT_TRUE(lru.find(acc, 1));
+ EXPECT_EQ(lru.stats().evictions, 0u);
+
+ // Capacity is 2, so this reuses the LRU tail.
+ lru.insert(3, 0x333);
+
+ auto const stats = lru.stats();
+ EXPECT_EQ(stats.hits, 1u);
+ EXPECT_EQ(stats.misses, 1u);
+ EXPECT_EQ(stats.evictions, 1u);
+}
+
+// find() is used as an existence predicate in a few places that are not
+// lookups; those must not move the hit rate.
+TEST(static_lru_test, contains_does_not_count_as_a_lookup)
+{
+ using LruCache = monad::static_lru_cache;
+ LruCache lru(2);
+
+ lru.insert(1, 0x111);
+
+ EXPECT_TRUE(lru.contains(1));
+ EXPECT_FALSE(lru.contains(2));
+
+ auto const stats = lru.stats();
+ EXPECT_EQ(stats.hits, 0u);
+ EXPECT_EQ(stats.misses, 0u);
+}
+
+TEST(static_lru_test, overwriting_an_existing_key_does_not_evict)
+{
+ using LruCache = monad::static_lru_cache;
+ LruCache lru(2);
+
+ lru.insert(1, 0x111);
+ lru.insert(1, 0x222);
+
+ EXPECT_EQ(lru.size(), 1);
+ EXPECT_EQ(lru.stats().evictions, 0u);
+}
diff --git a/category/execution/ethereum/db/db.hpp b/category/execution/ethereum/db/db.hpp
index e5a32ccde6..7c8911b101 100644
--- a/category/execution/ethereum/db/db.hpp
+++ b/category/execution/ethereum/db/db.hpp
@@ -80,6 +80,12 @@ struct Db
return {};
}
+ // Starts the window that print_stats() reports over. Call once per block,
+ // before execution. Pure so that an implementation reporting stats cannot
+ // forget it and silently report since-construction totals on a per-block
+ // line; implementations with no stats define it empty.
+ virtual void begin_block_stats() = 0;
+
protected:
bytes32_t storage_lookup_key(bytes32_t const &key) const
{
diff --git a/category/execution/ethereum/db/db_cache.hpp b/category/execution/ethereum/db/db_cache.hpp
index 589e8555dc..5afd827cd9 100644
--- a/category/execution/ethereum/db/db_cache.hpp
+++ b/category/execution/ethereum/db/db_cache.hpp
@@ -19,6 +19,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -46,6 +47,15 @@ enum class CacheReadStatus
// -> can't prove finalized-consistent -> don't cache miss
};
+// Counters for the two LRU caches. Reads answered by the proposal overlay, and
+// reads that abort on a truncated chain, never reach the LRUs and count as
+// neither hit nor miss.
+struct DbCacheStats
+{
+ CacheStatsSnapshot accounts;
+ CacheStatsSnapshot storage;
+};
+
// Encoding-agnostic LRU + proposal cache for accounts and storage leaves.
// Storage values are held as storage_page_t, keyed by the trie key the
// caller passes: slot_key (single slot at index 0) for slot encoding, or
@@ -68,6 +78,8 @@ class DbCache final
AccountsCache accounts_{10'000'000};
StorageCache storage_{STORAGE_CACHE_MAX_BYTES};
Proposals proposals_;
+ CacheStatsWindow accounts_window_;
+ CacheStatsWindow storage_window_;
public:
DbCache() = default;
@@ -187,18 +199,55 @@ class DbCache final
}
}
- std::string accounts_stats()
+ // Cumulative since construction.
+ DbCacheStats stats() const
+ {
+ return {.accounts = accounts_.stats(), .storage = storage_.stats()};
+ }
+
+ // Since the last begin_block_stats(), for the per-block log line.
+ DbCacheStats block_stats() const
+ {
+ return {
+ .accounts = accounts_window_.since(accounts_.stats()),
+ .storage = storage_window_.since(storage_.stats())};
+ }
+
+ void begin_block_stats()
{
- return accounts_.print_stats();
+ accounts_window_.reset(accounts_.stats());
+ storage_window_.reset(storage_.stats());
}
- std::string storage_stats()
+ std::string accounts_stats() const
{
return std::format(
- "{:8} / {:10}", storage_.size(), storage_.approx_weight());
+ "{:8} {}", accounts_.size(), format_stats(block_stats().accounts));
+ }
+
+ std::string storage_stats() const
+ {
+ return std::format(
+ "{:8} / {:10} {}",
+ storage_.size(),
+ storage_.approx_weight(),
+ format_stats(block_stats().storage));
}
private:
+ static std::string format_stats(CacheStatsSnapshot const &s)
+ {
+ uint64_t const lookups = s.hits + s.misses;
+ return std::format(
+ "{:5.1f}% {:10} {:10} {:8}",
+ lookups == 0 ? 0.0
+ : 100.0 * static_cast(s.hits) /
+ static_cast(lookups),
+ s.hits,
+ s.misses,
+ s.evictions);
+ }
+
void insert_in_lru_caches(ProposalPostState const &post_state)
{
for (auto const &[addr, acct] : post_state.accounts) {
diff --git a/category/execution/ethereum/db/db_cache_test.cpp b/category/execution/ethereum/db/db_cache_test.cpp
index 85eb828b33..a287d943b2 100644
--- a/category/execution/ethereum/db/db_cache_test.cpp
+++ b/category/execution/ethereum/db/db_cache_test.cpp
@@ -171,3 +171,117 @@ TEST(DbCacheTest, finalization_write_overwrites_readthrough_entry)
cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit);
EXPECT_EQ(slot, VALUE2);
}
+
+TEST(DbCacheTest, proposal_overlay_hits_are_not_counted_against_the_lru)
+{
+ DbCache cache;
+ cache.update_proposal_state(
+ make_post_state(ADDR, KEY, VALUE1), 1, bytes32_t{1});
+ cache.set_block_and_prefix(1, bytes32_t{1});
+
+ std::optional account;
+ EXPECT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit);
+
+ auto const stats = cache.stats();
+ EXPECT_EQ(stats.accounts.hits, 0u);
+ EXPECT_EQ(stats.accounts.misses, 0u);
+}
+
+namespace
+{
+ // Never written by any proposal, so reads of it fall through to the LRU.
+ constexpr auto UNWRITTEN_ADDR =
+ 0x00000000000000000000000000000000000000cc_address;
+ constexpr auto UNWRITTEN_KEY =
+ 0x00000000000000000000000000000000000000000000000000000000000000cc_bytes32;
+
+ // Proposals 1 and 2 write ADDR/KEY and are finalized, promoting them into
+ // the LRUs; proposal 3 is the block being read from and writes neither,
+ // so a read of ADDR/KEY walks clean to the finalized base and the LRU
+ // answers.
+ void seed_finalized_lru(DbCache &cache)
+ {
+ cache.update_proposal_state(
+ make_post_state(ADDR, KEY, VALUE1), 1, bytes32_t{1});
+ cache.update_proposal_state(
+ make_post_state(ADDR, KEY, VALUE2), 2, bytes32_t{2});
+ cache.update_proposal_state(
+ make_post_state(OTHER_ADDR, OTHER_KEY, VALUE1), 3, bytes32_t{3});
+ cache.on_finalize(1, bytes32_t{1});
+ cache.on_finalize(2, bytes32_t{2});
+ cache.set_block_and_prefix(3, bytes32_t{3});
+ }
+}
+
+TEST(DbCacheTest, counts_lru_account_hits_and_misses)
+{
+ DbCache cache;
+ seed_finalized_lru(cache);
+
+ std::optional account;
+ EXPECT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit);
+ EXPECT_EQ(
+ cache.try_read_account(UNWRITTEN_ADDR, account),
+ CacheReadStatus::MissResolved);
+
+ auto const stats = cache.stats();
+ EXPECT_EQ(stats.accounts.hits, 1u);
+ EXPECT_EQ(stats.accounts.misses, 1u);
+}
+
+// Both views must be available at once from the same cache.
+TEST(DbCacheTest, block_stats_count_only_what_happened_since_the_snapshot)
+{
+ DbCache cache;
+ seed_finalized_lru(cache);
+
+ std::optional account;
+ ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit);
+
+ cache.begin_block_stats();
+ ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit);
+
+ EXPECT_EQ(cache.stats().accounts.hits, 2u);
+ EXPECT_EQ(cache.block_stats().accounts.hits, 1u);
+}
+
+TEST(DbCacheTest, consecutive_blocks_do_not_accumulate_in_block_stats)
+{
+ DbCache cache;
+ seed_finalized_lru(cache);
+
+ std::optional account;
+ bytes32_t slot;
+
+ cache.begin_block_stats();
+ ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit);
+ ASSERT_EQ(
+ cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit);
+ EXPECT_EQ(cache.block_stats().accounts.hits, 1u);
+ EXPECT_EQ(cache.block_stats().storage.hits, 1u);
+
+ // A second block with no reads at all must report zero, not the first
+ // block's totals.
+ cache.begin_block_stats();
+ EXPECT_EQ(cache.block_stats().accounts.hits, 0u);
+ EXPECT_EQ(cache.block_stats().storage.hits, 0u);
+ EXPECT_EQ(cache.stats().accounts.hits, 1u);
+ EXPECT_EQ(cache.stats().storage.hits, 1u);
+}
+
+TEST(DbCacheTest, counts_lru_storage_hits_and_misses)
+{
+ DbCache cache;
+ seed_finalized_lru(cache);
+
+ bytes32_t slot;
+ EXPECT_EQ(
+ cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit);
+ EXPECT_EQ(
+ cache.try_read_storage(ADDR, INC, UNWRITTEN_KEY, 0, slot),
+ CacheReadStatus::MissResolved);
+
+ auto const stats = cache.stats();
+ EXPECT_EQ(stats.storage.hits, 1u);
+ EXPECT_EQ(stats.storage.misses, 1u);
+}
diff --git a/category/execution/ethereum/db/partial_trie_db.hpp b/category/execution/ethereum/db/partial_trie_db.hpp
index 2f581db425..473b3b8540 100644
--- a/category/execution/ethereum/db/partial_trie_db.hpp
+++ b/category/execution/ethereum/db/partial_trie_db.hpp
@@ -107,6 +107,9 @@ class PartialTrieDb final : public Db
return block_number_;
}
+ // Reports no stats.
+ void begin_block_stats() override {}
+
void set_block_and_prefix(
uint64_t const block_number, bytes32_t const &) override
{
diff --git a/category/execution/ethereum/db/trie_db.cpp b/category/execution/ethereum/db/trie_db.cpp
index 0eba74a988..164c0438c4 100644
--- a/category/execution/ethereum/db/trie_db.cpp
+++ b/category/execution/ethereum/db/trie_db.cpp
@@ -404,6 +404,17 @@ BlockHeader TrieDb::read_eth_header()
return std::move(decode_res.value());
}
+void TrieDb::begin_block_stats()
+{
+ n_account_no_value_.store(0, std::memory_order_release);
+ n_account_value_.store(0, std::memory_order_release);
+ n_storage_no_value_.store(0, std::memory_order_release);
+ n_storage_value_.store(0, std::memory_order_release);
+ if (cache_) {
+ cache_->begin_block_stats();
+ }
+}
+
std::string TrieDb::print_stats()
{
std::string ret;
@@ -413,10 +424,6 @@ std::string TrieDb::print_stats()
n_account_value_.load(std::memory_order_acquire),
n_storage_no_value_.load(std::memory_order_acquire),
n_storage_value_.load(std::memory_order_acquire));
- n_account_no_value_.store(0, std::memory_order_release);
- n_account_value_.store(0, std::memory_order_release);
- n_storage_no_value_.store(0, std::memory_order_release);
- n_storage_value_.store(0, std::memory_order_release);
if (cache_) {
ret += ",ac=" + cache_->accounts_stats() +
",sc=" + cache_->storage_stats();
diff --git a/category/execution/ethereum/db/trie_db.hpp b/category/execution/ethereum/db/trie_db.hpp
index a9d3c67eb5..4f628dee2b 100644
--- a/category/execution/ethereum/db/trie_db.hpp
+++ b/category/execution/ethereum/db/trie_db.hpp
@@ -100,6 +100,7 @@ class TrieDb final : public ::monad::Db
virtual bytes32_t transactions_root() override;
virtual std::optional withdrawals_root() override;
virtual std::string print_stats() override;
+ virtual void begin_block_stats() override;
virtual uint64_t get_block_number() const override;
nlohmann::json to_json(size_t concurrency_limit = 4096);
diff --git a/category/execution/ethereum/db/trie_rodb.hpp b/category/execution/ethereum/db/trie_rodb.hpp
index c4a0664102..b06b0949dd 100644
--- a/category/execution/ethereum/db/trie_rodb.hpp
+++ b/category/execution/ethereum/db/trie_rodb.hpp
@@ -206,6 +206,9 @@ class TrieRODb final : public ::monad::Db
{
return block_number_;
}
+
+ // Reports no stats.
+ virtual void begin_block_stats() override {}
};
MONAD_NAMESPACE_END
diff --git a/category/execution/runloop/runloop_ethereum.cpp b/category/execution/runloop/runloop_ethereum.cpp
index 40af47e40e..d9a1a17f2e 100644
--- a/category/execution/runloop/runloop_ethereum.cpp
+++ b/category/execution/runloop/runloop_ethereum.cpp
@@ -99,6 +99,8 @@ Result process_ethereum_block(
[[maybe_unused]] auto const block_start = std::chrono::system_clock::now();
auto const block_begin = std::chrono::steady_clock::now();
+ db.begin_block_stats();
+ vm.begin_block_stats();
record_block_start(
exec_recorder,
@@ -238,7 +240,7 @@ Result process_ethereum_block(
"__exec_block,bl={:8},ts={}"
",tx={:5},rt={:4},rtp={:5.2f}%"
",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}"
- ",gas={:9},gpse={:4},gps={:3}{}{}{}",
+ ",gas={:9},gpse={:4},gps={:3}{}{}{}{}",
block.header.number,
std::chrono::duration_cast(
block_start.time_since_epoch())
@@ -262,7 +264,8 @@ Result process_ethereum_block(
(uint64_t)std::max(1L, block_time.count()),
db.print_stats(),
vm.print_and_reset_block_counts(),
- vm.print_compiler_stats());
+ vm.print_compiler_stats(),
+ vm.print_varcode_cache_stats());
return outcome_e::success();
}
diff --git a/category/execution/runloop/runloop_interface_monad.cpp b/category/execution/runloop/runloop_interface_monad.cpp
index c79639e582..fc43548607 100644
--- a/category/execution/runloop/runloop_interface_monad.cpp
+++ b/category/execution/runloop/runloop_interface_monad.cpp
@@ -199,6 +199,11 @@ class MonadRunloopTrieDb : public Db
return triedb_.print_stats();
}
+ virtual void begin_block_stats() override
+ {
+ triedb_.begin_block_stats();
+ }
+
virtual uint64_t get_block_number() const override
{
return triedb_.get_block_number();
diff --git a/category/execution/runloop/runloop_monad.cpp b/category/execution/runloop/runloop_monad.cpp
index 9eb7d5e344..1ad157813a 100644
--- a/category/execution/runloop/runloop_monad.cpp
+++ b/category/execution/runloop/runloop_monad.cpp
@@ -188,6 +188,8 @@ Result propose_block(
{
[[maybe_unused]] auto const block_start = std::chrono::system_clock::now();
auto const block_begin = std::chrono::steady_clock::now();
+ db.begin_block_stats();
+ vm.begin_block_stats();
auto const &block_hash_buffer =
block_hash_chain.find_chain(consensus_header.parent_id());
@@ -392,7 +394,7 @@ Result propose_block(
"__exec_block,bl={:8},id={},ts={}"
",tx={:5},rt={:4},rtp={:5.2f}%"
",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}"
- ",gas={:9},gpse={:4},gps={:3}{}{}{}",
+ ",gas={:9},gpse={:4},gps={:3}{}{}{}{}",
block.header.number,
block_id,
std::chrono::duration_cast(
@@ -417,7 +419,8 @@ Result propose_block(
(uint64_t)std::max(1L, block_time.count()),
db.print_stats(),
vm.print_and_reset_block_counts(),
- vm.print_compiler_stats());
+ vm.print_compiler_stats(),
+ vm.print_varcode_cache_stats());
return exec_output;
}
diff --git a/category/execution/runloop/runloop_monad_ethblocks.cpp b/category/execution/runloop/runloop_monad_ethblocks.cpp
index 0afed6c069..3f9fc75259 100644
--- a/category/execution/runloop/runloop_monad_ethblocks.cpp
+++ b/category/execution/runloop/runloop_monad_ethblocks.cpp
@@ -133,6 +133,8 @@ Result process_monad_block(
{
[[maybe_unused]] auto const block_start = std::chrono::system_clock::now();
auto const block_begin = std::chrono::steady_clock::now();
+ db.begin_block_stats();
+ vm.begin_block_stats();
// This is exactly the same as the recording call in runloop_ethereum.cpp;
// even though these are historical Monad block inputs, we don't have the
@@ -282,7 +284,7 @@ Result process_monad_block(
"__exec_block,bl={:8},ts={}"
",tx={:5},rt={:4},rtp={:5.2f}%"
",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}"
- ",gas={:9},gpse={:4},gps={:3}{}{}{}",
+ ",gas={:9},gpse={:4},gps={:3}{}{}{}{}",
block.header.number,
std::chrono::duration_cast(
block_start.time_since_epoch())
@@ -306,7 +308,8 @@ Result process_monad_block(
(uint64_t)std::max(1L, block_time.count()),
db.print_stats(),
vm.print_and_reset_block_counts(),
- vm.print_compiler_stats());
+ vm.print_compiler_stats(),
+ vm.print_varcode_cache_stats());
return outcome_e::success();
}
diff --git a/category/mpt/find_notify_fiber.cpp b/category/mpt/find_notify_fiber.cpp
index 792449acdd..52b50e906a 100644
--- a/category/mpt/find_notify_fiber.cpp
+++ b/category/mpt/find_notify_fiber.cpp
@@ -138,10 +138,7 @@ namespace
// to write new data.
auto const virtual_offset_after = aux.physical_to_virtual(offset);
if (virtual_offset_after == virtual_offset) {
- {
- NodeCache::ConstAccessor acc;
- MONAD_ASSERT(node_cache.find(acc, virtual_offset) == false);
- }
+ MONAD_ASSERT(!node_cache.contains(virtual_offset));
std::shared_ptr const node =
detail::deserialize_node_from_receiver_result(
std::move(buffer_), buffer_off, io_state);
diff --git a/category/mpt/node_cache.hpp b/category/mpt/node_cache.hpp
index 7a3b69eef3..8f00c26300 100644
--- a/category/mpt/node_cache.hpp
+++ b/category/mpt/node_cache.hpp
@@ -44,6 +44,7 @@ class NodeCache final
while (used_bytes_ > max_bytes_ && !active_list_.empty()) {
auto const list_it = std::prev(active_list_.end());
auto &node_to_erase = *list_it;
+ stats_.record_eviction();
map_.erase(list_it->key);
used_bytes_ -= list_it->val.second;
// move to empty list
@@ -60,9 +61,19 @@ class NodeCache final
using Base::ConstAccessor;
using Base::list_node;
- using Base::clear;
+ using Base::contains;
using Base::find;
using Base::size;
+ using Base::stats;
+
+ // Bytes of cached nodes, against the max_bytes the cache was built with.
+ // Read alongside size() to tell which of the two bounds is binding: the
+ // slot count is max_bytes / AVERAGE_NODE_SIZE, so a cache whose nodes run
+ // larger than that average evicts on bytes with slots to spare.
+ size_t used_bytes() const noexcept
+ {
+ return used_bytes_;
+ }
explicit NodeCache(size_t const max_bytes)
: Base(
@@ -75,21 +86,23 @@ class NodeCache final
~NodeCache() = default;
- Map::iterator insert(
+ void insert(
virtual_chunk_offset_t const &virt_offset,
std::shared_ptr const &sp) noexcept
{
MONAD_ASSERT(virt_offset != virtual_chunk_offset_t::invalid_value());
- used_bytes_ += sp->get_mem_size();
- evict_until_under_limit();
-
- auto const [it, erased_value] =
- Base::insert(virt_offset, {sp, sp->get_mem_size()});
+ auto const size = sp->get_mem_size();
+ auto const [_, erased_value] = Base::insert(virt_offset, {sp, size});
+ // Charge the net change. An overwrite replaces an entry already
+ // accounted for, so only the difference needs room; charging the full
+ // size before inserting would evict entries to make room this insert
+ // does not need.
+ used_bytes_ += size;
if (erased_value.has_value()) {
used_bytes_ -= erased_value->second;
}
- return it;
+ evict_until_under_limit();
}
};
diff --git a/category/mpt/test/node_lru_cache_test.cpp b/category/mpt/test/node_lru_cache_test.cpp
index 5ce0118c68..4bf340df51 100644
--- a/category/mpt/test/node_lru_cache_test.cpp
+++ b/category/mpt/test/node_lru_cache_test.cpp
@@ -102,3 +102,76 @@ TEST(NodeCache, works)
ASSERT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 0)));
EXPECT_EQ(get_acc_value(), 0xdead);
}
+
+TEST(NodeCache, counts_hits_misses_and_evictions)
+{
+ NodeCache node_cache(2 * NodeCache::AVERAGE_NODE_SIZE);
+ NodeCache::ConstAccessor acc;
+
+ auto make_node = [] {
+ monad::byte_string value(84, 0);
+ return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0);
+ };
+
+ EXPECT_FALSE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 1)));
+ node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node());
+ node_cache.insert(virtual_chunk_offset_t(2, 0, 1), make_node());
+ ASSERT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 1)));
+ EXPECT_EQ(node_cache.stats().evictions, 0u);
+
+ // Third node exceeds the byte budget and evicts the LRU tail.
+ node_cache.insert(virtual_chunk_offset_t(3, 0, 1), make_node());
+
+ auto const stats = node_cache.stats();
+ EXPECT_EQ(stats.hits, 1u);
+ EXPECT_EQ(stats.misses, 1u);
+ EXPECT_EQ(stats.evictions, 1u);
+}
+
+// Overwriting a key replaces an entry already counted against the budget, so
+// it must not evict anything to make room it does not need.
+TEST(NodeCache, overwriting_a_key_does_not_evict_to_make_room)
+{
+ NodeCache node_cache(2 * NodeCache::AVERAGE_NODE_SIZE);
+ NodeCache::ConstAccessor acc;
+
+ auto make_node = [] {
+ monad::byte_string value(84, 0);
+ return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0);
+ };
+
+ node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node());
+ node_cache.insert(virtual_chunk_offset_t(2, 0, 1), make_node());
+ auto const full = node_cache.used_bytes();
+ ASSERT_EQ(node_cache.size(), 2);
+ ASSERT_EQ(node_cache.stats().evictions, 0u);
+
+ // Same key, same size: the budget is unchanged, so the other entry stays.
+ node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node());
+
+ EXPECT_EQ(node_cache.used_bytes(), full);
+ EXPECT_EQ(node_cache.size(), 2);
+ EXPECT_EQ(node_cache.stats().evictions, 0u);
+ EXPECT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(2, 0, 1)));
+}
+
+TEST(NodeCache, reports_used_bytes_tracking_the_byte_budget)
+{
+ NodeCache node_cache(4 * NodeCache::AVERAGE_NODE_SIZE);
+
+ auto make_node = [] {
+ monad::byte_string value(84, 0);
+ return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0);
+ };
+
+ EXPECT_EQ(node_cache.used_bytes(), 0u);
+
+ auto const first = make_node();
+ auto const first_size = first->get_mem_size();
+ node_cache.insert(virtual_chunk_offset_t(1, 0, 1), first);
+ EXPECT_EQ(node_cache.used_bytes(), first_size);
+
+ auto const second = make_node();
+ node_cache.insert(virtual_chunk_offset_t(2, 0, 1), second);
+ EXPECT_EQ(node_cache.used_bytes(), first_size + second->get_mem_size());
+}
diff --git a/category/statesync/statesync_server_context.hpp b/category/statesync/statesync_server_context.hpp
index 23feb4e728..012454b754 100644
--- a/category/statesync/statesync_server_context.hpp
+++ b/category/statesync/statesync_server_context.hpp
@@ -141,4 +141,7 @@ struct monad_statesync_server_context final : public monad::Db
std::function) override;
virtual uint64_t get_block_number() const override;
+
+ // Reports no stats; reads here share the execution TrieDb's counters.
+ virtual void begin_block_stats() override {}
};
diff --git a/category/vm/compiler.hpp b/category/vm/compiler.hpp
index e289394fd2..62501a0e0f 100644
--- a/category/vm/compiler.hpp
+++ b/category/vm/compiler.hpp
@@ -216,6 +216,16 @@ namespace monad::vm
varcode_cache_.size(), varcode_cache_.approx_weight());
}
+ CacheStatsSnapshot varcode_cache_block_stats() const noexcept
+ {
+ return varcode_cache_.block_stats();
+ }
+
+ void begin_varcode_cache_block_stats() noexcept
+ {
+ varcode_cache_.begin_block_stats();
+ }
+
// For testing: wait for compile job queue to become empty.
void debug_wait_for_empty_queue();
diff --git a/category/vm/utils/lru_weight_cache.hpp b/category/vm/utils/lru_weight_cache.hpp
index 39a44f43ff..706ccab033 100644
--- a/category/vm/utils/lru_weight_cache.hpp
+++ b/category/vm/utils/lru_weight_cache.hpp
@@ -16,6 +16,7 @@
#pragma once
#include
+#include
#include
@@ -49,6 +50,7 @@ namespace monad::vm::utils
std::atomic weight_;
LruList lru_;
HashMap hmap_;
+ CacheStats stats_;
public:
using ConstAccessor = HashMap::const_accessor;
@@ -69,12 +71,19 @@ namespace monad::vm::utils
bool find(ConstAccessor &acc, Key const &key)
{
if (!hmap_.find(acc, key)) {
+ stats_.record_miss();
return false;
}
+ stats_.record_hit();
try_update_lru(&*acc);
return true;
}
+ CacheStatsSnapshot stats() const noexcept
+ {
+ return stats_.snapshot();
+ }
+
/// Insert `value` with `weight` under `key`. Overwrites if there is
/// already a value under `key`.
bool insert(Key const &key, Value const &value, uint32_t const weight)
@@ -179,6 +188,7 @@ namespace monad::vm::utils
break;
}
int64_t const n = evict(target);
+ stats_.record_eviction();
weight_.fetch_sub(n, std::memory_order_acq_rel);
evicted_weight += n;
}
diff --git a/category/vm/varcode_cache.hpp b/category/vm/varcode_cache.hpp
index 6368acf216..a9fd8186d1 100644
--- a/category/vm/varcode_cache.hpp
+++ b/category/vm/varcode_cache.hpp
@@ -16,6 +16,7 @@
#pragma once
#include
+#include
#include
#include
@@ -88,8 +89,24 @@ namespace monad::vm
return weight_cache_.size();
}
+ CacheStatsSnapshot stats() const noexcept
+ {
+ return weight_cache_.stats();
+ }
+
+ CacheStatsSnapshot block_stats() const noexcept
+ {
+ return block_window_.since(weight_cache_.stats());
+ }
+
+ void begin_block_stats() noexcept
+ {
+ block_window_.reset(weight_cache_.stats());
+ }
+
private:
WeightCache weight_cache_;
uint32_t warm_cache_kb_;
+ CacheStatsWindow block_window_;
};
}
diff --git a/category/vm/vm.hpp b/category/vm/vm.hpp
index 7753443ffd..b56ca08312 100644
--- a/category/vm/vm.hpp
+++ b/category/vm/vm.hpp
@@ -157,6 +157,28 @@ namespace monad::vm
return compiler_.find_varcode(code_hash);
}
+ void begin_block_stats() noexcept
+ {
+ compiler_.begin_varcode_cache_block_stats();
+ }
+
+ // The block window, not the cumulative totals. Always emitted;
+ // print_compiler_stats() is empty unless
+ // utils::collect_monad_compiler_stats.
+ std::string print_varcode_cache_stats() const
+ {
+ auto const s = compiler_.varcode_cache_block_stats();
+ uint64_t const lookups = s.hits + s.misses;
+ return std::format(
+ ",vc={:5.1f}%,vch={},vcm={},vce={}",
+ lookups == 0 ? 0.0
+ : 100.0 * static_cast(s.hits) /
+ static_cast(lookups),
+ s.hits,
+ s.misses,
+ s.evictions);
+ }
+
SharedVarcode try_insert_varcode(
bytes32_t const &code_hash, SharedIntercode const &icode)
{
diff --git a/rust/crates/monad-triedb/include/ffi.h b/rust/crates/monad-triedb/include/ffi.h
index fe991e1a49..d81b62fa58 100644
--- a/rust/crates/monad-triedb/include/ffi.h
+++ b/rust/crates/monad-triedb/include/ffi.h
@@ -62,6 +62,29 @@ typedef struct triedb_storage_stats
void triedb_storage_stats_read(TriedbRoInner *, triedb_storage_stats *out);
+// Trie-node LRU counters for this handle. Totals since the handle was opened;
+// reading does not reset them. `used_bytes` is against the node_lru_max_mem
+// the handle was opened with, and `entries` against the slot count derived
+// from it — compare the two to see which bound is binding.
+//
+// Covers triedb_async_read and the traverse calls only. triedb_read is a
+// blocking path that consults no cache, so a caller using it exclusively sees
+// zeros here — which is not the same as an unused cache.
+//
+// Returns false without writing `out` if no counters are available; all-zero
+// is a legitimate reading for an idle cache, so it cannot double as an error.
+typedef struct triedb_node_cache_stats
+{
+ uint64_t hits;
+ uint64_t misses;
+ uint64_t evictions;
+ uint64_t used_bytes;
+ uint64_t entries;
+} triedb_node_cache_stats;
+
+bool triedb_node_cache_stats_read(
+ TriedbRoInner *, triedb_node_cache_stats *out);
+
// Compute the storage page key for a 32-byte slot key on a page-encoded db:
// page_key = slot >> 7. Writes the 32-byte big-endian page key (the key the
// storage trie is looked up by) to out_page_key.
diff --git a/rust/crates/monad-triedb/src/ffi.cpp b/rust/crates/monad-triedb/src/ffi.cpp
index c14d5407a8..410b397533 100644
--- a/rust/crates/monad-triedb/src/ffi.cpp
+++ b/rust/crates/monad-triedb/src/ffi.cpp
@@ -193,6 +193,23 @@ void triedb_storage_stats_read(
out->disk_used_bytes = stats.disk_used_bytes;
}
+bool triedb_node_cache_stats_read(
+ TriedbRoInner *const db, triedb_node_cache_stats *const out)
+{
+ if (out == nullptr || db == nullptr) {
+ return false;
+ }
+ *out = {};
+ auto const &cache = db->async_ctx.node_cache;
+ auto const stats = cache.stats();
+ out->hits = stats.hits;
+ out->misses = stats.misses;
+ out->evictions = stats.evictions;
+ out->used_bytes = static_cast(cache.used_bytes());
+ out->entries = static_cast(cache.size());
+ return true;
+}
+
void triedb_compute_page_key(
uint8_t const *const slot_key, uint8_t *const out_page_key)
{
diff --git a/rust/crates/monad-triedb/src/ffi.rs b/rust/crates/monad-triedb/src/ffi.rs
index c50bf275c9..18fec8ce20 100644
--- a/rust/crates/monad-triedb/src/ffi.rs
+++ b/rust/crates/monad-triedb/src/ffi.rs
@@ -23,9 +23,9 @@ pub(crate) use self::bindings::{
triedb_earliest_version, triedb_finalize, triedb_free_valset, triedb_is_page_encoded,
triedb_latest_finalized_version, triedb_latest_proposed_block_id,
triedb_latest_proposed_version, triedb_latest_verified_version, triedb_latest_voted_block_id,
- triedb_latest_voted_version, triedb_migration_phase, triedb_open, triedb_poll, triedb_read,
- triedb_read_valset, triedb_storage_stats, triedb_storage_stats_read, triedb_traverse,
- TriedbRoInner,
+ triedb_latest_voted_version, triedb_migration_phase, triedb_node_cache_stats,
+ triedb_node_cache_stats_read, triedb_open, triedb_poll, triedb_read, triedb_read_valset,
+ triedb_storage_stats, triedb_storage_stats_read, triedb_traverse, TriedbRoInner,
};
pub use self::bindings::{validator_data, validator_set};
diff --git a/rust/crates/monad-triedb/src/lib.rs b/rust/crates/monad-triedb/src/lib.rs
index e60f3c09ee..d5adfb12b3 100644
--- a/rust/crates/monad-triedb/src/lib.rs
+++ b/rust/crates/monad-triedb/src/lib.rs
@@ -72,6 +72,20 @@ pub struct StorageStats {
pub disk_used_bytes: u64,
}
+/// Trie-node LRU counters for one handle. Totals since the handle was opened;
+/// reading does not reset them, so a scraper derives its own rates.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct NodeCacheStats {
+ pub hits: u64,
+ pub misses: u64,
+ pub evictions: u64,
+ /// Bytes of cached nodes, against the handle's `node_lru_max_mem`.
+ pub used_bytes: u64,
+ /// Cached nodes, against the slot count derived from `node_lru_max_mem`.
+ /// Compare with `used_bytes` to see which of the two bounds is binding.
+ pub entries: u64,
+}
+
struct SenderContext {
sender: Sender