Compare commits

...

2 Commits

Author SHA1 Message Date
Denis Angell
36a659905f feat: report node cache size and bytes in get_counts 2026-08-06 15:17:43 -04:00
Denis Angell
7786baf5b5 feat: bound the remaining advisory caches within the memory budget 2026-08-06 15:10:58 -04:00
13 changed files with 295 additions and 21 deletions

View File

@@ -18,6 +18,7 @@
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <type_traits>
@@ -74,11 +75,25 @@ public:
using shared_pointer_type = SharedPointerType;
public:
/**
* A byte budget for the strongly-cached entries. Each entry is charged
* `cost(ptr)` bytes when it becomes strong; growth past `bytes` evicts
* like the entry cap. The cost should include the value's heap payload
* plus per-entry overhead.
*/
struct ByteBudget
{
std::size_t bytes = 0;
std::function<std::size_t(SharedPointerType const&)> cost;
};
/**
* @param cacheHardCap When positive, a hard upper bound on the number of
* strongly-cached entries, enforced by demoting the approximately
* oldest entry whenever growth would exceed it. 0 disables the cap
* (the periodic sweep alone bounds the cache).
* @param byteBudget When set, a hard upper bound on the charged bytes of
* strongly-cached entries, enforced the same way.
*/
TaggedCache(
std::string const& name,
@@ -87,7 +102,8 @@ public:
clock_type& clock,
beast::Journal journal,
beast::insight::Collector::ptr const& collector = beast::insight::NullCollector::make(),
int cacheHardCap = 0);
int cacheHardCap = 0,
std::optional<ByteBudget> byteBudget = std::nullopt);
public:
/**
@@ -108,6 +124,13 @@ public:
int
getTrackSize() const;
/**
* Returns the charged bytes of strongly-cached entries; 0 unless a
* byte budget with a cost function is configured.
*/
std::size_t
getCacheBytes() const;
float
getHitRate();
@@ -322,6 +345,10 @@ private:
shared_weak_combo_pointer_type ptr;
clock_type::time_point lastAccess;
// Bytes charged against the byte budget while strong; 0 when weak
// or when no budget is configured.
std::uint32_t costBytes{0};
ValueEntry(clock_type::time_point const& lastAccess, shared_pointer_type const& ptr)
: ptr(ptr), lastAccess(lastAccess)
{
@@ -378,6 +405,7 @@ private:
KeyValueCacheType::map_type& partition,
SweptPointersVector& stuffToSweep,
std::atomic<int>& allRemovals,
std::atomic<std::uint64_t>& allBytesRemoved,
std::scoped_lock<std::recursive_mutex> const&);
[[nodiscard]] std::thread
@@ -387,6 +415,7 @@ private:
KeyOnlyCacheType::map_type& partition,
SweptPointersVector&,
std::atomic<int>& allRemovals,
std::atomic<std::uint64_t>& allBytesRemoved,
std::scoped_lock<std::recursive_mutex> const&);
beast::Journal journal_;
@@ -409,6 +438,13 @@ private:
// weak-to-strong revivals). 0 disables it (sweep-only sizing).
int const cacheHardCap_;
// Byte-denominated bound on strongly-cached entries, enforced the same
// way; each entry is charged by the budget's cost function while strong.
std::optional<ByteBudget> const byteBudget_;
// Charged bytes of strongly-cached entries (under mutex_).
std::size_t cacheBytes_{0};
// Total hard-cap evictions (under mutex_); the first marks saturation
// onset for logging.
std::uint64_t hardCapEvictions_{0};
@@ -416,6 +452,17 @@ private:
// Number of items cached
int cacheCount_{0};
// Charge or release an entry's bytes against the byte budget. No-ops
// without a configured budget; callers hold mutex_.
void
chargeEntry(ValueEntry& entry, SharedPointerType const& ptr);
void
dischargeEntry(ValueEntry& entry);
// True when either the entry cap or the byte budget is exceeded.
[[nodiscard]] bool
overHardCap() const;
// Rotating bucket cursor for evictForHardCap so successive over-cap
// evictions sweep the whole partition (CLOCK hand) instead of repeatedly
// sampling the head buckets. Advanced under mutex_.

View File

