mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 22:30:57 +00:00
develop moved TempDir from beast:: to xrpl::, deleting include/xrpl/beast/utility/temp_dir.h in favour of include/xrpl/basics/FileUtilities.h. The merge kept this branch's references to the old API, so the tree no longer compiled: the missing header is a fatal include, and because DatabaseConfig_test.cpp lands in the xrpld unity blob it broke the xrpld target itself, not just the tests. Swap the include and drop the stale beast:: qualifier on 17 uses. The three src/tests/libxrpl/nodestore files already include FileUtilities.h and already spell TempDir unqualified elsewhere, so only the qualifier was wrong there. DatabaseConfig_test.cpp needed the include as well; it sits in namespace xrpl::node_store, so unqualified TempDir resolves to xrpl::TempDir through the enclosing namespace. Two further uses exist only on the sync-diagnostics tip and are fixed there rather than here.
473 lines
16 KiB
C++
473 lines
16 KiB
C++
#include <xrpl/nodestore/Database.h>
|
|
|
|
#include <xrpl/basics/ByteUtilities.h>
|
|
#include <xrpl/basics/FileUtilities.h>
|
|
#include <xrpl/beast/utility/Journal.h>
|
|
#include <xrpl/beast/xor_shift_engine.h>
|
|
#include <xrpl/config/BasicConfig.h>
|
|
#include <xrpl/nodestore/DummyScheduler.h>
|
|
#include <xrpl/nodestore/Manager.h>
|
|
#include <xrpl/nodestore/Scheduler.h>
|
|
#include <xrpl/nodestore/Task.h>
|
|
#include <xrpl/nodestore/Types.h>
|
|
#include <xrpl/nodestore/WriteStats.h>
|
|
#include <xrpl/protocol/SystemParameters.h>
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <helpers/TestSink.h>
|
|
#include <nodestore/TestBase.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace xrpl::node_store {
|
|
|
|
namespace {
|
|
|
|
std::vector<std::string>
|
|
allBackends()
|
|
{
|
|
#if XRPL_ROCKSDB_AVAILABLE
|
|
return {"memory", "nudb", "rocksdb"};
|
|
#else
|
|
return {"memory", "nudb"};
|
|
#endif
|
|
}
|
|
|
|
std::vector<std::string>
|
|
persistentBackends()
|
|
{
|
|
std::vector<std::string> types{"nudb"};
|
|
#if XRPL_ROCKSDB_AVAILABLE
|
|
types.emplace_back("rocksdb");
|
|
#endif
|
|
return types;
|
|
}
|
|
|
|
std::vector<std::string>
|
|
importBackends()
|
|
{
|
|
std::vector<std::string> types{"nudb"};
|
|
#if XRPL_ROCKSDB_AVAILABLE
|
|
types.emplace_back("rocksdb");
|
|
#endif
|
|
#ifdef XRPL_ENABLE_SQLITE_BACKEND_TESTS
|
|
types.emplace_back("sqlite");
|
|
#endif
|
|
return types;
|
|
}
|
|
|
|
/**
|
|
* A scheduler that records what the nodestore reports about each operation.
|
|
*
|
|
* The production scheduler forwards these reports to the job queue and to
|
|
* telemetry, so capturing them here lets a test observe exactly the values a
|
|
* dashboard would receive.
|
|
*
|
|
* Database::fetchNodeObject()
|
|
* |
|
|
* +-- FetchReport{elapsed, wasFound} --> onFetch()
|
|
*
|
|
* Database::store() -> backend
|
|
* |
|
|
* +-- BatchWriteReport{elapsed, writeCount} --> onBatchWrite()
|
|
*
|
|
* @note Counters are atomic because Scheduler is called from the nodestore's
|
|
* read threads in production. The tests below fetch synchronously on
|
|
* one thread, so the totals are still exact.
|
|
*/
|
|
struct CapturingScheduler : Scheduler
|
|
{
|
|
std::atomic<std::uint64_t> totalReportedUs{0}; ///< Sum of reported fetch durations.
|
|
std::atomic<std::uint64_t> fetchCount{0}; ///< Fetch reports received.
|
|
std::atomic<std::uint64_t> foundCount{0}; ///< Fetch reports with wasFound set.
|
|
std::atomic<std::uint64_t> batchWriteCount{0}; ///< Batch-write reports received.
|
|
|
|
/**
|
|
* Fetch reports whose duration is not a whole number of milliseconds.
|
|
*
|
|
* A millisecond-typed duration converts to a whole multiple of 1000
|
|
* microseconds, so it can never be counted here however fast or slow the
|
|
* machine is. A non-zero count therefore proves the report carried
|
|
* sub-millisecond resolution, and it proves it without assuming anything
|
|
* about how long a read takes.
|
|
*/
|
|
std::atomic<std::uint64_t> subMillisecondCount{0};
|
|
|
|
void
|
|
scheduleTask(Task& task) override
|
|
{
|
|
task.performScheduledTask();
|
|
}
|
|
|
|
void
|
|
onFetch(FetchReport const& report) override
|
|
{
|
|
// duration_cast, not .count(), so this compiles against either unit
|
|
// and the test can be run before and after the fix.
|
|
auto const us = static_cast<std::uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::microseconds>(report.elapsed).count());
|
|
|
|
totalReportedUs += us;
|
|
++fetchCount;
|
|
if (report.wasFound)
|
|
++foundCount;
|
|
if (us % 1000 != 0)
|
|
++subMillisecondCount;
|
|
}
|
|
|
|
void
|
|
onBatchWrite(BatchWriteReport const&) override
|
|
{
|
|
++batchWriteCount;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// Shared setup for the parameterized Database tests: builds the node params,
|
|
// journal and a predictable batch per test, mirroring Backend.cpp's fixture.
|
|
class NodeStoreDatabaseTestBase : public ::testing::TestWithParam<std::string>
|
|
{
|
|
protected:
|
|
void
|
|
SetUp() override
|
|
{
|
|
nodeParams_.set("type", GetParam());
|
|
nodeParams_.set("path", nodeDb_.path());
|
|
|
|
beast::xor_shift_engine rng(kSeedValue);
|
|
batch_ = createPredictableBatch(kNumObjects, rng());
|
|
}
|
|
|
|
std::unique_ptr<Database>
|
|
makeDatabase()
|
|
{
|
|
return Manager::instance().makeDatabase(megabytes(4), scheduler_, 2, nodeParams_, journal_);
|
|
}
|
|
|
|
DummyScheduler scheduler_;
|
|
TempDir const nodeDb_;
|
|
beast::Journal const journal_{TestSink::instance()};
|
|
Section nodeParams_;
|
|
Batch batch_;
|
|
};
|
|
|
|
class NodeStoreDatabaseTest : public NodeStoreDatabaseTestBase
|
|
{
|
|
};
|
|
|
|
class NodeStoreDatabasePersistenceTest : public NodeStoreDatabaseTestBase
|
|
{
|
|
};
|
|
|
|
TEST_P(NodeStoreDatabaseTest, store_and_fetch)
|
|
{
|
|
auto db = makeDatabase();
|
|
|
|
storeBatch(*db, batch_);
|
|
|
|
{
|
|
SCOPED_TRACE("read in original order");
|
|
auto const copy = fetchCopyOfBatch(*db, batch_);
|
|
EXPECT_EQ(batch_, copy);
|
|
}
|
|
|
|
{
|
|
SCOPED_TRACE("read in shuffled order");
|
|
beast::xor_shift_engine rng(kSeedValue);
|
|
std::shuffle(batch_.begin(), batch_.end(), rng);
|
|
auto const copy = fetchCopyOfBatch(*db, batch_);
|
|
EXPECT_EQ(batch_, copy);
|
|
}
|
|
}
|
|
|
|
TEST_P(NodeStoreDatabasePersistenceTest, round_trip)
|
|
{
|
|
{
|
|
auto db = makeDatabase();
|
|
storeBatch(*db, batch_);
|
|
}
|
|
|
|
// re-open without the ephemeral db
|
|
auto db = makeDatabase();
|
|
|
|
auto copy = fetchCopyOfBatch(*db, batch_);
|
|
std::ranges::sort(batch_, LessThan{});
|
|
std::ranges::sort(copy, LessThan{});
|
|
EXPECT_EQ(batch_, copy);
|
|
}
|
|
|
|
// missing-key path at the Database layer. Mirrors Backend's fetch_missing —
|
|
// fetching keys that were never stored must return nullptr (NotFound).
|
|
TEST_P(NodeStoreDatabaseTest, fetch_missing)
|
|
{
|
|
auto db = makeDatabase();
|
|
|
|
// never store: every key must be absent
|
|
fetchMissing(*db, batch_);
|
|
}
|
|
|
|
// Database::getWriteStats() forwards to the backend, and the telemetry
|
|
// exporter branches on the optional to decide whether to publish the NuDB
|
|
// write-queue labels at all. Backend.cpp covers the backend's own answer;
|
|
// this covers the forwarding, which is what the exporter actually calls.
|
|
TEST_P(NodeStoreDatabaseTest, write_stats_forwarded_from_backend)
|
|
{
|
|
auto db = makeDatabase();
|
|
|
|
// Before any write, only a measuring backend answers at all.
|
|
auto const initial = db->getWriteStats();
|
|
ASSERT_EQ(initial.has_value(), GetParam() == "nudb")
|
|
<< "only nudb measures its write path; backend=" << GetParam();
|
|
|
|
if (!initial)
|
|
{
|
|
// Negative path: a non-measuring backend must keep reporting absence
|
|
// after real writes too, so the exporter omits the labels rather
|
|
// than publishing zeros that would read as an idle write path.
|
|
storeBatch(*db, batch_);
|
|
EXPECT_FALSE(db->getWriteStats().has_value());
|
|
return;
|
|
}
|
|
|
|
// A freshly opened store has written nothing.
|
|
EXPECT_EQ(initial->insertCount, 0u);
|
|
EXPECT_EQ(initial->insertTotalUs, 0u);
|
|
EXPECT_EQ(initial->depthSum, 0u);
|
|
EXPECT_EQ(initial->concurrentWriters, 0u);
|
|
|
|
storeBatch(*db, batch_);
|
|
|
|
// Every object stored through the Database reaches the backend exactly
|
|
// once, so the forwarded count is the batch size and not, say, the
|
|
// number of store() batches.
|
|
auto const after = db->getWriteStats();
|
|
ASSERT_TRUE(after.has_value());
|
|
EXPECT_EQ(after->insertCount, batch_.size());
|
|
// One writer thread, so the depth recorded at each insert is exactly 1.
|
|
// This catches a depth accumulator fed the wrong quantity (insertCount's
|
|
// running value, or the elapsed microseconds), but it CANNOT catch one fed
|
|
// a constant 1, because here the real depth is 1. That case needs genuine
|
|
// overlap and is covered by NuDBFactory.cpp's
|
|
// write_stats_measure_depth_under_real_overlap.
|
|
EXPECT_EQ(after->depthSum, batch_.size());
|
|
// No writer is left in flight once the calls have returned.
|
|
EXPECT_EQ(after->concurrentWriters, 0u);
|
|
// Same live depth through the other accessor on the same object, so the
|
|
// two cannot drift onto different fields.
|
|
EXPECT_EQ(db->getWriteLoad(), 0);
|
|
EXPECT_GT(after->insertTotalUs, 0u);
|
|
// max * n >= sum. Catches a field holding the running MINIMUM, since
|
|
// min * n <= sum with equality only when every sample is identical.
|
|
// Degenerates when the samples do not vary; the unconditional guarantee
|
|
// that the field is a maximum is the non-decreasing check in
|
|
// NuDBFactory.cpp's write_stats_accumulate_per_insert.
|
|
EXPECT_GE(after->insertMaxUs * after->insertCount, after->insertTotalUs);
|
|
}
|
|
|
|
// Read latency is the discriminator between a warm nodestore and a cold one:
|
|
// a warm store answers in single-digit microseconds and a cold one in low
|
|
// hundreds, while the hit rate reads the same for both. FetchReport::elapsed
|
|
// is what the production scheduler forwards, so if that field cannot hold a
|
|
// sub-millisecond value the difference is gone before anything can record it.
|
|
//
|
|
// nudb rather than memory: a real store's reads take microseconds of
|
|
// measurable work, whereas an in-memory map lookup can finish inside a single
|
|
// microsecond tick and report a legitimate zero.
|
|
TEST(NodeStoreDatabase, sub_millisecond_fetch_latency_is_reported)
|
|
{
|
|
CapturingScheduler scheduler;
|
|
TempDir const nodeDb;
|
|
Section nodeParams;
|
|
nodeParams.set("type", "nudb");
|
|
nodeParams.set("path", nodeDb.path());
|
|
|
|
beast::Journal const journal(TestSink::instance());
|
|
|
|
// No cache_size/cache_age, so DatabaseNodeImp builds no cache and every
|
|
// fetch reaches the backend. That keeps the counts exact.
|
|
auto db = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
|
|
ASSERT_NE(db, nullptr);
|
|
|
|
// Nothing has been fetched, so nothing has been reported. This also
|
|
// proves the counters below are moved by this test and not by setup.
|
|
ASSERT_EQ(scheduler.fetchCount.load(), 0u);
|
|
ASSERT_EQ(scheduler.totalReportedUs.load(), 0u);
|
|
ASSERT_EQ(db->getFetchDurationUs(), 0u);
|
|
|
|
constexpr std::size_t kNumStored = 256;
|
|
auto const stored = createPredictableBatch(kNumStored, kSeedValue);
|
|
ASSERT_EQ(stored.size(), kNumStored);
|
|
storeBatch(*db, stored);
|
|
|
|
// Each store reaches the backend once, and nudb reports every insert as a
|
|
// one-object batch write. A change to BatchWriteReport that broke its
|
|
// millisecond contract would show up here rather than silently.
|
|
EXPECT_EQ(scheduler.batchWriteCount.load(), kNumStored);
|
|
|
|
// Positive path: every object is present, so every fetch is a hit.
|
|
for (auto const& object : stored)
|
|
EXPECT_NE(db->fetchNodeObject(object->getHash(), 0), nullptr);
|
|
|
|
// Every fetch produced exactly one report, and each one saw its object.
|
|
ASSERT_EQ(scheduler.fetchCount.load(), kNumStored);
|
|
EXPECT_EQ(scheduler.foundCount.load(), kNumStored);
|
|
|
|
// The accumulator keeps nanoseconds internally, so 256 reads sum past a
|
|
// microsecond and register here even when each individual read finishes in
|
|
// well under one. This is a statement about the accumulator's resolution,
|
|
// not about this machine's speed: to still read zero, all 256 reads would
|
|
// have to complete inside a single microsecond in total.
|
|
auto const internalUs = db->getFetchDurationUs();
|
|
ASSERT_GT(internalUs, 0u);
|
|
|
|
// Each report truncates its own fetch to whole microseconds, while the
|
|
// accumulator keeps the remainder. The reported total can therefore only be
|
|
// smaller than the accumulated one, and only by the discarded remainder of
|
|
// each fetch, which is strictly under 1 us per fetch.
|
|
auto const reportedUs = scheduler.totalReportedUs.load();
|
|
EXPECT_LE(reportedUs, internalUs);
|
|
EXPECT_LE(internalUs - reportedUs, kNumStored);
|
|
|
|
// Negative path: a miss is still a fetch, so it is still reported and
|
|
// still timed, but it is not a hit. A report that only fired on hits
|
|
// would hide exactly the cold-read case this measures.
|
|
constexpr std::size_t kNumMissing = 32;
|
|
auto const missing = createPredictableBatch(kNumMissing, kSeedValue + 1);
|
|
ASSERT_EQ(missing.size(), kNumMissing);
|
|
for (auto const& object : missing)
|
|
EXPECT_EQ(db->fetchNodeObject(object->getHash(), 0), nullptr);
|
|
|
|
EXPECT_EQ(scheduler.fetchCount.load(), kNumStored + kNumMissing);
|
|
EXPECT_EQ(scheduler.foundCount.load(), kNumStored);
|
|
|
|
// The same bound still holds once misses are included, so the miss path
|
|
// reports what it measured rather than substituting a zero.
|
|
//
|
|
// GE and not GT: a cumulative total cannot shrink, but the microsecond view
|
|
// of it only advances once the accumulated nanoseconds cross the next
|
|
// thousand, so requiring growth from 32 more reads would be a statement
|
|
// about this machine's speed rather than about the code.
|
|
auto const internalUsWithMisses = db->getFetchDurationUs();
|
|
EXPECT_GE(internalUsWithMisses, internalUs);
|
|
auto const reportedUsWithMisses = scheduler.totalReportedUs.load();
|
|
EXPECT_LE(reportedUsWithMisses, internalUsWithMisses);
|
|
EXPECT_LE(internalUsWithMisses - reportedUsWithMisses, kNumStored + kNumMissing);
|
|
|
|
// Reads perform no writes, so the write-report count cannot have moved.
|
|
EXPECT_EQ(scheduler.batchWriteCount.load(), kNumStored);
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
NodeStoreBackends,
|
|
NodeStoreDatabaseTest,
|
|
::testing::ValuesIn(allBackends()),
|
|
[](::testing::TestParamInfo<std::string> const& info) { return info.param; });
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
PersistentBackends,
|
|
NodeStoreDatabasePersistenceTest,
|
|
::testing::ValuesIn(persistentBackends()),
|
|
[](::testing::TestParamInfo<std::string> const& info) { return info.param; });
|
|
|
|
TEST(NodeStoreDatabase, memory_earliest_seq)
|
|
{
|
|
DummyScheduler scheduler;
|
|
TempDir const nodeDb;
|
|
Section nodeParams;
|
|
nodeParams.set("type", "memory");
|
|
nodeParams.set("path", nodeDb.path());
|
|
|
|
beast::Journal const journal(TestSink::instance());
|
|
|
|
// default earliest ledger sequence
|
|
{
|
|
auto db = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
|
|
EXPECT_EQ(db->earliestLedgerSeq(), kXrpLedgerEarliestSeq);
|
|
}
|
|
|
|
// invalid earliest_seq value
|
|
{
|
|
nodeParams.set("earliest_seq", "0");
|
|
try
|
|
{
|
|
auto db =
|
|
Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
|
|
FAIL() << "expected runtime_error for earliest_seq=0";
|
|
}
|
|
catch (std::runtime_error const& e)
|
|
{
|
|
EXPECT_STREQ(e.what(), "Invalid earliest_seq");
|
|
}
|
|
}
|
|
|
|
// valid earliest_seq value
|
|
{
|
|
nodeParams.set("earliest_seq", "1");
|
|
auto db = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
|
|
EXPECT_EQ(db->earliestLedgerSeq(), 1u);
|
|
}
|
|
}
|
|
|
|
class DatabaseImportTest : public ::testing::TestWithParam<std::string>
|
|
{
|
|
};
|
|
|
|
TEST_P(DatabaseImportTest, same_backend)
|
|
{
|
|
auto const type = GetParam();
|
|
|
|
DummyScheduler scheduler;
|
|
beast::Journal const journal(TestSink::instance());
|
|
|
|
TempDir const srcDir;
|
|
Section srcParams;
|
|
srcParams.set("type", type);
|
|
srcParams.set("path", srcDir.path());
|
|
|
|
auto batch = createPredictableBatch(kNumObjects, kSeedValue);
|
|
|
|
// write to source db
|
|
{
|
|
auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal);
|
|
storeBatch(*src, batch);
|
|
}
|
|
|
|
Batch copy;
|
|
{
|
|
// re-open source and import into a fresh destination
|
|
auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal);
|
|
|
|
TempDir const destDir;
|
|
Section destParams;
|
|
destParams.set("type", type);
|
|
destParams.set("path", destDir.path());
|
|
|
|
auto dest =
|
|
Manager::instance().makeDatabase(megabytes(4), scheduler, 2, destParams, journal);
|
|
|
|
dest->importDatabase(*src);
|
|
copy = fetchCopyOfBatch(*dest, batch);
|
|
}
|
|
|
|
std::ranges::sort(batch, LessThan{});
|
|
std::ranges::sort(copy, LessThan{});
|
|
EXPECT_EQ(batch, copy);
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
ImportBackends,
|
|
DatabaseImportTest,
|
|
::testing::ValuesIn(importBackends()),
|
|
[](::testing::TestParamInfo<std::string> const& info) { return info.param; });
|
|
|
|
} // namespace xrpl::node_store
|