diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index a04f2653285..81709f2a548 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -28,6 +28,8 @@ This section contains changes targeting a future version. ### Additions +- `server_info` (admin): The `node_size` field has been removed along with the deprecated `[node_size]` config setting it reported. Admin responses now include `memory_limit`, the cache memory budget in gigabytes (0 when enforcement is disabled). + - `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`. When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present. diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 9e334e6f4f3..fdc9af06cb3 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -1306,23 +1306,40 @@ # # [node_size] # -# Tunes the servers based on the expected load and available memory. Legal -# sizes are "tiny", "small", "medium", "large", and "huge". We recommend -# you start at the default and raise the setting if you have extra memory. -# -# The code attempts to automatically determine the appropriate size for -# this parameter based on the amount of RAM and the number of execution -# cores available to the server. The current decision matrix is: -# -# | | Cores | -# |---------|------------------------| -# | RAM | 1 | 2 or 3 | ≥ 4 | -# |---------|------|--------|--------| -# | < ~8GB | tiny | tiny | tiny | -# | < ~12GB | tiny | small | small | -# | < ~16GB | tiny | small | medium | -# | < ~24GB | tiny | small | large | -# | < ~32GB | tiny | small | huge | +# DEPRECATED. Each size is now an alias for a [memory_limit] value: +# tiny = 4, small = 8, medium = 32, large = 64, huge = 128. Set +# [memory_limit] instead; setting this logs a warning at startup. +# +# [memory_limit] +# +# The memory budget, in gigabytes, that the server sizes its caches +# within. Cache sizes scale with the budget; the SHAMap tree node cache +# is capped to fit within half of it, enforced as it grows. Defaults to +# detected physical RAM (capped by the container limit when one is set); +# 0 selects minimal sizes with no enforcement. Values above 1024 are +# rejected, and a value above detected RAM logs a warning. +# Set this when xrpld shares the machine with other services or runs in +# a container with a memory limit below the host's RAM. Thread counts +# are unrelated: they come from the core count and the [workers] / +# [io_workers] overrides. +# +# Example: +# memory_limit = 16 +# +# [tree_cache_age] +# +# Seconds a SHAMap tree node stays cached after its last use. The default +# is 300. Accepted values are 10 to 3600. +# +# [ledger_cache_age] +# +# Seconds a full ledger stays in the ledger cache after its last use. The +# default is 180. Accepted values are 10 to 3600. +# +# [ledger_fetch_size] +# +# How many historical ledgers to acquire per fetch pass while backfilling. +# The default is 4. Accepted values are 1 to 16. # # [signing_support] # diff --git a/include/xrpl/basics/TaggedCache.h b/include/xrpl/basics/TaggedCache.h index 7bb2cb552b5..2eeb4996e72 100644 --- a/include/xrpl/basics/TaggedCache.h +++ b/include/xrpl/basics/TaggedCache.h @@ -74,13 +74,20 @@ class TaggedCache using shared_pointer_type = SharedPointerType; public: + /** + * @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). + */ TaggedCache( std::string const& name, int size, clock_type::duration expiration, clock_type& clock, beast::Journal journal, - beast::insight::Collector::ptr const& collector = beast::insight::NullCollector::make()); + beast::insight::Collector::ptr const& collector = beast::insight::NullCollector::make(), + int cacheHardCap = 0); public: /** @@ -357,6 +364,13 @@ class TaggedCache using cache_type = hardened_partitioned_hash_map; + // Bounded approximate-LRU eviction from a single partition. Keeps the + // strong-entry count at/below cacheHardCap_ as new entries are inserted, so + // a burst can't drive the cache past its RAM budget between timer sweeps. + // No-op unless cacheHardCap_ > 0 (opt-in); caller holds mutex_. + void + evictForHardCap(cache_type::map_type& partition, cache_type::map_type::iterator const& keep); + [[nodiscard]] std::thread sweepHelper( clock_type::time_point const& whenExpire, @@ -390,8 +404,23 @@ class TaggedCache // Desired maximum cache age clock_type::duration const targetAge_; + // Hard upper bound on strongly-cached entries, enforced by + // evictForHardCap whenever the strong count grows (fresh inserts and + // weak-to-strong revivals). 0 disables it (sweep-only sizing). + int const cacheHardCap_; + + // Total hard-cap evictions (under mutex_); the first marks saturation + // onset for logging. + std::uint64_t hardCapEvictions_{0}; + // Number of items cached int cacheCount_{0}; + + // 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_. + std::size_t evictHand_{0}; + cache_type cache_; // Hold strong reference to recent objects std::uint64_t hits_{0}; std::uint64_t misses_{0}; diff --git a/include/xrpl/basics/TaggedCache.ipp b/include/xrpl/basics/TaggedCache.ipp index 447743a7b77..55a6f986c7e 100644 --- a/include/xrpl/basics/TaggedCache.ipp +++ b/include/xrpl/basics/TaggedCache.ipp @@ -57,7 +57,8 @@ inline TaggedCache< clock_type::duration expiration, clock_type& clock, beast::Journal journal, - beast::insight::Collector::ptr const& collector) + beast::insight::Collector::ptr const& collector, + int cacheHardCap) : journal_(journal) , clock_(clock) , stats_( @@ -67,6 +68,7 @@ inline TaggedCache< , name_(name) , targetSize_(size) , targetAge_(expiration) + , cacheHardCap_(cacheHardCap) { } @@ -219,6 +221,102 @@ TaggedCache +inline void +TaggedCache:: + evictForHardCap(cache_type::map_type& partition, cache_type::map_type::iterator const& keep) +{ + // Caller holds mutex_. Only value caches carry strong/weak entries; key + // caches never enable the hard cap, so this is a no-op for them. + if constexpr (!IsKeyCache) + { + std::size_t const bucketCount = partition.bucket_count(); + if (bucketCount == 0) + return; + + // Approximate LRU with bounded work per call: sample a window of + // strong entries starting at the rotating bucket cursor and demote + // the oldest, repeating until the count is back under the cap or the + // demotion budget is spent. Growth paths raise the count by one at a + // time, so the budget lets eviction catch up without stalling them. + constexpr int kEvictSampleBudget = 64; + constexpr int kMaxDemotionsPerCall = 8; + std::size_t const maxBuckets = std::min(bucketCount, 4 * kEvictSampleBudget); + + for (int demotions = 0; cacheCount_ > cacheHardCap_ && demotions < kMaxDemotionsPerCall; + ++demotions) + { + int sampled = 0; + std::size_t bucketsWalked = 0; + key_type oldestKey{}; + bool haveOldest = false; + clock_type::time_point oldestAccess{}; + + std::size_t b = evictHand_ % bucketCount; + while (sampled < kEvictSampleBudget && bucketsWalked < maxBuckets) + { + for (auto lit = partition.begin(b); lit != partition.end(b); ++lit) + { + if (lit->first == keep->first || lit->second.isWeak()) + continue; + if (!haveOldest || lit->second.lastAccess < oldestAccess) + { + oldestAccess = lit->second.lastAccess; + oldestKey = lit->first; + haveOldest = true; + } + if (++sampled >= kEvictSampleBudget) + break; + } + b = (b + 1) % bucketCount; + ++bucketsWalked; + } + evictHand_ = b; // resume the scan here on the next over-cap call + + if (!haveOldest) + { + JLOG(journal_.debug()) << name_ << ": over hard cap " << cacheHardCap_ + << " but eviction sample found no strong entry to demote"; + return; + } + + auto oldest = partition.find(oldestKey); + if (oldest == partition.end() || oldest == keep || oldest->second.isWeak()) + return; + + if (oldest->second.ptr.useCount() == 1) + { + // Sole owner: release entirely. + partition.erase(oldest); + } + else + { + // Others hold it: keep it weakly tracked. + oldest->second.ptr.convertToWeak(); + } + --cacheCount_; + + // First eviction marks saturation onset; then a heartbeat every + // 100k to avoid flooding. + ++hardCapEvictions_; + if (hardCapEvictions_ == 1 || hardCapEvictions_ % 100000 == 0) + { + JLOG(journal_.warn()) << name_ << ": hard-cap eviction #" << hardCapEvictions_ + << " (cap " << cacheHardCap_ << ", strong " << cacheCount_ + << ") - cache saturated, growth now evicts"; + } + } + } +} + template < class Key, class T, @@ -360,11 +458,17 @@ TaggedCache 0 && cacheCount_ > cacheHardCap_) + evictForHardCap(*emplacedIt.ait, emplacedIt.mit); return false; } @@ -415,11 +519,15 @@ TaggedCache 0 && cacheCount_ > cacheHardCap_) + evictForHardCap(*cit.ait, cit.mit); return true; } entry.ptr = data; ++cacheCount_; + if (cacheHardCap_ > 0 && cacheCount_ > cacheHardCap_) + evictForHardCap(*cit.ait, cit.mit); return false; } @@ -729,6 +837,8 @@ TaggedCache 0 && cacheCount_ > cacheHardCap_) + evictForHardCap(*cit.ait, cit.mit); entry.touch(clock_.now()); return entry.ptr.getStrong(); } diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 5514e0e77ba..a947e17fead 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -22,10 +22,13 @@ struct Sections static constexpr auto kIoWorkers = "io_workers"; static constexpr auto kIps = "ips"; static constexpr auto kIpsFixed = "ips_fixed"; + static constexpr auto kLedgerCacheAge = "ledger_cache_age"; + static constexpr auto kLedgerFetchSize = "ledger_fetch_size"; static constexpr auto kLedgerHistory = "ledger_history"; static constexpr auto kLedgerReplay = "ledger_replay"; static constexpr auto kLedgerTxTables = "ledger_tx_tables"; static constexpr auto kMaxTransactions = "max_transactions"; + static constexpr auto kMemoryLimit = "memory_limit"; static constexpr auto kNetworkId = "network_id"; static constexpr auto kNetworkQuorum = "network_quorum"; static constexpr auto kNodeDatabase = "node_db"; @@ -63,6 +66,7 @@ struct Sections static constexpr auto kSslVerifyFile = "ssl_verify_file"; static constexpr auto kSweepInterval = "sweep_interval"; static constexpr auto kTransactionQueue = "transaction_queue"; + static constexpr auto kTreeCacheAge = "tree_cache_age"; static constexpr auto kValidationSeed = "validation_seed"; static constexpr auto kValidatorKeys = "validator_keys"; static constexpr auto kValidatorKeyRevocation = "validator_key_revocation"; diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..5b0f978b4c7 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -395,6 +395,7 @@ JSS(mean); // out: get_aggregate_price JSS(median); // out: get_aggregate_price JSS(median_fee); // out: TxQ JSS(median_level); // out: TxQ +JSS(memory_limit); // out: server_info JSS(message); // error. JSS(meta); // out: NetworkOPs, AccountTx*, Tx JSS(meta_blob); // out: NetworkOPs, AccountTx*, Tx @@ -435,7 +436,6 @@ JSS(node_read_retries); // out: GetCounts JSS(node_reads_hit); // out: GetCounts JSS(node_reads_total); // out: GetCounts JSS(node_reads_duration_us); // out: GetCounts -JSS(node_size); // out: server_info JSS(nodes); // out: VaultInfo JSS(nodestore); // out: GetCounts JSS(node_writes); // out: GetCounts diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp index 6ee7442d235..a13c273bcb0 100644 --- a/src/test/app/SHAMapStore_test.cpp +++ b/src/test/app/SHAMapStore_test.cpp @@ -502,7 +502,7 @@ class SHAMapStore_test : public beast::unit_test::Suite auto backend{node_store::Manager::instance().makeBackend( section, - megabytes(env.app().config().getValueFor(SizedItem::BurstSize, std::nullopt)), + megabytes(env.app().config().getValueFor(SizedItem::BurstSize)), scheduler, env.app().getJournal("NodeStoreTest"))}; backend->open(); @@ -524,22 +524,12 @@ class SHAMapStore_test : public beast::unit_test::Suite // Normally, SHAMapStoreImp handles all these details. auto nscfg = env.app().config().section(Sections::kNodeDatabase); - // Provide default values. + // Provide default values (mirrors SHAMapStoreImp::makeNodeStore). if (!nscfg.exists(Keys::kCacheSize)) - { - nscfg.set( - Keys::kCacheSize, - std::to_string( - env.app().config().getValueFor(SizedItem::TreeCacheSize, std::nullopt))); - } + nscfg.set(Keys::kCacheSize, "16384"); if (!nscfg.exists(Keys::kCacheAge)) - { - nscfg.set( - Keys::kCacheAge, - std::to_string( - env.app().config().getValueFor(SizedItem::TreeCacheAge, std::nullopt))); - } + nscfg.set(Keys::kCacheAge, "5"); NodeStoreScheduler scheduler(env.app().getJobQueue()); diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index e98a0e1e881..e670b7851ea 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -596,6 +596,87 @@ main BEAST_EXPECT(c.networkId == 10000); } + void + testMemoryLimit() + { + testcase("memory limit"); + + { + Config c; + c.loadFromString(""); + BEAST_EXPECT(!c.memoryLimit); + } + + auto const parse = [](std::string const& value) { + Config c; + c.loadFromString("[memory_limit]\n" + value + "\n"); + return c; + }; + + BEAST_EXPECT(parse("16").memoryLimit == std::uint64_t{16} << 30); + BEAST_EXPECT(parse("0").memoryLimit == std::uint64_t{0}); + BEAST_EXPECT(parse("0").cacheMemoryBudget() == 0); + + // Garbage and out-of-range values are rejected. + expectException([&parse] { parse("banana"); }); + expectException([&parse] { parse("2000"); }); + + // Standalone mode does not change the budget: detected RAM unless + // a limit is configured. + { + Config c; + c.setupControl(true, true, true); + c.loadFromString("[memory_limit]\n8\n"); + BEAST_EXPECT(c.cacheMemoryBudget() == std::uint64_t{8} << 30); + } + + // Values derive from the budget: half of it at 8 KiB per entry for + // the tree cache; 0 yields the floors. + BEAST_EXPECT(parse("16").getValueFor(SizedItem::TreeCacheSize) == 1048576); + BEAST_EXPECT(parse("64").getValueFor(SizedItem::TreeCacheSize) == 4194304); + BEAST_EXPECT(parse("0").getValueFor(SizedItem::TreeCacheSize) == 16384); + BEAST_EXPECT(parse("16").getValueFor(SizedItem::TxnDbCache) == 32); + BEAST_EXPECT(parse("0").getValueFor(SizedItem::TxnDbCache) == 4); + BEAST_EXPECT(parse("16").getValueFor(SizedItem::SweepInterval) == 30); + BEAST_EXPECT(parse("16").getValueFor(SizedItem::LedgerSize) == 96); + BEAST_EXPECT(parse("16").getValueFor(SizedItem::BurstSize) == 16); + BEAST_EXPECT(parse("1024").getValueFor(SizedItem::BurstSize) == 48); + + // Deprecated [node_size] tiers are aliases for budgets (by name, + // case-insensitively, or legacy 0-4 index); an explicit + // [memory_limit] wins. + auto const alias = [](std::string const& value) { + Config c; + c.loadFromString("[node_size]\n" + value + "\n"); + return c; + }; + + BEAST_EXPECT(alias("large").cacheMemoryBudget() == std::uint64_t{64} << 30); + BEAST_EXPECT(alias("large").getValueFor(SizedItem::TreeCacheSize) == 4194304); + BEAST_EXPECT(alias("HUGE").cacheMemoryBudget() == std::uint64_t{128} << 30); + BEAST_EXPECT(alias("3").cacheMemoryBudget() == std::uint64_t{64} << 30); + BEAST_EXPECT(alias("9").cacheMemoryBudget() == std::uint64_t{128} << 30); + + { + Config c; + c.loadFromString("[node_size]\nsmall\n\n[memory_limit]\n100\n"); + BEAST_EXPECT(c.cacheMemoryBudget() == std::uint64_t{100} << 30); + } + + // Policy values are fixed but individually overridable. + { + Config c; + c.loadFromString("[tree_cache_age]\n900\n\n[ledger_fetch_size]\n8\n"); + BEAST_EXPECT(c.getValueFor(SizedItem::TreeCacheAge) == 900); + BEAST_EXPECT(c.getValueFor(SizedItem::LedgerFetch) == 8); + BEAST_EXPECT(c.getValueFor(SizedItem::LedgerAge) == 180); + } + expectException([] { + Config c; + c.loadFromString("[ledger_fetch_size]\n100\n"); + }); + } + void testValidatorsFile() { @@ -1596,6 +1677,7 @@ r.ripple.com:51235 testAmendment(); testOverlay(); testNetworkID(); + testMemoryLimit(); } }; diff --git a/src/test/rpc/LedgerRequest_test.cpp b/src/test/rpc/LedgerRequest_test.cpp index 98bde4e5a5d..6f2102b6971 100644 --- a/src/test/rpc/LedgerRequest_test.cpp +++ b/src/test/rpc/LedgerRequest_test.cpp @@ -301,7 +301,6 @@ class LedgerRequest_test : public beast::unit_test::Suite using namespace std::chrono_literals; Env env{*this, envconfig([](std::unique_ptr cfg) { cfg->fees.referenceFee = 10; - cfg->nodeSize = 0; return cfg; })}; Account const gw{"gateway"}; diff --git a/src/test/rpc/ServerInfo_test.cpp b/src/test/rpc/ServerInfo_test.cpp index 52a1e6cdb0d..c6f11568fae 100644 --- a/src/test/rpc/ServerInfo_test.cpp +++ b/src/test/rpc/ServerInfo_test.cpp @@ -78,6 +78,8 @@ admin = 127.0.0.1 BEAST_EXPECT(result.isMember(jss::info)); auto const& info = result[jss::info]; BEAST_EXPECT(info.isMember(jss::build_version)); + // Admin request: reports the cache memory budget in GB. + BEAST_EXPECT(info.isMember(jss::memory_limit)); // Git info is not guaranteed to be present if (info.isMember(jss::git)) { diff --git a/src/tests/libxrpl/basics/TaggedCache.cpp b/src/tests/libxrpl/basics/TaggedCache.cpp index c8ccc415ad4..2789da8f7f4 100644 --- a/src/tests/libxrpl/basics/TaggedCache.cpp +++ b/src/tests/libxrpl/basics/TaggedCache.cpp @@ -4,6 +4,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include @@ -243,4 +244,57 @@ TEST(TaggedCacheTest, tagged_cache) } } +TEST(TaggedCacheTest, hard_cap_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; + + // A cap-enabled cache must never let the strong-cache count exceed the + // cap, enforced on the insert path alone (no sweep). The large targetSize + // and long age make the periodic sweep irrelevant here, so only + // evictForHardCap can be bounding it. + int const cap = 100; + Cache capped( + "capped", 1'000'000, 3600s, clock, journal, beast::insight::NullCollector::make(), cap); + + bool everExceeded = false; + for (Key k = 1; k <= 1000; ++k) + { + capped.insert(k, "v"); + if (capped.getCacheSize() > cap) + everExceeded = true; + } + EXPECT_FALSE(everExceeded); + EXPECT_LE(capped.getCacheSize(), cap); + EXPECT_GT(capped.getCacheSize(), 0); +} + +TEST(TaggedCacheTest, hard_cap_disabled) +{ + 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; + + // cacheHardCap = 0: growth is bounded only by the periodic sweep. + Cache uncapped( + "uncapped", 1'000'000, 3600s, clock, journal, beast::insight::NullCollector::make(), 0); + + for (Key k = 1; k <= 1000; ++k) + uncapped.insert(k, "v"); + EXPECT_EQ(uncapped.getCacheSize(), 1000); +} + } // namespace xrpl diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index 52ec9ce5449..62e04a03ed2 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -292,9 +292,8 @@ class ApplicationImp : public Application, public BasicApp auto const cores = std::thread::hardware_concurrency(); - // Use a single thread when running on under-provisioned systems - // or if we are configured to use minimal resources. - if ((cores == 1) || ((config.nodeSize == 0) && (cores == 2))) + // Use a single thread on under-provisioned systems. + if (cores <= 2) return 1; // Otherwise, prefer six threads. @@ -336,14 +335,12 @@ class ApplicationImp : public Application, public BasicApp auto count = static_cast(std::thread::hardware_concurrency()); - // Be more aggressive about the number of threads to use - // for the job queue if the server is configured as - // "large" or "huge" if there are enough cores. - if (config->nodeSize >= 4 && count >= 16) + // Scale the job queue with the available cores. + if (count >= 16) { count = 6 + std::min(count, 8); } - else if (config->nodeSize >= 3 && count >= 8) + else if (count >= 8) { count = 4 + std::min(count, 6); } @@ -863,7 +860,7 @@ class ApplicationImp : public Application, public BasicApp node_store::DummyScheduler dummyScheduler; std::unique_ptr source = node_store::Manager::instance().makeDatabase( - megabytes(config_->getValueFor(SizedItem::BurstSize, std::nullopt)), + megabytes(config_->getValueFor(SizedItem::BurstSize)), dummyScheduler, 0, config_->section(Sections::kImportNodeDatabase), diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 8f31ce1eb39..59d54964f99 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -2703,27 +2703,9 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters) if (admin) { - // Note: By default the node size is "tiny". When parsing it's an error if the final - // NODE_SIZE is over 4 so below code should be safe. - // NOLINTNEXTLINE(bugprone-switch-missing-default-case) - switch (registry_.get().getApp().config().nodeSize) - { - case 0: - info[jss::node_size] = "tiny"; - break; - case 1: - info[jss::node_size] = "small"; - break; - case 2: - info[jss::node_size] = "medium"; - break; - case 3: - info[jss::node_size] = "large"; - break; - case 4: - info[jss::node_size] = "huge"; - break; - } + // The cache memory budget in GB; 0 means enforcement is disabled. + info[jss::memory_limit] = + static_cast(registry_.get().getApp().config().cacheMemoryBudget() >> 30); auto when = registry_.get().getValidators().expires(); diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index e41837d2065..5c5fc2877b7 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -120,7 +120,7 @@ SHAMapStoreImp::SHAMapStoreImp( Keys::kCacheMb, std::to_string(config.getValueFor(SizedItem::HashNodeDbCache))); } - if (!section.exists(Keys::kFilterBits) && (config.nodeSize >= 2)) + if (!section.exists(Keys::kFilterBits) && config.cacheMemoryBudget() != 0) section.set(Keys::kFilterBits, "10"); } @@ -170,20 +170,13 @@ SHAMapStoreImp::makeNodeStore(int readThreads) { auto nscfg = app_.config().section(Sections::kNodeDatabase); - // Provide default values. + // Documented defaults: 16384 records, 5 minutes (DatabaseNodeImp reads + // cache_age in minutes). if (!nscfg.exists(Keys::kCacheSize)) - { - nscfg.set( - Keys::kCacheSize, - std::to_string(app_.config().getValueFor(SizedItem::TreeCacheSize, std::nullopt))); - } + nscfg.set(Keys::kCacheSize, "16384"); if (!nscfg.exists(Keys::kCacheAge)) - { - nscfg.set( - Keys::kCacheAge, - std::to_string(app_.config().getValueFor(SizedItem::TreeCacheAge, std::nullopt))); - } + nscfg.set(Keys::kCacheAge, "5"); std::unique_ptr db; @@ -215,7 +208,7 @@ SHAMapStoreImp::makeNodeStore(int readThreads) else { db = node_store::Manager::instance().makeDatabase( - megabytes(app_.config().getValueFor(SizedItem::BurstSize, std::nullopt)), + megabytes(app_.config().getValueFor(SizedItem::BurstSize)), scheduler_, readThreads, nscfg, @@ -537,7 +530,7 @@ SHAMapStoreImp::makeBackendRotating(std::string path) auto backend{node_store::Manager::instance().makeBackend( section, - megabytes(app_.config().getValueFor(SizedItem::BurstSize, std::nullopt)), + megabytes(app_.config().getValueFor(SizedItem::BurstSize)), scheduler_, app_.getJournal(kNodeStoreName))}; backend->open(); diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index d43a7a566db..ba5b642d48f 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -38,9 +38,7 @@ enum class SizedItem : std::size_t { HashNodeDbCache, TxnDbCache, LgrDbCache, - OpenFinalLimit, BurstSize, - RamSizeGb, AccountIdCacheSize, }; @@ -138,7 +136,8 @@ class Config : public BasicConfig */ bool signingEnabled_ = false; - // The amount of RAM, in bytes, that we detected on this system. + // The amount of RAM, in GiB, that we detected on this system. + // 0 when detection failed. std::uint64_t const ramSize_; public: @@ -209,10 +208,10 @@ class Config : public BasicConfig std::uint32_t ledgerHistory = 256; std::uint32_t fetchDepth = 1000000000; - // Tunable that adjusts various parameters, typically associated - // with hardware parameters (RAM size and CPU cores). The default - // is 'tiny'. - std::size_t nodeSize = 0; + // Cache memory budget in bytes, from [memory_limit] (gigabytes). Unset + // defaults to detected physical RAM; 0 disables enforcement. The + // deprecated [node_size] tiers map onto this budget. + std::optional memoryLimit; bool sslVerify = true; std::string sslVerifyFile; @@ -244,6 +243,11 @@ class Config : public BasicConfig // size, but we allow admins to explicitly set it in the config. std::optional sweepInterval; + // Optional overrides for the fixed cache policy values. + std::optional treeCacheAge; // [tree_cache_age], seconds + std::optional ledgerCacheAge; // [ledger_cache_age], seconds + std::optional ledgerFetchSize; // [ledger_fetch_size], ledgers per fetch pass + // Reduce-relay - Experimental parameters to control p2p routing algorithms // Enable base squelching of duplicate validation/proposal messages @@ -352,25 +356,22 @@ class Config : public BasicConfig } /** - * Retrieve the default value for the item at the specified node size - * - * @param item The item for which the default value is needed - * @param node Optional value, used to adjust the result to match the - * size of a node (0: tiny, ..., 4: huge). If unseated, - * uses the configured size (NODE_SIZE). - * - * @throws This method can throw std::out_of_range if you ask for values - * that it does not recognize or request a non-default node-size. + * Retrieve the value for the item, derived from the memory budget. * + * @param item The item for which the value is needed * @return The value for the requested item. - * - * @note The defaults are selected so as to be reasonable, but the node - * size is an imprecise metric that combines multiple aspects of - * the underlying system; this means that we can't provide optimal - * defaults in the code for every case. */ [[nodiscard]] int - getValueFor(SizedItem item, std::optional node = std::nullopt) const; + getValueFor(SizedItem item) const; + + /** + * The effective cache memory budget in bytes. + * + * [memory_limit] if set, otherwise detected physical RAM. 0 means + * enforcement is disabled (explicit 0, or RAM detection failed). + */ + [[nodiscard]] std::uint64_t + cacheMemoryBudget() const; [[nodiscard]] beast::Journal journal() const diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index 3b7b57328b1..5edd5570b62 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -37,7 +37,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,7 @@ #include #include #include -#include +#include #include #include #include @@ -71,15 +70,144 @@ getMemorySize() #if BOOST_OS_LINUX #include // IWYU pragma: keep +#include + +#include + namespace xrpl::detail { +// This process's cgroup path from /proc/self/cgroup: the v2 line is +// "0::"; a v1 line is "::". Empty when absent. +[[nodiscard]] std::string +getOwnCgroupPath(std::string_view controller) +{ + std::ifstream in("/proc/self/cgroup"); + std::string line; + + while (std::getline(in, line)) + { + auto const first = line.find(':'); + auto const second = line.find(':', first + 1); + if (first == std::string::npos || second == std::string::npos) + continue; + + std::string_view const controllers(line.data() + first + 1, second - first - 1); + if (controller.empty()) + { + // The v2 entry is exactly "0::". + if (first == 1 && line[0] == '0' && controllers.empty()) + return line.substr(second + 1); + } + else if (controllers.contains(controller)) + { + return line.substr(second + 1); + } + } + + return {}; +} + +// The value in a cgroup limit file; 0 when absent or unlimited. "max" (v2) +// fails the read, and the page-counter maximum (v1) both mean unlimited. +[[nodiscard]] std::uint64_t +readCgroupLimit(std::string const& path) +{ + std::ifstream in(path); + std::uint64_t limit = 0; + + if (in >> limit && limit < (std::uint64_t{1} << 62)) + return limit; + + return 0; +} + +// Whether the cgroup directory contains this process. /proc/self/cgroup +// paths are namespace-relative, so a resolved directory can name-collide +// with a different cgroup when the cgroup mount shows another view; only +// trust a directory this process is actually in. +[[nodiscard]] bool +cgroupContainsSelf(std::string const& dir) +{ + std::ifstream in(dir + "/cgroup.procs"); + pid_t const self = ::getpid(); + pid_t pid = 0; + + while (in >> pid) + { + if (pid == self) + return true; + } + + return false; +} + +// The smallest numeric limit in `file` from the leaf cgroup up through its +// ancestors (the effective limit is the minimum over the hierarchy); 0 when +// none is set or the leaf does not belong to this process. +[[nodiscard]] std::uint64_t +minCgroupLimit(std::string const& mount, std::string path, char const* file) +{ + if (!cgroupContainsSelf(mount + path)) + return 0; + + std::uint64_t best = 0; + auto const consider = [&best](std::uint64_t limit) { + if (limit != 0 && (best == 0 || limit < best)) + best = limit; + }; + + while (!path.empty() && path != "/") + { + consider(readCgroupLimit(mount + path + "/" + file)); + + auto const slash = path.find_last_of('/'); + if (slash == std::string::npos) + break; + path.resize(slash); + } + consider(readCgroupLimit(mount + "/" + file)); + + return best; +} + +// The cgroup (v2, then v1) memory limit in bytes; 0 when absent or +// unlimited. Checks this process's own cgroup and its ancestors (covering +// nested limits such as systemd MemoryMax=) before the root-level files +// containers expose. +[[nodiscard]] std::uint64_t +getCgroupMemoryLimit() +{ + if (auto const path = getOwnCgroupPath(""); !path.empty() && path != "/") + { + if (auto const limit = minCgroupLimit("/sys/fs/cgroup", path, "memory.max")) + return limit; + } + + if (auto const limit = readCgroupLimit("/sys/fs/cgroup/memory.max")) + return limit; + + if (auto const path = getOwnCgroupPath("memory"); !path.empty() && path != "/") + { + if (auto const limit = + minCgroupLimit("/sys/fs/cgroup/memory", path, "memory.limit_in_bytes")) + return limit; + } + + return readCgroupLimit("/sys/fs/cgroup/memory/memory.limit_in_bytes"); +} + [[nodiscard]] std::uint64_t getMemorySize() { + std::uint64_t ram = 0; + if (struct sysinfo si{}; sysinfo(&si) == 0) - return static_cast(si.totalram) * si.mem_unit; + ram = static_cast(si.totalram) * si.mem_unit; - return 0; + if (auto const limit = getCgroupMemoryLimit(); limit != 0 && (ram == 0 || limit < ram)) + return limit; + + return ram; } } // namespace xrpl::detail @@ -110,50 +238,6 @@ getMemorySize() namespace xrpl { -// clang-format off -// The configurable node sizes are "tiny", "small", "medium", "large", "huge" -inline constexpr std::array>, 13> -kSizedItems -{{ - // FIXME: We should document each of these items, explaining exactly - // what they control and whether there exists an explicit - // config option that can be used to override the default. - - // tiny small medium large huge - {SizedItem::SweepInterval, {{ 10, 30, 60, 90, 120 }}}, - {SizedItem::TreeCacheSize, {{ 262144, 524288, 2097152, 4194304, 8388608 }}}, - {SizedItem::TreeCacheAge, {{ 30, 60, 90, 120, 900 }}}, - {SizedItem::LedgerSize, {{ 32, 32, 64, 256, 384 }}}, - {SizedItem::LedgerAge, {{ 30, 60, 180, 300, 600 }}}, - {SizedItem::LedgerFetch, {{ 2, 3, 4, 5, 8 }}}, - {SizedItem::HashNodeDbCache, {{ 4, 12, 24, 64, 128 }}}, - {SizedItem::TxnDbCache, {{ 4, 12, 24, 64, 128 }}}, - {SizedItem::LgrDbCache, {{ 4, 8, 16, 32, 128 }}}, - {SizedItem::OpenFinalLimit, {{ 8, 16, 32, 64, 128 }}}, - {SizedItem::BurstSize, {{ 4, 8, 16, 32, 48 }}}, - {SizedItem::RamSizeGb, {{ 6, 8, 12, 24, 0 }}}, - {SizedItem::AccountIdCacheSize, {{ 20047, 50053, 77081, 150061, 300007 }}} -}}; -// clang-format on - -// Ensure that the order of entries in the table corresponds to the -// order of entries in the enum: -static_assert( - []() constexpr -> bool { - std::underlying_type_t idx = 0; - - for (auto const& i : kSizedItems) - { - if (static_cast>(i.first) != idx) - return false; - - ++idx; - } - - return true; - }(), - "Mismatch between sized item enum & array indices"); - // // TODO: Check permissions on config file before using it. // @@ -271,36 +355,9 @@ Config::Config() void Config::setupControl(bool bQuiet, bool bSilent, bool bStandalone) { - XRPL_ASSERT(nodeSize == 0, "xrpl::Config::setupControl : node size not set"); - quiet_ = bQuiet || bSilent; silent_ = bSilent; runStandalone_ = bStandalone; - - // We try to autodetect the appropriate node size by checking available - // RAM and CPU resources. We default to "tiny" for standalone mode. - if (!bStandalone) - { - // First, check against 'minimum' RAM requirements per node size: - auto const& threshold = - kSizedItems[std::underlying_type_t(SizedItem::RamSizeGb)]; - - auto ns = std::ranges::find_if(threshold.second, [this](std::size_t limit) { - return (limit == 0) || (ramSize_ < limit); - }); - - XRPL_ASSERT(ns != threshold.second.end(), "xrpl::Config::setupControl : valid node size"); - - if (ns != threshold.second.end()) - nodeSize = std::distance(threshold.second.begin(), ns); - - // Adjust the size based on the number of hardware threads of - // execution available to us: - if (auto const hc = std::thread::hardware_concurrency(); hc != 0) - nodeSize = std::min(hc / 2, nodeSize); - } - - XRPL_ASSERT(nodeSize <= 4, "xrpl::Config::setupControl : node size is set"); } void @@ -583,34 +640,55 @@ Config::loadFromString(std::string const& fileContents) } } - if (getSingleSection(secConfig, Sections::kNodeSize, strTemp, j_)) + if (getSingleSection(secConfig, Sections::kMemoryLimit, strTemp, j_)) { - if (boost::iequals(strTemp, "tiny")) - { - nodeSize = 0; - } - else if (boost::iequals(strTemp, "small")) - { - nodeSize = 1; - } - else if (boost::iequals(strTemp, "medium")) - { - nodeSize = 2; - } - else if (boost::iequals(strTemp, "large")) - { - nodeSize = 3; - } - else if (boost::iequals(strTemp, "huge")) + // Gigabytes; 0 disables enforcement. + auto const gb = beast::lexicalCastThrow(strTemp); + if (gb > 1024) { - nodeSize = 4; + Throw( + "Invalid value '" + strTemp + "' for key '" + Sections::kMemoryLimit + + "'; the limit is in gigabytes and may not exceed 1024"); } - else + memoryLimit = gb << 30; + } + + if (getSingleSection(secConfig, Sections::kNodeSize, strTemp, j_)) + { + // Deprecated: each tier (by name or its legacy 0-4 index) is an + // alias for a memory budget. [memory_limit], when present, wins. + static constexpr std::array, 5> kTiers{ + {{"tiny", 4}, {"small", 8}, {"medium", 32}, {"large", 64}, {"huge", 128}}}; + + auto const tier = std::ranges::find_if( + kTiers, [&strTemp](auto const& t) { return boost::iequals(strTemp, t.first); }); + + std::uint64_t const budgetGb = tier != kTiers.end() + ? tier->second + : kTiers[std::min(4, beast::lexicalCastThrow(strTemp))] + .second; + + if (!memoryLimit) + memoryLimit = budgetGb << 30; + + if (!quiet_) { - nodeSize = std::min(4, beast::lexicalCastThrow(strTemp)); + std::cerr << "WARNING: [node_size] is deprecated and will be removed " + "in a future release. Set [memory_limit] instead; thread " + "counts derive from the core count and [workers] / " + "[io_workers].\n"; } } + // A budget beyond physical memory cannot be honored and recreates the + // oversized-preset OOM this setting exists to prevent. + if (memoryLimit && ramSize_ != 0 && *memoryLimit > (ramSize_ << 30) && !quiet_) + { + std::cerr << "WARNING: the configured memory budget (" << (*memoryLimit >> 30) + << " GB) exceeds detected RAM (" << ramSize_ << " GB); set [memory_limit] to " + << ramSize_ << " or less.\n"; + } + if (getSingleSection(secConfig, Sections::kSigningSupport, strTemp, j_)) signingEnabled_ = beast::lexicalCastThrow(strTemp); @@ -747,6 +825,42 @@ Config::loadFromString(std::string const& fileContents) } } + if (getSingleSection(secConfig, Sections::kTreeCacheAge, strTemp, j_)) + { + treeCacheAge = beast::lexicalCastThrow(strTemp); + + if (*treeCacheAge < 10 || *treeCacheAge > 3600) + { + Throw( + std::string("Invalid ") + Sections::kTreeCacheAge + + ": must be between 10 and 3600 inclusive"); + } + } + + if (getSingleSection(secConfig, Sections::kLedgerCacheAge, strTemp, j_)) + { + ledgerCacheAge = beast::lexicalCastThrow(strTemp); + + if (*ledgerCacheAge < 10 || *ledgerCacheAge > 3600) + { + Throw( + std::string("Invalid ") + Sections::kLedgerCacheAge + + ": must be between 10 and 3600 inclusive"); + } + } + + if (getSingleSection(secConfig, Sections::kLedgerFetchSize, strTemp, j_)) + { + ledgerFetchSize = beast::lexicalCastThrow(strTemp); + + if (*ledgerFetchSize < 1 || *ledgerFetchSize > 16) + { + Throw( + std::string("Invalid ") + Sections::kLedgerFetchSize + + ": must be between 1 and 16 inclusive"); + } + } + if (getSingleSection(secConfig, Sections::kWorkers, strTemp, j_)) { workers = beast::lexicalCastThrow(strTemp); @@ -1195,12 +1309,57 @@ Config::getDebugLogFile() const } int -Config::getValueFor(SizedItem item, std::optional node) const +Config::getValueFor(SizedItem item) const +{ + // Memory-shaped items scale linearly with the budget between a floor and + // a ceiling; time and policy items are fixed. A budget of 0 (enforcement + // disabled) yields the floors. The 1024 bound keeps gb * 65536 within + // int range (the config parser enforces it too). + auto const gb = static_cast(std::min(cacheMemoryBudget() >> 30, 1024)); + + switch (item) + { + case SizedItem::SweepInterval: + return 30; + case SizedItem::TreeCacheSize: + // Half the budget at an estimated 8 KiB per entry (the node plus + // its weak-tracking entry, hash buckets, and control block): + // 1 GiB / 2 / 8 KiB = 65536 entries per budget GB. + return std::max(16384, gb * 65536); + case SizedItem::TreeCacheAge: + return treeCacheAge.value_or(300); + case SizedItem::LedgerSize: + return std::clamp(gb * 6, 32, 384); + case SizedItem::LedgerAge: + return ledgerCacheAge.value_or(180); + case SizedItem::LedgerFetch: + return ledgerFetchSize.value_or(4); + case SizedItem::HashNodeDbCache: + case SizedItem::TxnDbCache: + case SizedItem::LgrDbCache: + // HashNodeDbCache is consumed in MB (RocksDB cache_mb); the two + // SQLite page caches are consumed in KB. + return std::clamp(gb * 2, 4, 128); + case SizedItem::BurstSize: + return std::clamp(gb, 4, 48); + case SizedItem::AccountIdCacheSize: + // Fixed regardless of budget: ~22 MB at 72 bytes per slot, and + // the value stays prime for hash distribution. + return 300007; + } + + UNREACHABLE("xrpl::Config::getValueFor : invalid item"); + return 0; +} + +std::uint64_t +Config::cacheMemoryBudget() const { - auto const index = static_cast>(item); - XRPL_ASSERT(index < kSizedItems.size(), "xrpl::Config::getValueFor : valid index input"); - XRPL_ASSERT(!node || *node <= 4, "xrpl::Config::getValueFor : unset or valid node"); - return kSizedItems.at(index).second.at(node.value_or(nodeSize)); + if (memoryLimit) + return *memoryLimit; + + // ramSize_ is in GiB; 0 when detection failed, which disables enforcement. + return ramSize_ << 30; } FeeSetup diff --git a/src/xrpld/shamap/NodeFamily.cpp b/src/xrpld/shamap/NodeFamily.cpp index 2e48117d6a9..1e89c65db2c 100644 --- a/src/xrpld/shamap/NodeFamily.cpp +++ b/src/xrpld/shamap/NodeFamily.cpp @@ -39,8 +39,17 @@ NodeFamily::NodeFamily(Application& app, CollectorManager& cm) app.config().getValueFor(SizedItem::TreeCacheSize), std::chrono::seconds(app.config().getValueFor(SizedItem::TreeCacheAge)), stopwatch(), - j_)) + j_, + beast::insight::NullCollector::make(), + // Hard cap: the clamped target, enforced on insert; 0 = off. + app.config().cacheMemoryBudget() != 0 + ? app.config().getValueFor(SizedItem::TreeCacheSize) + : 0)) { + auto const budget = app.config().cacheMemoryBudget(); + JLOG(j_.info()) << "TreeNodeCache sizing: target=" + << app.config().getValueFor(SizedItem::TreeCacheSize) << " entries, budget " + << (budget >> 30) << " GB" << (budget == 0 ? " (enforcement disabled)" : ""); } void