@@ -6,6 +6,7 @@
#include <xrpl/basics/scope.h>
#include <algorithm>
#include <limits>
namespace xrpl {
@@ -58,7 +59,8 @@ inline TaggedCache<
clock_type& clock,
beast::Journal journal,
beast::insight::Collector::ptr const& collector,
int cacheHardCap)
int cacheHardCap,
std::optional<ByteBudget> byteBudget)
: journal_(journal)
, clock_(clock)
, stats_(
@@ -69,9 +71,65 @@ inline TaggedCache<
, targetSize_(size)
, targetAge_(expiration)
, cacheHardCap_(cacheHardCap)
, byteBudget_(std::move(byteBudget))
{
}
template <
class Key,
class T,
bool IsKeyCache,
class SharedWeakUnionPointer,
class SharedPointerType,
class Hash,
class KeyEqual,
class Mutex>
inline void
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
chargeEntry(ValueEntry& entry, SharedPointerType const& ptr)
{
if (byteBudget_ && byteBudget_->cost)
{
entry.costBytes = static_cast<std::uint32_t>(std::min<std::size_t>(
byteBudget_->cost(ptr), std::numeric_limits<std::uint32_t>::max()));
cacheBytes_ += entry.costBytes;
}
}
template <
class Key,
class T,
bool IsKeyCache,
class SharedWeakUnionPointer,
class SharedPointerType,
class Hash,
class KeyEqual,
class Mutex>
inline void
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
dischargeEntry(ValueEntry& entry)
{
cacheBytes_ -= entry.costBytes;
entry.costBytes = 0;
}
template <
class Key,
class T,
bool IsKeyCache,
class SharedWeakUnionPointer,
class SharedPointerType,
class Hash,
class KeyEqual,
class Mutex>
inline bool
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
overHardCap() const
{
return (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_) ||
(byteBudget_ && cacheBytes_ > byteBudget_->bytes);
}
template <
class Key,
class T,
@@ -122,6 +180,23 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
return cacheCount_;
}
template <
class Key,
class T,
bool IsKeyCache,
class SharedWeakUnionPointer,
class SharedPointerType,
class Hash,
class KeyEqual,
class Mutex>
inline std::size_t
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
getCacheBytes() const
{
std::scoped_lock const lock(mutex_);
return cacheBytes_;
}
template <
class Key,
class T,
@@ -173,6 +248,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::scoped_lock const lock(mutex_);
cache_.clear();
cacheCount_ = 0;
cacheBytes_ = 0;
}
template <
@@ -191,6 +267,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::scoped_lock const lock(mutex_);
cache_.clear();
cacheCount_ = 0;
cacheBytes_ = 0;
hits_ = 0;
misses_ = 0;
}
@@ -251,8 +328,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
constexpr int kMaxDemotionsPerCall = 8;
std::size_t const maxBuckets = std::min<std::size_t>(bucketCount, 4 * kEvictSampleBudget);
for (int demotions = 0; cacheCount_ > cacheHardCap_ && demotions < kMaxDemotionsPerCall;
++demotions)
for (int demotions = 0; overHardCap() && demotions < kMaxDemotionsPerCall; ++demotions)
{
int sampled = 0;
std::size_t bucketsWalked = 0;
@@ -292,6 +368,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
if (oldest == partition.end() || oldest == keep || oldest->second.isWeak())
return;
dischargeEntry(oldest->second);
if (oldest->second.ptr.useCount() == 1)
{
// Sole owner: release entirely.
@@ -362,16 +439,24 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::vector<std::thread> workers;
workers.reserve(cache_.partitions());
std::atomic<int> allRemovals = 0;
std::atomic<std::uint64_t> allBytesRemoved = 0;
for (std::size_t p = 0; p < cache_.partitions(); ++p)
{
workers.push_back(sweepHelper(
whenExpire, now, cache_.map()[p], allStuffToSweep[p], allRemovals, lock));
whenExpire,
now,
cache_.map()[p],
allStuffToSweep[p],
allRemovals,
allBytesRemoved,
lock));
}
for (std::thread& worker : workers)
worker.join();
cacheCount_ -= allRemovals;
cacheBytes_ -= std::min<std::uint64_t>(allBytesRemoved, cacheBytes_);
}
// At this point allStuffToSweep will go out of scope outside the lock
// and decrement the reference count on each strong pointer.
@@ -410,6 +495,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
if (entry.isCached())
{
--cacheCount_;
dischargeEntry(entry);
entry.ptr.convertToWeak();
ret = true;
}
@@ -465,9 +551,10 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::forward_as_tuple(clock_.now(), data))
.first;
++cacheCount_;
chargeEntry(emplacedIt.mit->second, data);
// The just-inserted entry is the newest; evictForHardCap skips it
// and drops the oldest in its partition.
if (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_)
if (overHardCap())
evictForHardCap(*emplacedIt.ait, emplacedIt.mit);
return false;
}
@@ -494,7 +581,9 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
{
if (shouldReplaceCached())
{
dischargeEntry(entry);
entry.ptr = data;
chargeEntry(entry, data);
}
else if constexpr (!replaceCached)
{
@@ -519,14 +608,16 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
}
++cacheCount_;
if (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_)
chargeEntry(entry, entry.ptr.getStrong());
if (overHardCap())
evictForHardCap(*cit.ait, cit.mit);
return true;
}
entry.ptr = data;
++cacheCount_;
if (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_)
chargeEntry(entry, data);
if (overHardCap())
evictForHardCap(*cit.ait, cit.mit);
return false;
@@ -802,8 +893,17 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::scoped_lock const l(mutex_);
++misses_;
auto const [it, inserted] = cache_.emplace(digest, Entry(clock_.now(), std::move(sle)));
if (!inserted)
if (inserted)
{
++cacheCount_;
chargeEntry(it.mit->second, it.mit->second.ptr.getStrong());
if (overHardCap())
evictForHardCap(*it.ait, it.mit);
}
else
{
it->second.touch(clock_.now());
}
return it->second.ptr.getStrong();
}
// End CachedSLEs functions.
@@ -837,7 +937,8 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
{
// independent of cache size, so not counted as a hit
++cacheCount_;
if (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_)
chargeEntry(entry, entry.ptr.getStrong());
if (overHardCap())
evictForHardCap(*cit.ait, cit.mit);
entry.touch(clock_.now());
return entry.ptr.getStrong();
@@ -891,11 +992,13 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
KeyValueCacheType::map_type& partition,
SweptPointersVector& stuffToSweep,
std::atomic<int>& allRemovals,
std::atomic<std::uint64_t>& allBytesRemoved,
std::scoped_lock<std::recursive_mutex> const&)
{
return std::thread([&, this]() {
int cacheRemovals = 0;
int mapRemovals = 0;
std::uint64_t bytesRemoved = 0;
// Keep references to all the stuff we sweep
// so that we can destroy them outside the lock.
@@ -922,6 +1025,8 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
{
// strong, expired
++cacheRemovals;
bytesRemoved += cit->second.costBytes;
cit->second.costBytes = 0;
if (cit->second.ptr.useCount() == 1)
{
stuffToSweep.emplace_back(std::move(cit->second.ptr));
@@ -951,6 +1056,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
}
allRemovals += cacheRemovals;
allBytesRemoved += bytesRemoved;
});
}
@@ -971,6 +1077,7 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
KeyOnlyCacheType::map_type& partition,
SweptPointersVector&,
std::atomic<int>& allRemovals,
std::atomic<std::uint64_t>&,
std::scoped_lock<std::recursive_mutex> const&)
{
return std::thread([&, this]() {

View File

@@ -98,6 +98,7 @@ struct Keys
static constexpr auto kBgThreads = "bg_threads";
static constexpr auto kBlockSize = "block_size";
static constexpr auto kCacheAge = "cache_age";
static constexpr auto kCacheBytes = "cache_bytes";
static constexpr auto kCacheMb = "cache_mb";
static constexpr auto kCacheSize = "cache_size";
static constexpr auto kClientMaxWindowBits = "client_max_window_bits";

View File

@@ -197,7 +197,7 @@ public:
return fetchSz_;
}
void
virtual void
getCountsJson(json::Value& obj);
/**

View File

@@ -13,6 +13,7 @@
#include <xrpl/nodestore/Database.h>
#include <xrpl/nodestore/NodeObject.h>
#include <xrpl/nodestore/Scheduler.h>
#include <xrpl/protocol/jss.h>
#include <chrono>
#include <cstdint>
@@ -58,12 +59,31 @@ public:
if (cacheSize.has_value() || cacheAge.has_value())
{
cache_ = std::make_shared<TaggedCache<uint256, NodeObject>>(
using Cache = TaggedCache<uint256, NodeObject>;
// Serialized sizes are exact, so bound this cache by bytes when
// cache_bytes is set; the count target remains advisory.
std::optional<Cache::ByteBudget> byteBudget;
if (auto const bytes = config.exists(Keys::kCacheBytes)
? get<std::uint64_t>(config, Keys::kCacheBytes)
: 0)
{
// Charge the blob plus per-entry overhead (map node, weak
// tracking, control block).
byteBudget = Cache::ByteBudget{bytes, [](std::shared_ptr<NodeObject> const& obj) {
return (obj ? obj->getData().size() : 0) + 160;
}};
}
cache_ = std::make_shared<Cache>(
"DatabaseNodeImp",
cacheSize.value_or(0),
std::chrono::minutes(cacheAge.value_or(0)),
stopwatch(),
j);
j,
beast::insight::NullCollector::make(),
0,
std::move(byteBudget));
}
XRPL_ASSERT(
@@ -120,6 +140,17 @@ public:
void
sweep() override;
void
getCountsJson(json::Value& obj) override
{
Database::getCountsJson(obj);
if (cache_)
{
obj[jss::node_cache_size] = static_cast<json::UInt>(cache_->getCacheSize());
obj[jss::node_cache_bytes] = std::to_string(cache_->getCacheBytes());
}
}
private:
// Cache for database objects. This cache is not always initialized. Check
// for null before using.

View File

@@ -430,6 +430,8 @@ JSS(no_ripple); // out: AccountLines
JSS(no_ripple_peer); // out: AccountLines
JSS(node); // out: LedgerEntry
JSS(node_binary); // out: LedgerEntry
JSS(node_cache_bytes); // out: GetCounts
JSS(node_cache_size); // out: GetCounts
JSS(node_read_bytes); // out: GetCounts
JSS(node_read_errors); // out: GetCounts
JSS(node_read_retries); // out: GetCounts

View File

@@ -297,4 +297,43 @@ TEST(TaggedCacheTest, hard_cap_disabled)
EXPECT_EQ(uncapped.getCacheSize(), 1000);
}
TEST(TaggedCacheTest, byte_budget_enforced_on_insert)
{
using namespace std::chrono_literals;
beast::Journal const journal{TestSink::instance()};
TestStopwatch clock;
clock.set(0);
using Key = LedgerIndex;
using Value = std::string;
using Cache = TaggedCache<Key, Value>;
// Each 100-byte value is charged exactly; the 4 KiB budget holds ~40
// entries, enforced as the cache grows, with the sweep unable to fire.
Cache::ByteBudget budget{
4096, [](std::shared_ptr<Value> const& v) { return v ? v->size() : 0; }};
Cache capped(
"bytes",
1'000'000,
3600s,
clock,
journal,
beast::insight::NullCollector::make(),
0,
budget);
bool everExceeded = false;
for (Key k = 1; k <= 200; ++k)
{
capped.insert(k, std::string(100, 'x'));
if (capped.getCacheBytes() > 4096)
everExceeded = true;
}
EXPECT_FALSE(everExceeded);
EXPECT_LE(capped.getCacheBytes(), std::size_t{4096});
EXPECT_GT(capped.getCacheSize(), 0);
EXPECT_LT(capped.getCacheSize(), 50);
}
} // namespace xrpl

View File

@@ -42,7 +42,11 @@ LedgerHistory::LedgerHistory(beast::insight::Collector::ptr const& collector, Ap
app_.config().getValueFor(SizedItem::LedgerSize),
std::chrono::seconds{app_.config().getValueFor(SizedItem::LedgerAge)},
stopwatch(),
app_.getJournal("TaggedCache"))
app_.getJournal("TaggedCache"),
beast::insight::NullCollector::make(),
// A ledger byte size is ill-posed (nodes are shared copy-on-write
// across ledgers), so bound this cache by count.
app_.config().getValueFor(SizedItem::LedgerSize))
, consensusValidated_(
"ConsensusValidated",
64,

View File

@@ -136,7 +136,9 @@ LedgerMaster::LedgerMaster(
65536,
std::chrono::seconds{45},
stopwatch,
app_.getJournal("TaggedCache"))
app_.getJournal("TaggedCache"),
beast::insight::NullCollector::make(),
65536)
, stats_([this] { collectMetrics(); }, collector)
{
}

View File

@@ -258,6 +258,10 @@ public:
std::unique_ptr<TxQ> txQ_;
ClosureCounter<void, boost::system::error_code const&> waitHandlerCounter_;
boost::asio::steady_timer sweepTimer_;
// Set by doSweep when post-trim RSS exceeds 150% of the memory budget;
// accelerates the sweep cadence until RSS retreats below 130%.
std::atomic<bool> memoryPressure_{false};
boost::asio::steady_timer entropyTimer_;
std::optional<SQLiteDatabase> relationalDatabase_;
@@ -365,10 +369,12 @@ public:
logs_->journal("TaggedCache"))
, cachedSLEs_(
"Cached SLEs",
0,
config_->getValueFor(SizedItem::SleCacheSize),
std::chrono::minutes(1),
stopwatch(),
logs_->journal("CachedSLEs"))
logs_->journal("CachedSLEs"),
beast::insight::NullCollector::make(),
config_->getValueFor(SizedItem::SleCacheSize))
, networkIDService_(std::make_unique<NetworkIDServiceImpl>(config_->networkId))
, validatorKeys_(*config_, journal_)
, resourceManager_(
@@ -916,9 +922,11 @@ public:
}))
{
using namespace std::chrono;
sweepTimer_.expires_after(
seconds{config_->sweepInterval.value_or(
config_->getValueFor(SizedItem::SweepInterval))});
auto interval = seconds{
config_->sweepInterval.value_or(config_->getValueFor(SizedItem::SweepInterval))};
if (memoryPressure_)
interval = std::min(interval, seconds{10});
sweepTimer_.expires_after(interval);
sweepTimer_.async_wait(std::move(*optionalCountedHandler));
}
}
@@ -1087,7 +1095,30 @@ public:
<< "; size after: " << cachedSLEs_.size();
}
mallocTrim("doSweep", journal_);
auto const trim = mallocTrim("doSweep", journal_);
// Circuit breaker, not a control loop: byte-charged caps do the real
// bounding, and RSS legitimately lags eviction (allocator retention),
// so pressure only accelerates sweeps and warns. Hysteresis: trip at
// 150% of the budget, reset below 130%.
if (auto const budget = config_->cacheMemoryBudget(); budget != 0 && trim.rssAfterKB > 0)
{
auto const rss = static_cast<std::uint64_t>(trim.rssAfterKB) * 1024;
if (rss > budget + budget / 2)
{
if (!memoryPressure_)
{
JLOG(journal_.warn())
<< "memory pressure: RSS " << (rss >> 20) << " MB exceeds 150% of the "
<< (budget >> 20) << " MB memory_limit; sweeping every 10s";
}
memoryPressure_ = true;
}
else if (rss < budget + budget * 3 / 10)
{
memoryPressure_ = false;
}
}
// Set timer to do another sweep later.
setSweepTimer();

View File

@@ -178,6 +178,11 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
if (!nscfg.exists(Keys::kCacheAge))
nscfg.set(Keys::kCacheAge, "5");
// An eighth of the memory budget bounds the serialized-object cache.
if (auto const budget = app_.config().cacheMemoryBudget();
budget != 0 && !nscfg.exists(Keys::kCacheBytes))
nscfg.set(Keys::kCacheBytes, std::to_string(budget / 8));
std::unique_ptr<node_store::Database> db;
if (deleteInterval_ != 0u)

View File

@@ -40,6 +40,7 @@ enum class SizedItem : std::size_t {
LgrDbCache,
BurstSize,
AccountIdCacheSize,
SleCacheSize,
};
/**

View File

@@ -1346,6 +1346,10 @@ Config::getValueFor(SizedItem item) const
// Fixed regardless of budget: ~22 MB at 72 bytes per slot, and
// the value stays prime for hash distribution.
return 300007;
case SizedItem::SleCacheSize:
// Closed-ledger SLEs pulled by RPC and pathfinding, a few KB
// each; previously unbounded.
return std::clamp(gb * 1024, 4096, 262144);
}
UNREACHABLE("xrpl::Config::getValueFor : invalid item");