mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-22 14:50:54 +00:00
Merge branch 'pratik/otel-phase10-workload-validation' into pratik/otel-sync-diagnostics
Brings in the phase-10 revert of the nodestore read-latency histogram plus the
nudb_bytes -> stored_object_bytes rename.
Conflicts in MetricsRegistry.{h,cpp} resolved keeping both intents:
- MetricsRegistry.cpp: dropped everything that existed only to serve the
reverted nodestore_read_us histogram -- the addSubMillisecondHistogramView()
helper, its call site, the kSubMillisecondBoundaries array and the
NodeStoreMetricNames.h include. Kept every view this branch registers
(consensus round duration, sweep_malloc_trim_us, dns_resolve_latency_ms,
overlay_dial_latency_ms) and the shared addHistogramView() base helper.
Took the rename at the storage_detail observe() call site.
- MetricsRegistry.h: took phase-10's move of the four nodestore_state observe
helpers and their ObserveFn sink from private to public, while keeping this
branch's enriched Doxygen on observeNodeStoreTotals().
Also corrected the registered-view count in the 09 reference doc: neither side's
arithmetic survives the merge, since this branch adds four views phase-10 never
saw and the revert removes one. Ten views are registered now, not six or seven.
This commit is contained in:
@@ -98,22 +98,10 @@ if(telemetry)
|
||||
HINTS "${opentelemetry-cpp_PACKAGE_FOLDER_RELEASE}/lib"
|
||||
REQUIRED
|
||||
)
|
||||
# The metric side of the in-memory exporter is a SEPARATE archive
|
||||
# (libopentelemetry_exporter_in_memory_metric.a) with the same
|
||||
# no-declared-libs problem, so it needs its own find_library. The
|
||||
# nodestore read-latency histogram tests use it to read exported
|
||||
# histogram points back and assert per-bucket counts.
|
||||
find_library(
|
||||
OTEL_IN_MEMORY_METRIC_EXPORTER_LIB
|
||||
NAMES opentelemetry_exporter_in_memory_metric
|
||||
HINTS "${opentelemetry-cpp_PACKAGE_FOLDER_RELEASE}/lib"
|
||||
REQUIRED
|
||||
)
|
||||
target_link_libraries(
|
||||
xrpl_tests
|
||||
PRIVATE
|
||||
"${OTEL_IN_MEMORY_EXPORTER_LIB}"
|
||||
"${OTEL_IN_MEMORY_METRIC_EXPORTER_LIB}"
|
||||
opentelemetry-cpp::opentelemetry-cpp
|
||||
)
|
||||
# ValidationTracker lives in src/xrpld/ (not libxrpl), so we compile its
|
||||
|
||||
@@ -204,4 +204,54 @@ INSTANTIATE_TEST_SUITE_P(
|
||||
::testing::ValuesIn(backendTypes()),
|
||||
[](::testing::TestParamInfo<std::string> const& info) { return info.param; });
|
||||
|
||||
// The std::nullopt default on the base class, exercised directly on the two
|
||||
// backends that always exist in every build -- unlike rocksdb, which the
|
||||
// parameterized suite above only reaches when XRPL_ROCKSDB_AVAILABLE.
|
||||
//
|
||||
// Why absence and not zeros: the exporter skips the whole nudb_* label group
|
||||
// when getWriteStats() is empty (MetricsRegistry.cpp observeWritePathDetail
|
||||
// returns early). If the base class returned a default-constructed WriteStats
|
||||
// instead, every non-NuDB node would publish nudb_writers_in_flight=0 and
|
||||
// nudb_insert_max_us=0 -- a perfectly idle write path, on a node whose write
|
||||
// path is simply not instrumented. Each assertion below fails against that
|
||||
// change.
|
||||
TEST(BackendWriteStats, non_measuring_backends_report_absence_not_zeros)
|
||||
{
|
||||
for (auto const& type : {std::string{"memory"}, std::string{"none"}})
|
||||
{
|
||||
SCOPED_TRACE("type=" + type);
|
||||
|
||||
DummyScheduler scheduler;
|
||||
beast::Journal const journal{TestSink::instance()};
|
||||
beast::TempDir const tempDir;
|
||||
|
||||
Section params;
|
||||
params.set("type", type);
|
||||
params.set("path", tempDir.path());
|
||||
|
||||
auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
|
||||
ASSERT_TRUE(backend);
|
||||
backend->open();
|
||||
|
||||
// Absent before any write.
|
||||
EXPECT_FALSE(backend->getWriteStats().has_value());
|
||||
|
||||
// Still absent after real writes. Cause, not just state: the
|
||||
// backend has genuinely been used, so the absence is the base-class
|
||||
// default and not an unopened backend.
|
||||
beast::xor_shift_engine rng(kSeedValue);
|
||||
auto const batch = createPredictableBatch(16, rng());
|
||||
storeBatch(*backend, batch);
|
||||
EXPECT_FALSE(backend->getWriteStats().has_value());
|
||||
|
||||
// These backends queue nothing, so their own write load stays 0. The
|
||||
// pairing matters: absent stats plus a 0 load is what tells the
|
||||
// exporter "not measured", whereas present stats reading 0 would mean
|
||||
// "measured, and idle".
|
||||
EXPECT_EQ(backend->getWriteLoad(), 0);
|
||||
|
||||
backend->close();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace xrpl::node_store
|
||||
|
||||
@@ -253,12 +253,23 @@ TEST_P(NodeStoreDatabaseTest, write_stats_forwarded_from_backend)
|
||||
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);
|
||||
// A maximum is never below the mean, which fails if the field held the
|
||||
// minimum or the first sample instead of a running maximum.
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <xrpl/config/BasicConfig.h>
|
||||
#include <xrpl/nodestore/DummyScheduler.h>
|
||||
#include <xrpl/nodestore/Manager.h>
|
||||
#include <xrpl/nodestore/WriteStats.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/CaptureSink.h>
|
||||
@@ -14,7 +15,9 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <latch>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
@@ -56,6 +59,48 @@ runRoundTrip(Section const& params, std::size_t expectedBlocksize)
|
||||
EXPECT_EQ(batch, copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threads used by the overlapping-insert round below.
|
||||
*/
|
||||
constexpr std::uint64_t kOverlapThreads = 8;
|
||||
|
||||
/**
|
||||
* Inserts each of those threads performs per round.
|
||||
*/
|
||||
constexpr std::uint64_t kOverlapPerThread = 50;
|
||||
|
||||
/**
|
||||
* Run one round of deliberately overlapping inserts against @p backend.
|
||||
*
|
||||
* All kOverlapThreads threads are released from a single latch, so they reach
|
||||
* doInsert() together rather than one after another; staggered starts are what
|
||||
* would let every insert run end to end and never overlap.
|
||||
*
|
||||
* @param backend Backend to insert into. Must be open.
|
||||
* @param round Round index, mixed into the seeds so every round writes
|
||||
* fresh keys and no insert takes the duplicate short-circuit.
|
||||
*/
|
||||
void
|
||||
runOverlappingInsertRound(Backend& backend, int round)
|
||||
{
|
||||
std::latch start(static_cast<std::ptrdiff_t>(kOverlapThreads));
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(kOverlapThreads);
|
||||
for (auto t = 0uz; t < kOverlapThreads; ++t)
|
||||
{
|
||||
threads.emplace_back([&backend, &start, t, round] {
|
||||
auto const batch = createPredictableBatch(
|
||||
kOverlapPerThread, 1000 + t + (static_cast<std::uint64_t>(round) * 100'000));
|
||||
start.arrive_and_wait();
|
||||
for (auto const& obj : batch)
|
||||
backend.store(obj);
|
||||
});
|
||||
}
|
||||
for (auto& th : threads)
|
||||
th.join();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(NuDBFactory, default_block_size)
|
||||
@@ -290,6 +335,14 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
|
||||
// Exactly 10 inserts must be counted as 10, and depthSum must be 10
|
||||
// because a single-threaded caller is always the only writer, so the
|
||||
// depth recorded at each insert is exactly 1.
|
||||
//
|
||||
// What this pins and what it cannot: on one thread depthSum == insertCount
|
||||
// catches an accumulator fed the wrong quantity -- fed insertCount it
|
||||
// would read 1+2+...+n, and fed the elapsed time it would read the
|
||||
// microseconds. It does NOT catch depthSum being fed a constant 1, which
|
||||
// is indistinguishable here because the real depth IS 1. That bug is the
|
||||
// one that would silently zero every derived wait time, and it is caught
|
||||
// by write_stats_measure_depth_under_real_overlap below.
|
||||
constexpr std::uint64_t kFirstBatch = 10;
|
||||
auto const batch = createPredictableBatch(kFirstBatch, 12345);
|
||||
storeBatch(*backend, batch);
|
||||
@@ -301,11 +354,12 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
|
||||
EXPECT_EQ(after->depthSum, kFirstBatch);
|
||||
EXPECT_GT(after->insertTotalUs, 0u);
|
||||
EXPECT_GT(after->insertMaxUs, 0u);
|
||||
// The largest single insert cannot exceed the sum of all of them.
|
||||
EXPECT_LE(after->insertMaxUs, after->insertTotalUs);
|
||||
// A maximum is never below the mean. This fails if the field were
|
||||
// holding the minimum, or the first or last sample, instead of the
|
||||
// running maximum.
|
||||
// A maximum is never below the mean, so max * n >= sum. Catches a field
|
||||
// fed the running MINIMUM: min * n <= sum, with equality only when every
|
||||
// sample is identical, so any variation at all makes the two orderings
|
||||
// exclusive. It does not discriminate when the samples happen not to
|
||||
// vary; the running-maximum property is pinned unconditionally by the
|
||||
// non-decreasing check after the second batch below.
|
||||
EXPECT_GE(after->insertMaxUs * after->insertCount, after->insertTotalUs);
|
||||
// No writer remains in flight once the calls have returned.
|
||||
EXPECT_EQ(after->concurrentWriters, 0u);
|
||||
@@ -334,6 +388,22 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
|
||||
// Negative path: NuDB reports key_exists for a duplicate key and doInsert
|
||||
// deliberately does not treat that as an error. The accounting must still
|
||||
// run, and in particular the depth must come back down.
|
||||
//
|
||||
// What this does NOT cover, stated plainly because a comment claiming absent
|
||||
// coverage is worse than none: this is not the throwing path. nudb::insert()
|
||||
// sets error::key_exists and RETURNS (nudb/impl/basic_store.ipp:294, :307,
|
||||
// :329), and doInsert() filters exactly that code out before it would throw
|
||||
// (NuDBFactory.cpp:283), so a duplicate key takes the identical non-throwing
|
||||
// control flow as a fresh key. It reaches the ScopeExit guard by the same
|
||||
// route the happy path does.
|
||||
//
|
||||
// The throwing path -- where the guard is the only reason the depth comes
|
||||
// back down -- is not reachable from a unit test: it needs nudb::insert() to
|
||||
// fail with something other than key_exists (an I/O or allocation failure
|
||||
// inside the library), which cannot be induced through the Backend interface
|
||||
// without a fault-injection seam that does not exist. Its RAII contract is
|
||||
// covered generically instead: src/tests/libxrpl/basics/scope.cpp:34-45 proves
|
||||
// ScopeExit runs its function during unwinding.
|
||||
TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
|
||||
{
|
||||
beast::TempDir const tempDir;
|
||||
@@ -353,6 +423,7 @@ TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
|
||||
if (!first.has_value())
|
||||
FAIL() << "nudb must report write stats";
|
||||
ASSERT_EQ(first->insertCount, kBatchSize);
|
||||
ASSERT_EQ(first->depthSum, kBatchSize);
|
||||
|
||||
// Re-storing the identical batch writes nothing new, but each call is
|
||||
// still an insert attempt that entered and left the backend.
|
||||
@@ -361,16 +432,40 @@ TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
|
||||
auto const second = backend->getWriteStats();
|
||||
if (!second.has_value())
|
||||
FAIL() << "nudb must report write stats after re-storing";
|
||||
EXPECT_EQ(second->insertCount, kBatchSize * 2);
|
||||
EXPECT_EQ(second->depthSum, kBatchSize * 2);
|
||||
// The depth returned to zero, so the early-return error path did not
|
||||
// The duplicate round is counted, so a key_exists early return is not
|
||||
// skipping the accounting. Written as the first snapshot plus the batch
|
||||
// size rather than as one product, because the two sides must differ by
|
||||
// exactly the second round: an implementation that counted only the
|
||||
// rounds that stored new data would leave these equal.
|
||||
EXPECT_EQ(second->insertCount, first->insertCount + kBatchSize);
|
||||
EXPECT_EQ(second->depthSum, first->depthSum + kBatchSize);
|
||||
// The depth returned to zero, so the key_exists early return did not
|
||||
// leak a writer.
|
||||
EXPECT_EQ(second->concurrentWriters, 0u);
|
||||
EXPECT_EQ(backend->getWriteLoad(), 0);
|
||||
|
||||
backend->close();
|
||||
}
|
||||
|
||||
TEST(NuDBFactory, write_stats_observe_concurrent_writers)
|
||||
// depthSum is the L in Little's Law: mean depth L and mean insert time W give
|
||||
// service time S = W / L, and the queuing time the whole diagnosis rests on is
|
||||
// W - S. If depthSum were fed a constant 1 instead of the observed depth then
|
||||
// L would read exactly 1.0, S would equal W, and every derived wait would read
|
||||
// 0 -- a stalled write path indistinguishable from a healthy one, with nothing
|
||||
// on any dashboard looking wrong.
|
||||
//
|
||||
// A single-threaded test cannot see that bug, because there the real depth IS
|
||||
// 1. This test forces genuine overlap so the correct implementation records a
|
||||
// depth above 1 and the constant-1 implementation cannot.
|
||||
//
|
||||
// Why the overlap is reachable and not merely hoped for: NuDB takes one global
|
||||
// mutex for the entire insert, and doInsert() reads the depth BEFORE entering
|
||||
// it. So while one thread is inside an insert, every other thread that reaches
|
||||
// doInsert() records a depth of at least 2 and then blocks. All threads are
|
||||
// released from one latch, and the round is retried until the overlap is
|
||||
// observed -- so a constant-1 implementation exhausts every round and fails,
|
||||
// while the real one satisfies it as soon as any two inserts overlap.
|
||||
TEST(NuDBFactory, write_stats_measure_depth_under_real_overlap)
|
||||
{
|
||||
beast::TempDir const tempDir;
|
||||
auto const params = makeSection(tempDir.path());
|
||||
@@ -381,36 +476,57 @@ TEST(NuDBFactory, write_stats_observe_concurrent_writers)
|
||||
ASSERT_TRUE(backend);
|
||||
backend->open();
|
||||
|
||||
// Four threads insert distinct objects concurrently. The exact peak
|
||||
// depth is racy, but two invariants are not: every insert is counted,
|
||||
// and depthSum is at least insertCount because depth is >= 1 per
|
||||
// insert.
|
||||
constexpr std::uint64_t kThreads = 4;
|
||||
constexpr std::uint64_t kPerThread = 50;
|
||||
// Bounded so a genuine regression fails instead of hanging. Each round
|
||||
// runs kOverlapThreads * kOverlapPerThread inserts through one global
|
||||
// mutex, so one round already gives the correct implementation many
|
||||
// chances to overlap.
|
||||
constexpr int kMaxRounds = 20;
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(kThreads);
|
||||
for (auto t = 0uz; t < kThreads; ++t)
|
||||
std::uint64_t completedRounds = 0;
|
||||
std::optional<WriteStats> stats;
|
||||
|
||||
for (auto round = 0; round < kMaxRounds; ++round)
|
||||
{
|
||||
threads.emplace_back([&backend, t] {
|
||||
auto const batch = createPredictableBatch(kPerThread, 1000 + t);
|
||||
for (auto const& obj : batch)
|
||||
backend->store(obj);
|
||||
});
|
||||
}
|
||||
for (auto& th : threads)
|
||||
th.join();
|
||||
runOverlappingInsertRound(*backend, round);
|
||||
++completedRounds;
|
||||
|
||||
stats = backend->getWriteStats();
|
||||
if (!stats.has_value())
|
||||
FAIL() << "nudb must report write stats after concurrent inserts";
|
||||
|
||||
if (stats->depthSum > stats->insertCount)
|
||||
break;
|
||||
}
|
||||
|
||||
auto const stats = backend->getWriteStats();
|
||||
if (!stats.has_value())
|
||||
FAIL() << "nudb must report write stats after concurrent inserts";
|
||||
EXPECT_EQ(stats->insertCount, kThreads * kPerThread);
|
||||
EXPECT_GE(stats->depthSum, stats->insertCount);
|
||||
FAIL() << "no round produced write stats";
|
||||
|
||||
// Every insert of every round is counted exactly once. A lost increment
|
||||
// under contention fails this.
|
||||
EXPECT_EQ(stats->insertCount, completedRounds * kOverlapThreads * kOverlapPerThread);
|
||||
|
||||
// THE assertion this test exists for: strictly greater, so a depthSum fed
|
||||
// a constant 1 (or fed nothing, or fed insertCount's own delta) cannot
|
||||
// satisfy it however many rounds run.
|
||||
EXPECT_GT(stats->depthSum, stats->insertCount)
|
||||
<< "depthSum must record the observed depth, not a constant 1; rounds run="
|
||||
<< completedRounds;
|
||||
|
||||
// Upper bound with teeth: at most kThreads writers can be inside an
|
||||
// insert at once, so no single insert can observe a depth above kThreads.
|
||||
// A missing fetch_sub in recordInsert() would let the gauge climb once
|
||||
// per insert, giving a depthSum near insertCount squared over two --
|
||||
// vastly over this bound at these counts.
|
||||
EXPECT_LE(stats->depthSum, stats->insertCount * kOverlapThreads);
|
||||
|
||||
// State plus cause: the gauge is back to exactly zero, so every one of
|
||||
// the increments taken above was matched by its decrement. Exactly 0 and
|
||||
// not "small": a single leaked writer strands getWriteLoad() nonzero for
|
||||
// the life of the process, which gates history acquisition.
|
||||
EXPECT_EQ(stats->concurrentWriters, 0u);
|
||||
EXPECT_EQ(backend->getWriteLoad(), 0);
|
||||
|
||||
EXPECT_GT(stats->insertMaxUs, 0u);
|
||||
// Depth cannot exceed the number of threads that could be inside the
|
||||
// insert at once, so the mean depth is bounded by kThreads.
|
||||
EXPECT_LE(stats->depthSum, stats->insertCount * kThreads);
|
||||
|
||||
backend->close();
|
||||
}
|
||||
@@ -431,15 +547,29 @@ TEST(NuDBFactory, write_load_reports_writer_depth)
|
||||
|
||||
// After writes complete the depth returns to 0 rather than staying
|
||||
// elevated, because this is an instantaneous gauge and not a counter.
|
||||
// Exactly 0 and not merely small: were getWriteLoad() to return one of
|
||||
// the cumulative fields instead of the live depth -- insertCount would
|
||||
// read 5 here, insertTotalUs some microsecond total -- this fails.
|
||||
auto const batch = createPredictableBatch(5, 777);
|
||||
storeBatch(*backend, batch);
|
||||
EXPECT_EQ(backend->getWriteLoad(), 0);
|
||||
|
||||
// The value must stay far below the history-acquisition cutoff that
|
||||
// LedgerMaster applies (kMaxWriteLoadAcquire), or history acquisition
|
||||
// would silently stop. Depth is bounded by the writing threads.
|
||||
constexpr int kMaxWriteLoadAcquire = 8192;
|
||||
EXPECT_LT(backend->getWriteLoad(), kMaxWriteLoadAcquire);
|
||||
// Same value as the write-stats snapshot reports, since both read the one
|
||||
// depth atomic. Catches the two accessors drifting onto different fields.
|
||||
auto const stats = backend->getWriteStats();
|
||||
if (!stats.has_value())
|
||||
FAIL() << "nudb must report write stats";
|
||||
EXPECT_EQ(static_cast<std::uint64_t>(backend->getWriteLoad()), stats->concurrentWriters);
|
||||
// The cumulative fields did move, so the 0 above is the gauge being
|
||||
// instantaneous and not the backend having done nothing.
|
||||
EXPECT_EQ(stats->insertCount, 5u);
|
||||
|
||||
// NOTE. LedgerMaster gates history acquisition on getWriteLoad() staying
|
||||
// below kMaxWriteLoadAcquire (8192), declared static constexpr inside
|
||||
// src/xrpld/app/ledger/detail/LedgerMaster.cpp and so unreachable from
|
||||
// this binary. Depth is bounded by the number of writing threads, which
|
||||
// cannot approach that figure, so the coupling is recorded here rather
|
||||
// than asserted against a literal copy of the constant that could drift.
|
||||
|
||||
backend->close();
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@ TEST(MetricsRegistryScaledMean, default_scale_is_one)
|
||||
{
|
||||
// The two-argument form is the latency case and must not scale silently;
|
||||
// if the default were 100 every published latency would be 100x wrong.
|
||||
EXPECT_EQ(Registry::scaledMean(360, 8), Registry::scaledMean(360, 8, 1));
|
||||
// 360/8 is 45 by hand -- an independent literal, not a restatement of the
|
||||
// implementation. A default of 100 would read 4500 here.
|
||||
EXPECT_EQ(Registry::scaledMean(360, 8), 45);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
/**
|
||||
* @file NodeStoreMetricNames.cpp
|
||||
* Unit tests for the nodestore read-latency histogram wiring.
|
||||
*
|
||||
* Two independent groups, split by what they can link:
|
||||
*
|
||||
* 1. The shared name/label constants and the three record-site helpers from
|
||||
* `<xrpl/telemetry/NodeStoreMetricNames.h>`. Header-only and free of any
|
||||
* OTel dependency, so these run in **both** builds. That matters: the
|
||||
* constants are what keeps the bucket-view registration in
|
||||
* MetricsRegistry.cpp and the record site in NodeStoreScheduler.cpp
|
||||
* agreeing on one instrument name, and a divergence there silently drops
|
||||
* the sub-millisecond bucket override.
|
||||
*
|
||||
* 2. An end-to-end record-and-read-back over a real SDK MeterProvider fitted
|
||||
* with the same explicit sub-millisecond boundaries production registers,
|
||||
* asserting the exact bucket counts a set of known latencies must land in.
|
||||
* Guarded on XRPL_ENABLE_TELEMETRY because the metrics SDK headers only
|
||||
* exist in that build.
|
||||
*
|
||||
* Why the second group does not drive NodeStoreScheduler directly: that class
|
||||
* lives in xrpld (`src/xrpld/app/main/`) and its onFetch() needs a live
|
||||
* JobQueue plus a ServiceRegistry, neither of which the standalone xrpl_tests
|
||||
* binary can supply -- the same reason MetricsRegistry.cpp is only compiled
|
||||
* into this binary on the no-op path (see src/tests/libxrpl/CMakeLists.txt).
|
||||
* What is testable here is everything that decides *what* gets recorded: the
|
||||
* instrument name, the two label values, the negative-value guard, and the
|
||||
* bucket ladder the value is filed into. The remaining step -- that
|
||||
* Database::fetchNodeObject actually reaches onFetch -- is covered by the
|
||||
* existing nodestore suites, which already exercise that call path.
|
||||
*/
|
||||
|
||||
#include <xrpl/telemetry/NodeStoreMetricNames.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace xrpl::telemetry;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1: shared constants and record-site helpers. Compile-time first, so a
|
||||
// regression is a build failure rather than only a test failure; the runtime
|
||||
// duplicates below name the offending case when one does fail.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The instrument name is the contract between the two call sites. Pinned to
|
||||
// the exact literal: bare lower snake_case, no `xrpld_` prefix (no metric in
|
||||
// this codebase carries one), and the `_us` suffix stating the unit.
|
||||
static_assert(std::string_view{kNodeStoreReadUs} == "nodestore_read_us");
|
||||
|
||||
// Label keys. `found` rather than `was_found` and `fetch_type` rather than
|
||||
// `type`, matching what the dashboards query.
|
||||
static_assert(std::string_view{kFetchTypeLabel} == "fetch_type");
|
||||
static_assert(std::string_view{kFetchFoundLabel} == "found");
|
||||
|
||||
// Label values.
|
||||
static_assert(std::string_view{kFetchTypeAsync} == "async");
|
||||
static_assert(std::string_view{kFetchTypeSync} == "sync");
|
||||
static_assert(std::string_view{kFetchFoundTrue} == "true");
|
||||
static_assert(std::string_view{kFetchFoundFalse} == "false");
|
||||
|
||||
// The helpers map each input to exactly one value, and the two arms differ.
|
||||
static_assert(std::string_view{fetchTypeLabelValue(true)} == "async");
|
||||
static_assert(std::string_view{fetchTypeLabelValue(false)} == "sync");
|
||||
static_assert(std::string_view{fetchFoundLabelValue(true)} == "true");
|
||||
static_assert(std::string_view{fetchFoundLabelValue(false)} == "false");
|
||||
|
||||
// The negative guard. Zero is admitted on purpose -- a page-cache hit really
|
||||
// can round to 0 us -- while anything below it is refused.
|
||||
static_assert(shouldRecordFetchLatency(0));
|
||||
static_assert(shouldRecordFetchLatency(1));
|
||||
static_assert(shouldRecordFetchLatency(25'000));
|
||||
static_assert(!shouldRecordFetchLatency(-1));
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(NodeStoreMetricNames, instrument_name_is_the_exact_shared_literal)
|
||||
{
|
||||
// Both the view registration (MetricsRegistry.cpp) and the record site
|
||||
// (NodeStoreScheduler.cpp) read this one constant. If it changes, the
|
||||
// dashboard query and the reference doc must change with it, so the exact
|
||||
// string is asserted rather than merely its shape.
|
||||
EXPECT_EQ(std::string_view{kNodeStoreReadUs}, "nodestore_read_us");
|
||||
EXPECT_EQ(std::string_view{kNodeStoreReadUs}.size(), 17u);
|
||||
|
||||
// No `xrpld_` prefix: verified against the live metric surface, where 0 of
|
||||
// 537 exported names carry one. A prefix here would make this the only
|
||||
// odd metric out and break every dashboard that globs the family.
|
||||
EXPECT_FALSE(std::string_view{kNodeStoreReadUs}.starts_with("xrpld_"));
|
||||
|
||||
// The `_us` suffix is load-bearing: FetchReport::elapsed is
|
||||
// std::chrono::microseconds, and a name implying milliseconds would make
|
||||
// every reading 1000x wrong to a reader.
|
||||
EXPECT_TRUE(std::string_view{kNodeStoreReadUs}.ends_with("_us"));
|
||||
|
||||
// The description must name the unit too, since that is all a Prometheus
|
||||
// consumer sees alongside the metric.
|
||||
EXPECT_EQ(
|
||||
std::string_view{kNodeStoreReadUsDesc}, "NodeStore backend fetch latency in microseconds");
|
||||
}
|
||||
|
||||
TEST(NodeStoreMetricNames, label_keys_and_values_are_the_exact_literals)
|
||||
{
|
||||
EXPECT_EQ(std::string_view{kFetchTypeLabel}, "fetch_type");
|
||||
EXPECT_EQ(std::string_view{kFetchFoundLabel}, "found");
|
||||
|
||||
EXPECT_EQ(std::string_view{kFetchTypeAsync}, "async");
|
||||
EXPECT_EQ(std::string_view{kFetchTypeSync}, "sync");
|
||||
EXPECT_EQ(std::string_view{kFetchFoundTrue}, "true");
|
||||
EXPECT_EQ(std::string_view{kFetchFoundFalse}, "false");
|
||||
|
||||
// The two keys must differ, or one label would overwrite the other in the
|
||||
// attribute map and a whole dimension would vanish.
|
||||
EXPECT_NE(std::string_view{kFetchTypeLabel}, std::string_view{kFetchFoundLabel});
|
||||
}
|
||||
|
||||
TEST(NodeStoreMetricNames, helpers_map_each_input_to_its_own_value)
|
||||
{
|
||||
// Positive path for both arms of both helpers.
|
||||
EXPECT_EQ(std::string_view{fetchTypeLabelValue(true)}, "async");
|
||||
EXPECT_EQ(std::string_view{fetchTypeLabelValue(false)}, "sync");
|
||||
EXPECT_EQ(std::string_view{fetchFoundLabelValue(true)}, "true");
|
||||
EXPECT_EQ(std::string_view{fetchFoundLabelValue(false)}, "false");
|
||||
|
||||
// Cause, not just state: the two arms are genuinely distinct, so a
|
||||
// copy-paste that returned the same value for both would fail here rather
|
||||
// than quietly collapsing async and sync into one series.
|
||||
EXPECT_NE(
|
||||
std::string_view{fetchTypeLabelValue(true)}, std::string_view{fetchTypeLabelValue(false)});
|
||||
EXPECT_NE(
|
||||
std::string_view{fetchFoundLabelValue(true)},
|
||||
std::string_view{fetchFoundLabelValue(false)});
|
||||
|
||||
// Each helper returns one of its own two constants and never the other
|
||||
// helper's, which is what keeps the two dimensions independent.
|
||||
EXPECT_EQ(fetchTypeLabelValue(true), kFetchTypeAsync);
|
||||
EXPECT_EQ(fetchTypeLabelValue(false), kFetchTypeSync);
|
||||
EXPECT_EQ(fetchFoundLabelValue(true), kFetchFoundTrue);
|
||||
EXPECT_EQ(fetchFoundLabelValue(false), kFetchFoundFalse);
|
||||
}
|
||||
|
||||
TEST(NodeStoreMetricNames, latency_guard_admits_zero_and_refuses_negatives)
|
||||
{
|
||||
// Negative path -- the reason the guard exists. The OTel SDK drops a
|
||||
// negative histogram value AND logs a warning for it; on a per-fetch path
|
||||
// that is a log flood, so the sample is filtered before it gets there.
|
||||
EXPECT_FALSE(shouldRecordFetchLatency(-1));
|
||||
EXPECT_FALSE(shouldRecordFetchLatency(-1'000));
|
||||
|
||||
// Zero must NOT be filtered: a read served from the page cache genuinely
|
||||
// truncates to 0 us, and suppressing it would hide the fastest reads and
|
||||
// bias the whole distribution upward.
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(0));
|
||||
|
||||
// Ordinary and cold-tail values pass.
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(1));
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(9));
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(250));
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(30'000));
|
||||
|
||||
// The boundary is exactly at zero, not near it.
|
||||
EXPECT_TRUE(shouldRecordFetchLatency(0));
|
||||
EXPECT_FALSE(shouldRecordFetchLatency(-1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2: record into a real histogram carrying production's explicit
|
||||
// sub-millisecond boundaries, then read the exported point back and assert the
|
||||
// exact per-bucket counts.
|
||||
//
|
||||
// This is what proves the signal is usable rather than merely emitted. The
|
||||
// SDK's default boundaries begin at 0/5/10/25... but top out at 10,000, and
|
||||
// the microsecond ladder used by the other duration histograms begins at 100
|
||||
// us -- above the entire range a warm read occupies. Under that ladder every
|
||||
// warm read files into bucket 0 and the distribution reads flat. The
|
||||
// assertions below pin warm reads into distinct low buckets, which is exactly
|
||||
// the property the sub-millisecond ladder exists to provide.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#ifdef XRPL_ENABLE_TELEMETRY
|
||||
|
||||
#include <opentelemetry/context/context.h>
|
||||
#include <opentelemetry/exporters/memory/in_memory_metric_data.h>
|
||||
#include <opentelemetry/exporters/memory/in_memory_metric_exporter_factory.h>
|
||||
#include <opentelemetry/metrics/meter.h>
|
||||
#include <opentelemetry/nostd/unique_ptr.h>
|
||||
#include <opentelemetry/nostd/variant.h>
|
||||
#include <opentelemetry/sdk/metrics/aggregation/aggregation_config.h>
|
||||
#include <opentelemetry/sdk/metrics/data/point_data.h>
|
||||
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_factory.h>
|
||||
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_options.h>
|
||||
#include <opentelemetry/sdk/metrics/instruments.h>
|
||||
#include <opentelemetry/sdk/metrics/meter_provider.h>
|
||||
#include <opentelemetry/sdk/metrics/meter_provider_factory.h>
|
||||
#include <opentelemetry/sdk/metrics/view/instrument_selector_factory.h>
|
||||
#include <opentelemetry/sdk/metrics/view/meter_selector_factory.h>
|
||||
#include <opentelemetry/sdk/metrics/view/view_factory.h>
|
||||
#include <opentelemetry/sdk/metrics/view/view_registry.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
namespace metric_sdk = opentelemetry::sdk::metrics;
|
||||
namespace in_memory = opentelemetry::exporter::memory;
|
||||
|
||||
/**
|
||||
* The same edges MetricsRegistry.cpp's kSubMillisecondBoundaries holds.
|
||||
*
|
||||
* Deliberately a second, independent copy rather than an include of the
|
||||
* production array: that constant lives in an unnamed namespace inside
|
||||
* MetricsRegistry.cpp and is unreachable from here, and re-deriving the edges
|
||||
* from the implementation would make the bucket-index assertions below
|
||||
* tautological. Written out by hand, they pin the ladder -- so silently
|
||||
* re-tuning an edge in production without revisiting this file fails here.
|
||||
*/
|
||||
constexpr std::array kExpectedBoundaries{
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
25.0,
|
||||
50.0,
|
||||
100.0,
|
||||
250.0,
|
||||
500.0,
|
||||
1'000.0,
|
||||
5'000.0,
|
||||
25'000.0};
|
||||
|
||||
/**
|
||||
* Meter identity used by the production view selector, so the view this
|
||||
* fixture registers matches the instrument the fixture creates.
|
||||
*/
|
||||
constexpr char kMeterName[] = "xrpld";
|
||||
constexpr char kMeterVersion[] = "1.0.0";
|
||||
|
||||
/**
|
||||
* A MeterProvider carrying one explicit-bucket view for kNodeStoreReadUs and
|
||||
* an in-memory exporter, so a test can record values and read the resulting
|
||||
* histogram point back without any network or OTLP involvement.
|
||||
*
|
||||
* Mirrors what MetricsRegistry::initExporterAndProvider() builds for this
|
||||
* instrument, minus the OTLP exporter.
|
||||
*/
|
||||
class HistogramFixture
|
||||
{
|
||||
public:
|
||||
HistogramFixture()
|
||||
{
|
||||
// The view: same instrument type, same name, same meter selector and
|
||||
// the same boundaries production registers.
|
||||
auto config = std::make_shared<metric_sdk::HistogramAggregationConfig>();
|
||||
config->boundaries_ = {kExpectedBoundaries.begin(), kExpectedBoundaries.end()};
|
||||
|
||||
auto views = std::make_unique<metric_sdk::ViewRegistry>();
|
||||
views->AddView(
|
||||
metric_sdk::InstrumentSelectorFactory::Create(
|
||||
metric_sdk::InstrumentType::kHistogram, kNodeStoreReadUs, ""),
|
||||
metric_sdk::MeterSelectorFactory::Create(kMeterName, kMeterVersion, ""),
|
||||
metric_sdk::ViewFactory::Create(
|
||||
kNodeStoreReadUs, "", metric_sdk::AggregationType::kHistogram, config));
|
||||
|
||||
provider_ = metric_sdk::MeterProviderFactory::Create(std::move(views));
|
||||
|
||||
// A long export interval keeps the background thread from exporting
|
||||
// on its own schedule; the test drives collection via ForceFlush().
|
||||
metric_sdk::PeriodicExportingMetricReaderOptions readerOpts;
|
||||
readerOpts.export_interval_millis = std::chrono::milliseconds(600'000);
|
||||
readerOpts.export_timeout_millis = std::chrono::milliseconds(5'000);
|
||||
provider_->AddMetricReader(
|
||||
metric_sdk::PeriodicExportingMetricReaderFactory::Create(
|
||||
in_memory::InMemoryMetricExporterFactory::Create(data_), readerOpts));
|
||||
|
||||
histogram_ = provider_->GetMeter(kMeterName, kMeterVersion)
|
||||
->CreateDoubleHistogram(kNodeStoreReadUs, kNodeStoreReadUsDesc);
|
||||
}
|
||||
|
||||
~HistogramFixture()
|
||||
{
|
||||
provider_->Shutdown();
|
||||
}
|
||||
|
||||
HistogramFixture(HistogramFixture const&) = delete;
|
||||
HistogramFixture&
|
||||
operator=(HistogramFixture const&) = delete;
|
||||
|
||||
/**
|
||||
* Record one latency with the exact label set the production record site
|
||||
* attaches, built through the same two helpers.
|
||||
*
|
||||
* @param elapsedUs Latency in microseconds.
|
||||
* @param isAsync True for an async (prefetch) read.
|
||||
* @param wasFound True when the object was found.
|
||||
*/
|
||||
void
|
||||
record(double elapsedUs, bool isAsync, bool wasFound)
|
||||
{
|
||||
histogram_->Record(
|
||||
elapsedUs,
|
||||
{{kFetchTypeLabel, std::string(fetchTypeLabelValue(isAsync))},
|
||||
{kFetchFoundLabel, std::string(fetchFoundLabelValue(wasFound))}},
|
||||
opentelemetry::context::Context{});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the reader, then return every exported histogram point for
|
||||
* kNodeStoreReadUs keyed by its attribute set.
|
||||
*/
|
||||
[[nodiscard]] in_memory::SimpleAggregateInMemoryMetricData::AttributeToPoint const&
|
||||
collect()
|
||||
{
|
||||
provider_->ForceFlush();
|
||||
return data_->Get(kMeterName, kNodeStoreReadUs);
|
||||
}
|
||||
|
||||
private:
|
||||
/**
|
||||
* Sink the in-memory exporter writes each collection into.
|
||||
*/
|
||||
std::shared_ptr<in_memory::SimpleAggregateInMemoryMetricData> data_ =
|
||||
std::make_shared<in_memory::SimpleAggregateInMemoryMetricData>();
|
||||
|
||||
/**
|
||||
* Provider owning the view registry and the in-memory reader.
|
||||
*/
|
||||
std::shared_ptr<metric_sdk::MeterProvider> provider_;
|
||||
|
||||
/**
|
||||
* The instrument under test.
|
||||
*/
|
||||
opentelemetry::nostd::unique_ptr<opentelemetry::metrics::Histogram<double>> histogram_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the HistogramPointData for the one series matching @p isAsync and
|
||||
* @p wasFound, or nullptr when no such series was exported.
|
||||
*
|
||||
* @param points Exported points keyed by attribute set.
|
||||
* @param isAsync fetch_type dimension to match.
|
||||
* @param wasFound found dimension to match.
|
||||
*/
|
||||
[[nodiscard]] metric_sdk::HistogramPointData const*
|
||||
findPoint(
|
||||
in_memory::SimpleAggregateInMemoryMetricData::AttributeToPoint const& points,
|
||||
bool isAsync,
|
||||
bool wasFound)
|
||||
{
|
||||
for (auto const& [attributes, point] : points)
|
||||
{
|
||||
auto const type = attributes.find(kFetchTypeLabel);
|
||||
auto const found = attributes.find(kFetchFoundLabel);
|
||||
if (type == attributes.end() || found == attributes.end())
|
||||
continue;
|
||||
|
||||
if (opentelemetry::nostd::get<std::string>(type->second) != fetchTypeLabelValue(isAsync) ||
|
||||
opentelemetry::nostd::get<std::string>(found->second) != fetchFoundLabelValue(wasFound))
|
||||
continue;
|
||||
|
||||
return &opentelemetry::nostd::get<metric_sdk::HistogramPointData>(point);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(NodeStoreReadHistogram, view_applies_the_sub_millisecond_boundaries)
|
||||
{
|
||||
HistogramFixture fixture;
|
||||
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
|
||||
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
|
||||
ASSERT_NE(point, nullptr);
|
||||
|
||||
// The exported point must carry OUR boundaries, not the SDK defaults. This
|
||||
// is the assertion that catches a name mismatch between the view selector
|
||||
// and the instrument: on a mismatch the view is never applied and the
|
||||
// default ladder appears here instead.
|
||||
ASSERT_EQ(point->boundaries_.size(), kExpectedBoundaries.size());
|
||||
for (std::size_t i = 0; i < kExpectedBoundaries.size(); ++i)
|
||||
EXPECT_EQ(point->boundaries_[i], kExpectedBoundaries[i]) << "boundary index " << i;
|
||||
|
||||
// The first edge is 1 us, three orders of magnitude below the microsecond
|
||||
// ladder's 100 us first edge. That difference is the entire point: without
|
||||
// it a warm read cannot be distinguished from an instant one.
|
||||
EXPECT_EQ(point->boundaries_.front(), 1.0);
|
||||
EXPECT_EQ(point->boundaries_.back(), 25'000.0);
|
||||
}
|
||||
|
||||
TEST(NodeStoreReadHistogram, warm_reads_land_in_distinct_low_buckets)
|
||||
{
|
||||
HistogramFixture fixture;
|
||||
|
||||
// Four warm latencies, each chosen to fall in a different low bucket.
|
||||
// Bucket i counts values in (boundaries[i-1], boundaries[i]].
|
||||
// 0.5 us -> bucket 0 ( <= 1 )
|
||||
// 3 us -> bucket 2 ( 2 < v <= 5 )
|
||||
// 9 us -> bucket 3 ( 5 < v <= 10 )
|
||||
// 40 us -> bucket 5 ( 25 < v <= 50 )
|
||||
fixture.record(0.5, /*isAsync=*/false, /*wasFound=*/true);
|
||||
fixture.record(3.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
fixture.record(40.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
|
||||
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
|
||||
ASSERT_NE(point, nullptr);
|
||||
|
||||
// Exact counts, bucket by bucket -- not merely "the total is 4". Under the
|
||||
// microsecond ladder all four would sit in bucket 0 and this test would
|
||||
// fail, which is precisely the regression it guards against.
|
||||
ASSERT_EQ(point->counts_.size(), kExpectedBoundaries.size() + 1);
|
||||
EXPECT_EQ(point->counts_[0], 1u); // 0.5 us
|
||||
EXPECT_EQ(point->counts_[1], 0u);
|
||||
EXPECT_EQ(point->counts_[2], 1u); // 3 us
|
||||
EXPECT_EQ(point->counts_[3], 1u); // 9 us
|
||||
EXPECT_EQ(point->counts_[4], 0u);
|
||||
EXPECT_EQ(point->counts_[5], 1u); // 40 us
|
||||
for (std::size_t i = 6; i < point->counts_.size(); ++i)
|
||||
EXPECT_EQ(point->counts_[i], 0u) << "bucket " << i << " should be empty";
|
||||
|
||||
// Aggregate state must agree with the per-bucket detail.
|
||||
EXPECT_EQ(point->count_, 4u);
|
||||
EXPECT_EQ(opentelemetry::nostd::get<double>(point->sum_), 52.5);
|
||||
EXPECT_EQ(opentelemetry::nostd::get<double>(point->min_), 0.5);
|
||||
EXPECT_EQ(opentelemetry::nostd::get<double>(point->max_), 40.0);
|
||||
}
|
||||
|
||||
TEST(NodeStoreReadHistogram, a_cold_read_lands_in_the_tail_not_the_ceiling)
|
||||
{
|
||||
HistogramFixture fixture;
|
||||
|
||||
// A cold read and an outlier beyond the top edge. 800 us falls in bucket 9
|
||||
// ( 500 < v <= 1000 ); 30000 us exceeds the 25000 top edge and so lands in
|
||||
// the overflow bucket, index 12.
|
||||
fixture.record(800.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
fixture.record(30'000.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
|
||||
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
|
||||
ASSERT_NE(point, nullptr);
|
||||
|
||||
EXPECT_EQ(point->counts_[9], 1u); // 800 us -- resolved, not saturated
|
||||
EXPECT_EQ(point->counts_[12], 1u); // 30 ms -- overflow bucket
|
||||
EXPECT_EQ(point->count_, 2u);
|
||||
EXPECT_EQ(opentelemetry::nostd::get<double>(point->max_), 30'000.0);
|
||||
}
|
||||
|
||||
TEST(NodeStoreReadHistogram, the_two_labels_split_the_series_four_ways)
|
||||
{
|
||||
HistogramFixture fixture;
|
||||
|
||||
// One record per (fetch_type, found) combination, each with a distinct
|
||||
// latency so the series cannot be confused with one another.
|
||||
fixture.record(3.0, /*isAsync=*/true, /*wasFound=*/true);
|
||||
fixture.record(9.0, /*isAsync=*/true, /*wasFound=*/false);
|
||||
fixture.record(40.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
fixture.record(800.0, /*isAsync=*/false, /*wasFound=*/false);
|
||||
|
||||
auto const& points = fixture.collect();
|
||||
|
||||
// Four distinct label sets means four distinct time series. If either
|
||||
// label were dropped or misspelled these would collapse into fewer.
|
||||
EXPECT_EQ(points.size(), 4u);
|
||||
|
||||
struct Expected
|
||||
{
|
||||
bool isAsync;
|
||||
bool wasFound;
|
||||
double value;
|
||||
std::size_t bucket;
|
||||
};
|
||||
|
||||
// Each series holds exactly its own one sample, in its own bucket. This is
|
||||
// the assertion that a label mix-up would break: swapping two values would
|
||||
// put a sample in the wrong series and fail here.
|
||||
for (auto const& [isAsync, wasFound, value, bucket] : std::array<Expected, 4>{
|
||||
{{.isAsync = true, .wasFound = true, .value = 3.0, .bucket = 2},
|
||||
{.isAsync = true, .wasFound = false, .value = 9.0, .bucket = 3},
|
||||
{.isAsync = false, .wasFound = true, .value = 40.0, .bucket = 5},
|
||||
{.isAsync = false, .wasFound = false, .value = 800.0, .bucket = 9}}})
|
||||
{
|
||||
auto const* point = findPoint(points, isAsync, wasFound);
|
||||
ASSERT_NE(point, nullptr) << "missing series for fetch_type="
|
||||
<< fetchTypeLabelValue(isAsync)
|
||||
<< " found=" << fetchFoundLabelValue(wasFound);
|
||||
EXPECT_EQ(point->count_, 1u);
|
||||
EXPECT_EQ(opentelemetry::nostd::get<double>(point->sum_), value);
|
||||
EXPECT_EQ(point->counts_[bucket], 1u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(NodeStoreReadHistogram, a_guarded_negative_latency_never_reaches_the_instrument)
|
||||
{
|
||||
HistogramFixture fixture;
|
||||
|
||||
// Negative path, end to end: the record site consults
|
||||
// shouldRecordFetchLatency() before calling Record(), so a clock anomaly
|
||||
// produces no sample at all. Reproduced here with the same guard.
|
||||
for (double const elapsedUs : {-1.0, -1'000.0})
|
||||
{
|
||||
if (shouldRecordFetchLatency(static_cast<long long>(elapsedUs)))
|
||||
fixture.record(elapsedUs, /*isAsync=*/false, /*wasFound=*/true);
|
||||
}
|
||||
|
||||
// No series at all: nothing was recorded, so the instrument exported
|
||||
// nothing rather than exporting a zero-count point.
|
||||
EXPECT_TRUE(fixture.collect().empty());
|
||||
|
||||
// Cause, not just state: the same fixture DOES accept a valid sample, so
|
||||
// the emptiness above is the guard working and not a broken fixture.
|
||||
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
|
||||
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
|
||||
ASSERT_NE(point, nullptr);
|
||||
EXPECT_EQ(point->count_, 1u);
|
||||
EXPECT_EQ(point->counts_[3], 1u);
|
||||
}
|
||||
|
||||
#endif // XRPL_ENABLE_TELEMETRY
|
||||
Reference in New Issue
Block a user