feat(telemetry): pinpoint root cause of slow TMGetObjectByHash service

Slowness on the peer object-fetch path could be observed but not
attributed. Job duration metrics carry only `job_type`, and both
`RcvGetLedger` and `RcvGetObjByHash` report as `ledgerRequest`, so a
queue-wait spike could not be traced to a handler. Nothing measured
NodeStore cost, request size, or the differential charge.

Latency now decomposes into three additive parts, each separately
measurable:

    end-to-end = queue wait + NodeStore lookup + everything else

- `handler` label on job_queued_total/_started_total/_finished_total and
  job_queued_us/job_running_us. The value is sanitised: a name passes
  through only if non-empty and all ASCII letters, else "other". Two job
  names embed a ledger sequence, so a raw label would mint one series
  per ledger; the rule bounds the domain at 43 names plus "other".
- getobject_lookup_us, _request_objects, _lookups_total{result},
  _rejected_total{reason} and _charge, recorded at their call sites.
  All three histograms get explicit bucket views: the SDK default stops
  at 10,000, which every one of them exceeds.
- Per-job-type waiting/running/deferred gauges for the 35 non-special
  job types. `deferred` is the leading indicator, since addJob never
  rejects -- it defers, so backpressure otherwise shows up only as
  latency after the fact.

`JobQueue::collect()` snapshots the counters under the queue lock and
publishes gauges after releasing it. Writing them while holding the lock
would invert a lock order against the collector's own lock, which the
collector's flush thread already holds when it calls this hook.

Tests assert exact values, including that the charge is priced on the
requested count rather than the capped iteration count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-07-25 11:59:16 +01:00
parent 92fc2daff6
commit 15596f5b8d
26 changed files with 3433 additions and 164 deletions

View File

@@ -533,7 +533,7 @@ public:
// the jobs data with every addition.
for (int i = 0; i < jobs.size(); ++i)
{
perfLog->jobQueue(jobs[i].type);
perfLog->jobQueue(jobs[i].type, jobs[i].typeName);
json::Value const jqCounters{perfLog->countersJson()[jss::job_queue]};
BEAST_EXPECT(jqCounters.size() == i + 2);
@@ -581,7 +581,8 @@ public:
// be half as many queued as started...
for (int i = 0; i < jobs.size(); ++i)
{
perfLog->jobStart(jobs[i].type, microseconds{i + 1}, steady_clock::now(), i * 2);
perfLog->jobStart(
jobs[i].type, jobs[i].typeName, microseconds{i + 1}, steady_clock::now(), i * 2);
std::this_thread::sleep_for(microseconds(10));
// Check each jobType counter entry.
@@ -623,7 +624,8 @@ public:
BEAST_EXPECT(total[jss::running_duration_us] == "0");
}
perfLog->jobStart(jobs[i].type, microseconds{0}, steady_clock::now(), (i * 2) + 1);
perfLog->jobStart(
jobs[i].type, jobs[i].typeName, microseconds{0}, steady_clock::now(), (i * 2) + 1);
std::this_thread::sleep_for(microseconds{10});
// Verify that every entry in jobs appears twice in currents.
@@ -651,7 +653,7 @@ public:
// A number of the computations in this loop care about the
// number of jobs that have finished. Make that available.
int const finished = ((jobs.size() - i) * 2) - 1;
perfLog->jobFinish(jobs[i].type, microseconds(finished), (i * 2) + 1);
perfLog->jobFinish(jobs[i].type, jobs[i].typeName, microseconds(finished), (i * 2) + 1);
std::this_thread::sleep_for(microseconds(10));
json::Value const jqCounters{perfLog->countersJson()[jss::job_queue]};
@@ -697,7 +699,7 @@ public:
BEAST_EXPECT(jsonToUInt64(total[jss::running_duration_us]) == runningDur);
}
perfLog->jobFinish(jobs[i].type, microseconds(finished + 1), (i * 2));
perfLog->jobFinish(jobs[i].type, jobs[i].typeName, microseconds(finished + 1), (i * 2));
std::this_thread::sleep_for(microseconds(10));
// Verify that the two jobs we just finished no longer appear in
@@ -891,25 +893,25 @@ public:
};
// Start an ID that's too large.
perfLog->jobStart(jobType, microseconds{11}, steady_clock::now(), 2);
perfLog->jobStart(jobType, jobTypeName, microseconds{11}, steady_clock::now(), 2);
std::this_thread::sleep_for(microseconds{10});
verifyCounters(perfLog->countersJson(), 1, 0, 11, 0);
verifyEmptyCurrent(perfLog->currentJson());
// Start a negative ID
perfLog->jobStart(jobType, microseconds{13}, steady_clock::now(), -1);
perfLog->jobStart(jobType, jobTypeName, microseconds{13}, steady_clock::now(), -1);
std::this_thread::sleep_for(microseconds{10});
verifyCounters(perfLog->countersJson(), 2, 0, 24, 0);
verifyEmptyCurrent(perfLog->currentJson());
// Finish the too large ID
perfLog->jobFinish(jobType, microseconds{17}, 2);
perfLog->jobFinish(jobType, jobTypeName, microseconds{17}, 2);
std::this_thread::sleep_for(microseconds{10});
verifyCounters(perfLog->countersJson(), 2, 1, 24, 17);
verifyEmptyCurrent(perfLog->currentJson());
// Finish the negative ID
perfLog->jobFinish(jobType, microseconds{19}, -1);
perfLog->jobFinish(jobType, jobTypeName, microseconds{19}, -1);
std::this_thread::sleep_for(microseconds{10});
verifyCounters(perfLog->countersJson(), 2, 2, 24, 36);
verifyEmptyCurrent(perfLog->currentJson());

View File

@@ -1,14 +1,307 @@
#include <test/jtx/Env.h>
#include <xrpl/basics/Log.h>
#include <xrpl/beast/insight/Collector.h>
#include <xrpl/beast/insight/Counter.h>
#include <xrpl/beast/insight/CounterImpl.h>
#include <xrpl/beast/insight/Event.h>
#include <xrpl/beast/insight/EventImpl.h>
#include <xrpl/beast/insight/Gauge.h>
#include <xrpl/beast/insight/GaugeImpl.h>
#include <xrpl/beast/insight/Hook.h>
#include <xrpl/beast/insight/HookImpl.h>
#include <xrpl/beast/insight/Meter.h>
#include <xrpl/beast/insight/MeterImpl.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/Job.h>
#include <xrpl/core/JobQueue.h>
#include <xrpl/core/JobTypeData.h>
#include <xrpl/core/JobTypes.h>
#include <xrpl/core/PerfLog.h>
#include <xrpl/json/json_value.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <limits>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::test {
namespace {
/**
* A beast::insight::Collector that remembers the last value set on every
* gauge it created, keyed by gauge name.
*
* Needed because the production collectors are write-only sinks: StatsD
* sends over UDP and NullCollector discards. `JobQueue::collect()` assigns
* to the per-job-type gauges, so the only way to assert on the published
* values in-process is to supply a Collector that keeps them.
*
* RecordingCollector (Collector)
* | makeGauge(name)
* v
* RecordingGaugeImpl (GaugeImpl) --writes--> Values (shared, mutex-guarded)
* ^ ^
* | |
* JobQueue::collect() assigns gaugeValue(name) reads
*
* Only gauges are recorded; counters, events, meters and hooks get inert
* implementations, because no assertion here needs them. The hook handler is
* kept so the test can invoke the collection pass on demand rather than
* waiting for a collector's own timer -- that makes the reads deterministic.
*
* @note Thread-safe: the value map is guarded by its own mutex, because
* `JobQueue::collect()` may run on a different thread from the assertions.
*/
class RecordingCollector : public beast::insight::Collector
{
public:
/**
* Shared storage so gauge handles outliving the collector stay valid.
*/
struct Values
{
std::mutex mutex;
std::map<std::string, beast::insight::GaugeImpl::value_type> gauges;
};
private:
/**
* A gauge that writes each assigned value into the shared map.
*/
class RecordingGaugeImpl : public beast::insight::GaugeImpl
{
std::shared_ptr<Values> values_;
std::string name_;
public:
RecordingGaugeImpl(std::shared_ptr<Values> values, std::string name)
: values_(std::move(values)), name_(std::move(name))
{
}
void
set(value_type value) override
{
std::scoped_lock const lock(values_->mutex);
values_->gauges[name_] = value;
}
void
increment(difference_type amount) override
{
std::scoped_lock const lock(values_->mutex);
values_->gauges[name_] += static_cast<value_type>(amount);
}
};
/** @{ */
/**
* Inert implementations for the metric kinds no assertion reads.
*/
class InertCounterImpl : public beast::insight::CounterImpl
{
void
increment(value_type) override
{
}
};
class InertEventImpl : public beast::insight::EventImpl
{
void
notify(value_type const&) override
{
}
};
class InertMeterImpl : public beast::insight::MeterImpl
{
void
increment(value_type) override
{
}
};
/**
* A hook that owns its handler, so releasing the Hook releases the
* handler with it.
*
* The collector holds only a weak reference (see `hooks_`). That
* reproduces the documented beast::insight lifetime rule -- "when the
* last reference goes away, the metric is no longer collected" -- and
* matters here because `JobQueue::~JobQueue()` unhooks by assigning an
* empty Hook. A collector holding the handler strongly would keep
* calling back into a destroyed JobQueue.
*/
class InertHookImpl : public beast::insight::HookImpl
{
public:
explicit InertHookImpl(HandlerType handler) : handler_(std::move(handler))
{
}
void
invoke() const
{
if (handler_)
handler_();
}
private:
HandlerType handler_;
};
/** @} */
std::shared_ptr<Values> values_{std::make_shared<Values>()};
std::vector<std::weak_ptr<InertHookImpl>> hooks_;
public:
beast::insight::Hook
makeHook(beast::insight::HookImpl::HandlerType const& handler) override
{
auto impl = std::make_shared<InertHookImpl>(handler);
hooks_.push_back(impl);
return beast::insight::Hook(std::move(impl));
}
beast::insight::Counter
makeCounter(std::string const&) override
{
return beast::insight::Counter(std::make_shared<InertCounterImpl>());
}
beast::insight::Event
makeEvent(std::string const&) override
{
return beast::insight::Event(std::make_shared<InertEventImpl>());
}
beast::insight::Gauge
makeGauge(std::string const& name) override
{
return beast::insight::Gauge(std::make_shared<RecordingGaugeImpl>(values_, name));
}
beast::insight::Meter
makeMeter(std::string const&) override
{
return beast::insight::Meter(std::make_shared<InertMeterImpl>());
}
/**
* Run every still-live hook, i.e. force one collection pass.
*
* Expired hooks are skipped rather than resurrected, so calling this
* after the JobQueue has been destroyed is a no-op instead of a
* use-after-free.
*/
void
runHooks() const
{
for (auto const& weak : hooks_)
{
if (auto const hook = weak.lock())
hook->invoke();
}
}
/**
* The last value published for @p name.
*
* @return The value, or std::nullopt when no gauge of that name has
* ever been written -- which distinguishes "gauge absent" from
* "gauge present and reading zero".
*/
[[nodiscard]] std::optional<beast::insight::GaugeImpl::value_type>
gaugeValue(std::string const& name) const
{
std::scoped_lock const lock(values_->mutex);
auto const iter = values_->gauges.find(name);
if (iter == values_->gauges.end())
return std::nullopt;
return iter->second;
}
};
/**
* A perf::PerfLog that ignores everything.
*
* JobQueue requires a PerfLog reference; these tests assert on gauges, not
* on the perf hooks, so every override is empty.
*/
class SilentPerfLog : public perf::PerfLog
{
void
rpcStart(std::string const&, std::uint64_t) override
{
}
void
rpcFinish(std::string const&, std::uint64_t) override
{
}
void
rpcError(std::string const&, std::uint64_t) override
{
}
void
jobQueue(JobType, std::string const&) override
{
}
void
jobStart(
JobType,
std::string const&,
std::chrono::microseconds,
std::chrono::time_point<std::chrono::steady_clock>,
int) override
{
}
void
jobFinish(JobType, std::string const&, std::chrono::microseconds, int) override
{
}
[[nodiscard]] json::Value
countersJson() const override
{
return json::Value();
}
[[nodiscard]] json::Value
currentJson() const override
{
return json::Value();
}
void
resizeJobs(int) override
{
}
void
rotate() override
{
}
};
// Gauge-name suffixes. Aliased from JobTypeData rather than re-spelled, so a
// rename there fails here instead of silently asserting on a stale name.
/** @{ */
constexpr auto& kSuffixWaiting = JobTypeData::kSuffixWaiting;
constexpr auto& kSuffixRunning = JobTypeData::kSuffixRunning;
constexpr auto& kSuffixDeferred = JobTypeData::kSuffixDeferred;
/** @} */
} // namespace
//------------------------------------------------------------------------------
class JobQueue_test : public beast::unit_test::Suite
@@ -130,12 +423,298 @@ class JobQueue_test : public beast::unit_test::Suite
}
}
//--------------------------------------------------------------------------
// Per-job-type saturation gauges (waiting / running / deferred)
//--------------------------------------------------------------------------
/**
* Owns a JobQueue wired to a RecordingCollector.
*
* A standalone JobQueue rather than `env.app().getJobQueue()`, for two
* reasons: the application's queue uses a write-only collector whose
* gauge values cannot be read back, and it carries background jobs whose
* timing would make exact counts unreproducible. Constructed with the
* given thread count so a concurrency limit can be exceeded on demand.
*/
struct GaugeFixture
{
Logs logs{beast::Severity::Disabled};
SilentPerfLog perfLog;
std::shared_ptr<RecordingCollector> collector{std::make_shared<RecordingCollector>()};
JobQueue queue;
explicit GaugeFixture(int threadCount)
: queue(threadCount, collector, logs.journal("JobQueue"), logs, perfLog)
{
}
/**
* Publish one collection pass, then read a gauge by job type.
*/
[[nodiscard]] std::optional<beast::insight::GaugeImpl::value_type>
read(JobType type, char const* suffix) const
{
collector->runHooks();
return collector->gaugeValue(JobTypes::name(type) + suffix);
}
};
/**
* A gauge exists for a limited job type and not for a special one.
*
* Creation is observed through the RecordingCollector: a name no gauge
* was created for is absent from its value map even after a collection
* pass, whereas a created one is present. That distinguishes "never
* created" from "created and reading 0", which a value check alone
* cannot.
*/
void
testGaugeCreation()
{
testcase("Saturation gauge creation");
GaugeFixture fixture(1);
// JtLedgerReq has limit 3, so it is not special and must be gauged.
BEAST_EXPECT(!JobTypes::instance().get(JtLedgerReq).special());
BEAST_EXPECT(JobTypes::instance().get(JtLedgerReq).limit() == 3);
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixWaiting).has_value());
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixRunning).has_value());
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixDeferred).has_value());
// A quiescent queue publishes exactly zero, not "no value".
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixWaiting) == 0u);
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixRunning) == 0u);
BEAST_EXPECT(fixture.read(JtLedgerReq, kSuffixDeferred) == 0u);
// JtPeer is special (limit_ == 0) and must have no gauges at all.
BEAST_EXPECT(JobTypes::instance().get(JtPeer).special());
BEAST_EXPECT(JobTypes::instance().get(JtPeer).limit() == 0);
BEAST_EXPECT(!fixture.read(JtPeer, kSuffixWaiting).has_value());
BEAST_EXPECT(!fixture.read(JtPeer, kSuffixRunning).has_value());
BEAST_EXPECT(!fixture.read(JtPeer, kSuffixDeferred).has_value());
}
/**
* Driving a capped type past its limit reports exact running and
* deferred counts, and deferred returns to exactly 0 once drained.
*
* JtPack is used because its limit is 1, which makes both figures
* unambiguous: with the single slot occupied by a job that blocks until
* released, every further submission must defer.
*/
void
testDeferredGaugeExactValues()
{
testcase("Saturation gauge deferred counts");
int const limit = JobTypes::instance().get(JtPack).limit();
BEAST_EXPECT(limit == 1);
// More threads than the type's limit, so `running` is capped by the
// job type rather than by thread availability.
GaugeFixture fixture(4);
// The first job blocks inside doJob() until released, holding the
// one slot. `entered` proves it really is running before the
// remaining jobs are submitted.
std::mutex mutex;
std::condition_variable cv;
bool release = false;
int entered = 0;
auto blockingJob = [&]() {
std::unique_lock lock(mutex);
++entered;
cv.notify_all();
cv.wait(lock, [&release] { return release; });
};
BEAST_EXPECT(fixture.queue.addJob(JtPack, "GaugeHold", blockingJob));
{
std::unique_lock lock(mutex);
BEAST_EXPECT(
cv.wait_for(lock, std::chrono::seconds(10), [&entered] { return entered == 1; }));
}
// Every one of these must defer: the limit is already reached.
int const extra = 4;
for (int i = 0; i < extra; ++i)
BEAST_EXPECT(fixture.queue.addJob(JtPack, "GaugeDefer", blockingJob));
// Exact values, not bounds: `running` equals the type's limit and
// `deferred` equals the number of submissions beyond it. Both are
// deterministic here because no JtPack job can complete until
// `release` is set.
BEAST_EXPECT(fixture.read(JtPack, kSuffixRunning) == static_cast<std::uint64_t>(limit));
BEAST_EXPECT(fixture.read(JtPack, kSuffixDeferred) == static_cast<std::uint64_t>(extra));
// `waiting` counts everything submitted and not yet started, which
// is the deferred jobs -- the running one was decremented when
// getNextJob() picked it up.
BEAST_EXPECT(fixture.read(JtPack, kSuffixWaiting) == static_cast<std::uint64_t>(extra));
// Cross-check against the public accessors, so a gauge that silently
// published a stale or unrelated number would be caught.
BEAST_EXPECT(fixture.queue.getJobCount(JtPack) == extra);
BEAST_EXPECT(fixture.queue.getJobCountTotal(JtPack) == extra + limit);
// Release everything and drain.
{
std::scoped_lock const lock(mutex);
release = true;
}
cv.notify_all();
fixture.queue.stop();
BEAST_EXPECT(fixture.queue.isStopped());
// All five jobs ran, so the backlog is gone: deferred is exactly 0,
// and so are waiting and running.
BEAST_EXPECT(entered == extra + limit);
BEAST_EXPECT(fixture.read(JtPack, kSuffixDeferred) == 0u);
BEAST_EXPECT(fixture.read(JtPack, kSuffixWaiting) == 0u);
BEAST_EXPECT(fixture.read(JtPack, kSuffixRunning) == 0u);
}
/**
* Negative path: an uncapped job type never reports non-zero deferred.
*
* JtClient's limit is std::numeric_limits<int>::max(), so
* `addRefCountedJob()` can never take the `++data.deferred` branch. The
* gauge must therefore read exactly 0 both while jobs are in flight and
* after the queue drains -- otherwise a dashboard would attribute
* backpressure to a type that cannot experience it.
*/
void
testUncappedTypeNeverDefers()
{
testcase("Saturation gauge uncapped type");
int const limit = JobTypes::instance().get(JtClient).limit();
BEAST_EXPECT(limit == std::numeric_limits<int>::max());
BEAST_EXPECT(!JobTypes::instance().get(JtClient).special());
// One thread, so submissions greatly outnumber the workers that can
// service them. Under a capped type this would defer; here it must
// not, which is what separates "waiting" from "deferred".
GaugeFixture fixture(1);
std::mutex mutex;
std::condition_variable cv;
bool release = false;
int entered = 0;
int const jobs = 6;
for (int i = 0; i < jobs; ++i)
{
BEAST_EXPECT(fixture.queue.addJob(JtClient, "GaugeUncapped", [&]() {
std::unique_lock lock(mutex);
++entered;
cv.notify_all();
cv.wait(lock, [&release] { return release; });
}));
}
// At least one job is in flight and the rest are backlogged, yet
// deferred stays at exactly 0 because the type has no limit.
{
std::unique_lock lock(mutex);
BEAST_EXPECT(
cv.wait_for(lock, std::chrono::seconds(10), [&entered] { return entered >= 1; }));
}
BEAST_EXPECT(fixture.read(JtClient, kSuffixDeferred) == 0u);
{
std::scoped_lock const lock(mutex);
release = true;
}
cv.notify_all();
fixture.queue.stop();
BEAST_EXPECT(entered == jobs);
BEAST_EXPECT(fixture.read(JtClient, kSuffixDeferred) == 0u);
BEAST_EXPECT(fixture.read(JtClient, kSuffixWaiting) == 0u);
BEAST_EXPECT(fixture.read(JtClient, kSuffixRunning) == 0u);
}
/**
* Exactly the non-special job types are gauged, three gauges each, and
* every published value is non-negative.
*
* The coverage half pins the cardinality the metric family adds: one
* gauge per non-special type per counter and none for special types, so
* a job type gaining or losing a limit shows up here.
*
* The non-negativity half is the observable consequence of the clamp in
* `collect()`. The clamp itself cannot be triggered from a test: it
* guards `waiting` / `running` / `deferred`, which are private to
* JobQueue and only ever incremented and decremented in matched pairs,
* so forcing one negative would need the internals hacked. What is
* assertable is the property the clamp exists to guarantee -- since
* `Gauge::value_type` is unsigned, an unclamped negative would surface
* as a value near 2^64 rather than as a small number, which is exactly
* what the upper bound below rules out.
*/
void
testGaugeCoverageAndNonNegative()
{
testcase("Saturation gauge coverage");
GaugeFixture fixture(1);
fixture.collector->runHooks();
// Sanity-check the fixture against the job-type table itself, so the
// expected counts are derived rather than hard-coded.
int nonSpecial = 0;
int special = 0;
int gauges = 0;
bool allSmall = true;
// No job has been submitted, so every published value must be 0.
// The bound is deliberately generous: it is here to catch an
// unsigned wrap, not to re-assert the exact zero above.
auto const kWrapGuard = static_cast<std::uint64_t>(1) << 32;
for (auto const& [type, info] : JobTypes::instance())
{
if (type == JtInvalid)
continue;
info.special() ? ++special : ++nonSpecial;
for (char const* suffix : {kSuffixWaiting, kSuffixRunning, kSuffixDeferred})
{
auto const value = fixture.collector->gaugeValue(info.name() + suffix);
// Presence must agree with speciality, in both directions.
BEAST_EXPECT(value.has_value() == !info.special());
if (!value)
continue;
++gauges;
if (*value >= kWrapGuard)
allSmall = false;
BEAST_EXPECT(*value == 0u);
}
}
BEAST_EXPECT(nonSpecial == 35);
BEAST_EXPECT(special == 11);
BEAST_EXPECT(gauges == nonSpecial * 3);
BEAST_EXPECT(gauges == 105);
BEAST_EXPECT(allSmall);
}
public:
void
run() override
{
testAddJob();
testPostCoro();
testGaugeCreation();
testGaugeCoverageAndNonNegative();
testDeferredGaugeExactValues();
testUncappedTypeNeverDefers();
}
};

View File

@@ -36,13 +36,14 @@ class PerfLogTest : public PerfLog
}
void
jobQueue(JobType const type) override
jobQueue(JobType const type, std::string const& name) override
{
}
void
jobStart(
JobType const type,
std::string const& name,
std::chrono::microseconds dur,
std::chrono::time_point<std::chrono::steady_clock> startTime,
int instance) override
@@ -50,7 +51,11 @@ class PerfLogTest : public PerfLog
}
void
jobFinish(JobType const type, std::chrono::microseconds dur, int instance) override
jobFinish(
JobType const type,
std::string const& name,
std::chrono::microseconds dur,
int instance) override
{
}

View File

@@ -20,7 +20,9 @@
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/resource/Charge.h>
#include <xrpl/resource/Consumer.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/server/Handoff.h>
#include <boost/asio/ip/address.hpp>
@@ -33,6 +35,9 @@
#include <cstddef>
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
@@ -100,6 +105,50 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
return lastSentMessage_;
}
/**
* Capture the charge the handler applies, then apply it for real.
*
* `Peer::charge()` is pure virtual and `processGetObjectByHash()`
* calls it unqualified, so this override sees the exact
* `Resource::Charge` the handler built -- the same object passed to
* `computeGetObjectByHashFee()`'s caller. That makes the handler's
* *choice of argument* observable, which reading `fee_` or calling
* the pricing helper with the test's own arguments cannot do.
*
* Recorded before forwarding so the base-class strand hop cannot
* reorder the observation; forwarding keeps the production
* disconnect/accounting behaviour intact.
*/
void
charge(Resource::Charge const& fee, std::string const& context) override
{
lastAppliedCharge_ = fee;
lastChargeContext_ = context;
PeerImp::charge(fee, context);
}
/**
* The charge captured by the override above, or nullopt if none.
*
* `Resource::Charge` has no default constructor, so the optional
* also distinguishes "not charged at all" from "charged zero" --
* a distinction the rejection-gate tests depend on.
*/
[[nodiscard]] std::optional<Resource::Charge> const&
getLastAppliedCharge() const
{
return lastAppliedCharge_;
}
/**
* The context string that accompanied the captured charge.
*/
[[nodiscard]] std::string const&
getLastChargeContext() const
{
return lastChargeContext_;
}
// Synchronous test access to the JobQueue-dispatched processor.
// The production path runs this on JtLedgerReq; tests need a
// synchronous entry point to inspect the reply via send().
@@ -111,6 +160,25 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
processGetObjectByHash(m);
}
// Read the accumulated per-message charge. `currentFeeCharge()` is
// protected on PeerImp; exposed here because it is the one
// deterministic, same-thread witness that a rejection gate fired --
// `charge()` itself dispatches to the peer's strand.
[[nodiscard]] Resource::Charge
peekFeeCharge() const
{
return currentFeeCharge();
}
// The differential-pricing helper, so a test can compare the charge
// applied by the handler against the helper's own result for the
// same inputs. Static and protected on PeerImp.
[[nodiscard]] static Resource::Charge
peekComputeFee(int const requested, int const found)
{
return computeGetObjectByHashFee(requested, found);
}
static void
resetId()
{
@@ -119,12 +187,55 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
private:
inline static Peer::id_t id = 0;
/**
* The last message handed to send().
*
* @note Not synchronised. When the handler runs on a JobQueue
* worker (the `onMessage()` tests), this is written on that worker
* and read on the test thread, so every such test must call
* `env.app().getJobQueue().rendezvous()` before reading it. The
* rendezvous supplies the happens-before edge: the worker's
* `--processCount_` under `mutex_` in `JobQueue::processTask()`
* releases, and the waiter's predicate acquires the same mutex.
* Tests that drive `runProcessGetObjectByHash()` directly run
* wholly on the test thread and need no rendezvous.
*/
std::shared_ptr<Message> lastSentMessage_;
/**
* @see getLastAppliedCharge(). Same threading rules as above.
*/
std::optional<Resource::Charge> lastAppliedCharge_;
/**
* @see getLastChargeContext(). Same threading rules as above.
*/
std::string lastChargeContext_;
};
shared_context context_{makeSslContext("")};
ProtocolVersion protocolVersion_{1, 7};
/**
* Seed offset for hashes that must NOT be present in the NodeStore.
*
* `createRequest()` stores `sha512Half(i)` for i in [0, numObjects), and
* numObjects can reach kHardMaxReplyNodes. Offsetting well past that
* keeps "unstored" hashes genuinely absent.
*/
static constexpr int kUnstoredHashSeed = 1'000'000;
/**
* Build a live PeerTest registered with the overlay.
*
* @note `overlay.addActive()` stores only `std::weak_ptr`s
* (`OverlayImpl::peers_`, `ids_` and `list_` are all weak), so it does
* *not* keep the peer alive. The returned `shared_ptr` is the sole
* owner; keep it in scope for the whole test. Safety for the
* JobQueue-dispatched path comes from the job lambda locking its own
* `weak_ptr` plus the `rendezvous()` each such test performs.
*/
std::shared_ptr<PeerTest>
createPeer(jtx::Env& env)
{
@@ -187,6 +298,27 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
return request;
}
/**
* Parse the reply captured by PeerTest::send().
*
* @return The decoded message, or std::nullopt when nothing was sent.
*/
std::optional<protocol::TMGetObjectByHash>
parseReply(std::shared_ptr<PeerTest> const& peer)
{
auto const sentMessage = peer->getLastSentMessage();
if (!sentMessage)
return std::nullopt;
auto const& buffer = sentMessage->getBuffer(compression::Compressed::Off);
BEAST_EXPECT(buffer.size() > 6);
// Skip the 6-byte message header (4 size + 2 type).
protocol::TMGetObjectByHash reply;
BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true);
return reply;
}
/**
* Test that reply is limited to hardMaxReplyNodes when more objects
* are requested than the limit allows.
@@ -209,28 +341,675 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
peer->runProcessGetObjectByHash(request);
// Verify that a reply was sent
auto sentMessage = peer->getLastSentMessage();
BEAST_EXPECT(sentMessage != nullptr);
// Parse the reply message
auto const& buffer = sentMessage->getBuffer(compression::Compressed::Off);
BEAST_EXPECT(buffer.size() > 6);
// Skip the message header (6 bytes: 4 for size, 2 for type)
protocol::TMGetObjectByHash reply;
BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true);
auto reply = parseReply(peer);
BEAST_EXPECT(reply.has_value());
if (!reply)
return;
// Verify the reply is limited to expectedReplySize
BEAST_EXPECT(reply.objects_size() == expectedReplySize);
BEAST_EXPECT(reply->objects_size() == expectedReplySize);
}
//--------------------------------------------------------------------------
// Request-gate rejection paths
//--------------------------------------------------------------------------
/**
* Build a query request with @p numObjects hashes and nothing stored.
*
* Distinct from `createRequest()`, which writes every hash to the
* NodeStore. The rejection gates return before any NodeStore access, so
* storing 12289 objects to test them would cost real time and prove
* nothing. Hashes are derived from the index but need not resolve.
*
* @param numObjects Objects to place in the request.
* @param type Message type; must not be otFETCH_PACK or
* otTRANSACTIONS, both of which are intercepted by
* earlier branches of onMessage().
*/
static std::shared_ptr<protocol::TMGetObjectByHash>
createUnstoredRequest(
int const numObjects,
protocol::TMGetObjectByHash::ObjectType const type =
protocol::TMGetObjectByHash_ObjectType_otLEDGER)
{
auto request = std::make_shared<protocol::TMGetObjectByHash>();
request->set_type(type);
request->set_query(true);
for (int i = 0; i < numObjects; ++i)
{
// Offset the seed so these hashes cannot collide with the ones
// createRequest() stores, keeping "unstored" unambiguous.
uint256 const hash(xrpl::sha512Half(i + kUnstoredHashSeed));
auto* object = request->add_objects();
object->set_hash(hash.data(), hash.size());
}
return request;
}
/**
* An oversized request is refused with no reply and an exact fee.
*
* This is the gate that `getobject_rejected_total{reason="oversize"}`
* counts. The counter itself is not readable in-process (see the note
* on run()), so the assertions are on the two observable effects of the
* same early return: no message was sent, and `fee_` holds exactly
* `kFeeInvalidData`.
*/
void
testOversizeRejection()
{
testcase("Oversize Rejection");
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
// Successful-setup assertion: a fresh peer starts at the trivial
// fee, so the post-condition below can only come from this call.
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeTrivialPeer.cost());
BEAST_EXPECT(peer->getLastSentMessage() == nullptr);
int const oversize = static_cast<int>(Tuning::kHardMaxReplyNodes) + 1;
peer->onMessage(createUnstoredRequest(oversize));
// State: nothing was replied to, because the gate returns before
// the job is queued.
BEAST_EXPECT(peer->getLastSentMessage() == nullptr);
// Cause: the charge is exactly the invalid-data fee, not merely
// "some larger fee". The label pins which gate fired -- the
// malformed-ledgerhash gate charges a different constant.
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeInvalidData.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() == 400);
BEAST_EXPECT(peer->peekFeeCharge().label() == Resource::kFeeInvalidData.label());
// Negative path for the differential charge: the gate returns before
// the handler runs, so computeGetObjectByHashFee() is never reached
// and charge() is never called. Nothing was applied at all -- which
// the optional distinguishes from a zero-cost charge.
BEAST_EXPECT(!peer->getLastAppliedCharge().has_value());
}
/**
* Exactly at the limit the request is accepted, so the gate is a strict
* `>` and not `>=`.
*
* Negative control for testOversizeRejection: without it, a gate that
* rejected everything would pass that test.
*/
void
testAtLimitNotRejected()
{
testcase("At Limit Not Rejected");
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
int const atLimit = static_cast<int>(Tuning::kHardMaxReplyNodes);
peer->onMessage(createUnstoredRequest(atLimit));
// Accepted: the request was queued, so the fee is the
// moderate-burden admission charge, not the invalid-data charge.
//
// `fee_.update()` for the admission charge runs on this thread, but
// the enqueued worker also writes the peer's reply. Drain the queue
// before reading anything so the observation cannot race the worker.
env.app().getJobQueue().rendezvous();
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeModerateBurdenPeer.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() != Resource::kFeeInvalidData.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() == 250);
// The request was in bounds, so the worker ran and replied. Nothing
// was stored, so every lookup missed and the reply is empty.
auto reply = parseReply(peer);
BEAST_EXPECT(reply.has_value());
if (reply)
BEAST_EXPECT(reply->objects_size() == 0);
// Positive counterpart to the oversize test: because the handler did
// run, a differential charge was applied, and it is exactly the
// all-miss price for the full request size.
auto const& applied = peer->getLastAppliedCharge();
BEAST_EXPECT(applied.has_value());
if (applied)
{
BEAST_EXPECT(applied->cost() == PeerTest::peekComputeFee(atLimit, 0).cost());
BEAST_EXPECT(applied->cost() == 99176);
}
}
/**
* A wrong-sized ledgerhash is refused with no reply and an exact fee.
*
* This is the gate `getobject_rejected_total{reason="malformed_ledgerhash"}`
* counts. `stringIsUInt256Sized` requires exactly `uint256::size()`
* bytes, so both a short and a long hash must be refused; a test using
* only one would miss an off-by-one in either direction.
*
* @param hashSize Byte length of the malformed ledgerhash.
*/
void
testMalformedLedgerHashRejection(std::size_t const hashSize)
{
testcase("Malformed LedgerHash Rejection: " + std::to_string(hashSize) + " bytes");
BEAST_EXPECT(hashSize != uint256::size());
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeTrivialPeer.cost());
// Small in-bounds object count, so this can only be the ledgerhash
// gate: the oversize gate is checked afterwards and cannot fire.
auto request = createUnstoredRequest(1);
request->set_ledgerhash(std::string(hashSize, 'x'));
peer->onMessage(request);
BEAST_EXPECT(peer->getLastSentMessage() == nullptr);
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeMalformedRequest.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() == 200);
BEAST_EXPECT(peer->peekFeeCharge().label() == Resource::kFeeMalformedRequest.label());
// Negative path: the gate returns before the handler, so no
// differential charge was ever applied.
BEAST_EXPECT(!peer->getLastAppliedCharge().has_value());
}
/**
* A correctly sized ledgerhash passes the gate.
*
* Negative control for testMalformedLedgerHashRejection.
*/
void
testWellFormedLedgerHashAccepted()
{
testcase("Well-Formed LedgerHash Accepted");
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
auto request = createUnstoredRequest(1);
uint256 const ledgerHash(xrpl::sha512Half(0));
BEAST_EXPECT(ledgerHash.size() == uint256::size());
request->set_ledgerhash(ledgerHash.data(), ledgerHash.size());
peer->onMessage(request);
// Drain the enqueued worker before observing, as in
// testAtLimitNotRejected.
env.app().getJobQueue().rendezvous();
// Not the malformed charge: the request was admitted.
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeModerateBurdenPeer.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() != Resource::kFeeMalformedRequest.cost());
BEAST_EXPECT(peer->peekFeeCharge().cost() == 250);
// A reply was produced, and it echoes the request's ledgerhash.
auto reply = parseReply(peer);
BEAST_EXPECT(reply.has_value());
if (reply)
{
BEAST_EXPECT(reply->has_ledgerhash());
BEAST_EXPECT(reply->ledgerhash() == request->ledgerhash());
}
}
//--------------------------------------------------------------------------
// Hit / miss split and charge
//--------------------------------------------------------------------------
/**
* Build a request that interleaves stored and unstored hashes.
*
* One of each is taken in turn until a side runs out, then whichever
* side remains is drained. Interleaving matters: a handler that stopped
* at the first miss would return fewer objects than expected, which a
* stored-then-unstored layout could hide.
*
* Asserts on its own setup, so a miscount here is reported at this call
* rather than as a confusing reply-size failure later.
*
* @param env Environment whose NodeStore receives the writes.
* @param numStored Hashes written to the NodeStore, i.e. hits.
* @param numUnstored Hashes left absent, i.e. misses.
* @param storedHashes Out-param populated with the stored hashes.
* @return The assembled request.
*/
std::shared_ptr<protocol::TMGetObjectByHash>
buildInterleavedRequest(
Env& env,
int const numStored,
int const numUnstored,
std::set<uint256>& storedHashes)
{
auto& nodeStore = env.app().getNodeStore();
auto request = std::make_shared<protocol::TMGetObjectByHash>();
request->set_type(protocol::TMGetObjectByHash_ObjectType_otLEDGER);
request->set_query(true);
int stored = 0;
int unstored = 0;
for (int i = 0; i < numStored + numUnstored; ++i)
{
// Alternate while both remain; then drain whichever is left.
bool const takeStored =
(stored < numStored) && (unstored >= numUnstored || (i % 2) == 0);
uint256 const hash(
xrpl::sha512Half(takeStored ? stored : unstored + kUnstoredHashSeed));
if (takeStored)
{
Blob data(100, static_cast<unsigned char>(stored % 256));
nodeStore.store(
NodeObjectType::Ledger, std::move(data), hash, nodeStore.earliestLedgerSeq());
BEAST_EXPECT(storedHashes.insert(hash).second);
++stored;
}
else
{
++unstored;
}
auto* object = request->add_objects();
object->set_hash(hash.data(), hash.size());
}
// Setup assertions: the mix is exactly what was asked for.
BEAST_EXPECT(stored == numStored);
BEAST_EXPECT(unstored == numUnstored);
BEAST_EXPECT(storedHashes.size() == static_cast<std::size_t>(numStored));
BEAST_EXPECT(request->objects_size() == numStored + numUnstored);
return request;
}
/**
* Every replied object is a distinct hash drawn from @p storedHashes.
*
* Without the distinctness check a handler that returned the same hit
* twice would still satisfy a reply-size assertion.
*
* @param reply The decoded reply.
* @param storedHashes The hashes that were written to the NodeStore.
* @param numStored Expected number of distinct returned hashes.
*/
void
verifyReplyObjects(
protocol::TMGetObjectByHash const& reply,
std::set<uint256> const& storedHashes,
int const numStored)
{
std::set<uint256> returned;
for (int i = 0; i < reply.objects_size(); ++i)
{
auto const& obj = reply.objects(i);
BEAST_EXPECT(obj.hash().size() == uint256::size());
BEAST_EXPECT(returned.insert(uint256::fromRaw(obj.hash())).second);
BEAST_EXPECT(storedHashes.contains(uint256::fromRaw(obj.hash())));
}
BEAST_EXPECT(returned.size() == static_cast<std::size_t>(numStored));
}
/**
* A mixed request returns exactly the stored objects and nothing else.
*
* This is the split `getobject_lookups_total{result=hit|miss}` records:
* the handler derives the miss count as `requested - found`, so an
* exact reply size is exactly the hit count the metric would report.
*
* @param numStored Hashes written to the NodeStore before the call.
* @param numUnstored Hashes that will miss.
*/
void
testHitMissSplit(int const numStored, int const numUnstored)
{
testcase(
"Hit/Miss Split: " + std::to_string(numStored) + " stored, " +
std::to_string(numUnstored) + " unstored");
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
std::set<uint256> storedHashes;
auto request = buildInterleavedRequest(env, numStored, numUnstored, storedHashes);
int const requested = numStored + numUnstored;
peer->runProcessGetObjectByHash(request);
auto reply = parseReply(peer);
BEAST_EXPECT(reply.has_value());
if (!reply)
return;
// The exact hit count. Every stored hash is returned and no
// unstored one is, so hits == numStored and the derived miss count
// is exactly numUnstored.
BEAST_EXPECT(reply->objects_size() == numStored);
BEAST_EXPECT(requested - reply->objects_size() == numUnstored);
verifyReplyObjects(*reply, storedHashes, numStored);
// Cause: the value recorded as getobject_charge is exactly the
// charge the handler applied, captured by PeerTest::charge().
auto const& applied = peer->getLastAppliedCharge();
BEAST_EXPECT(applied.has_value());
if (!applied)
return;
BEAST_EXPECT(applied->cost() == PeerTest::peekComputeFee(requested, numStored).cost());
BEAST_EXPECT(applied->label() == "GetObject differential");
// These request sizes are all within kFreeObjectsPerRequest, so the
// charge is exactly zero regardless of the split. Asserted rather
// than assumed: it is why this test does not also pin a non-trivial
// fee -- testComputeFeeExactValues covers the billable bands.
BEAST_EXPECT(requested <= static_cast<int>(Tuning::kFreeObjectsPerRequest));
BEAST_EXPECT(applied->cost() == 0);
// `fee_` is untouched on this path: the handler charges through
// charge(), never through fee_.update().
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeTrivialPeer.cost());
}
/**
* One pricing case: inputs, the derived expectation, and the literal.
*
* Both expectations are kept. `derived` is written from the Tuning
* constants so a deliberate re-pricing needs one edit; `literal` is the
* number as of this branch so a re-pricing cannot pass unnoticed by
* being self-consistently wrong.
*/
struct FeeCase
{
/**
* Objects the peer asked for.
*/
int requested;
/**
* Objects that resolved in the NodeStore.
*/
int found;
/**
* Expectation computed from the Tuning constants.
*/
int derived;
/**
* The same value as a hard number.
*/
int literal;
/**
* Reported when the case fails.
*/
char const* why;
};
/**
* Assert computeGetObjectByHashFee() equals both expectations of a case.
*
* @param fc The case to check.
*/
void
checkFeeCase(FeeCase const& fc)
{
auto const cost = PeerTest::peekComputeFee(fc.requested, fc.found).cost();
BEAST_EXPECTS(cost == fc.derived, fc.why);
BEAST_EXPECTS(cost == fc.literal, fc.why);
}
/**
* The Tuning constants the fee expectations are built from.
*
* Verified against their literal values by testComputeFeeExactValues()
* so a silent re-pricing shows up as a failure there rather than as a
* self-consistent but wrong expectation in every case below.
*/
struct FeeConstants
{
int free{static_cast<int>(Tuning::kFreeObjectsPerRequest)};
int hit{static_cast<int>(Tuning::kCostPerLookupHit)};
int miss{static_cast<int>(Tuning::kCostPerLookupMiss)};
int bandSmall{static_cast<int>(Tuning::kCostBandSmall)};
int bandMedium{static_cast<int>(Tuning::kCostBandMedium)};
int bandLarge{static_cast<int>(Tuning::kCostBandLarge)};
int smallMax{static_cast<int>(Tuning::kBandSmallMax)};
int mediumMax{static_cast<int>(Tuning::kBandMediumMax)};
};
/**
* Every pricing case, in one table.
*
* @param k The Tuning constants to derive expectations from.
*/
static std::vector<FeeCase>
makeFeeCases(FeeConstants const& k)
{
return {
// Wholly free: at or below the free allowance nothing is
// billable, so only the small size band applies -- which is 0.
{k.free, k.free, k.bandSmall, 0, "at the free allowance"},
{0, 0, k.bandSmall, 0, "empty request"},
{1, 1, k.bandSmall, 0, "one object, one hit"},
// All hits, one object past the allowance: one billable hit.
{k.free + 1, k.free + 1, k.hit + k.bandSmall, 1, "one billable hit"},
// All misses, one past the allowance: misses are billed first,
// so the single billable object is priced as a miss, not a hit.
{k.free + 1, 0, k.miss + k.bandSmall, 8, "one billable miss"},
// Mixed at the small-band edge: 64 requested, 32 found.
// Billable is 64-16 = 48; misses are 32 and all billable,
// leaving 16 billable hits.
{k.smallMax,
32,
(16 * k.hit) + (32 * k.miss) + k.bandSmall,
272,
"small-band edge, mixed"},
// One past the small band moves to the medium surcharge.
{k.smallMax + 1,
k.smallMax + 1,
((k.smallMax + 1 - k.free) * k.hit) + k.bandMedium,
149,
"first medium-band size"},
// The medium band's last size, then one past it, which moves to
// the large surcharge.
{k.mediumMax,
k.mediumMax,
((k.mediumMax - k.free) * k.hit) + k.bandMedium,
1108,
"last medium-band size"},
{k.mediumMax + 1,
k.mediumMax + 1,
((k.mediumMax + 1 - k.free) * k.hit) + k.bandLarge,
2009,
"first large-band size"},
// Clamp: found > requested cannot make the miss count negative,
// so the fee is the same as the all-hit case (1).
{k.free + 1, k.free + 10, k.hit + k.bandSmall, 1, "found exceeds requested"},
};
}
/**
* computeGetObjectByHashFee() returns exactly the documented value.
*
* The metric records the helper's result verbatim, so pinning the helper
* pins what `getobject_charge` reports.
*/
void
testComputeFeeExactValues()
{
testcase("Compute Fee Exact Values");
FeeConstants const k;
// Verify the constants themselves, so a silent re-pricing shows up
// here rather than as a self-consistent but wrong expectation.
BEAST_EXPECT(k.free == 16);
BEAST_EXPECT(k.hit == 1);
BEAST_EXPECT(k.miss == 8);
BEAST_EXPECT(k.bandSmall == 0);
BEAST_EXPECT(k.bandMedium == 100);
BEAST_EXPECT(k.bandLarge == 1000);
BEAST_EXPECT(k.smallMax == 64);
BEAST_EXPECT(k.mediumMax == 1024);
auto const cases = makeFeeCases(k);
BEAST_EXPECT(cases.size() == 10);
for (auto const& fc : cases)
checkFeeCase(fc);
// A miss costs strictly more than a hit for the same request size.
// Relational, so it cannot be expressed as a table row.
BEAST_EXPECT(
PeerTest::peekComputeFee(k.free + 1, 0).cost() >
PeerTest::peekComputeFee(k.free + 1, k.free + 1).cost());
// The label is fixed, so a charge can be attributed to this helper.
BEAST_EXPECT(PeerTest::peekComputeFee(k.free + 1, 0).label() == "GetObject differential");
}
/**
* The charge the handler *applies* is priced on `requested`, not on the
* capped iteration count and not on `found`.
*
* Load-bearing because `getobject_charge` records the applied value: if
* `processGetObjectByHash()` priced on `iterLimit` -- i.e.
* `min(requested, kHardMaxReplyNodes)` -- the metric would under-report
* abusive batches by exactly the overshoot.
*
* The assertion is on `PeerTest::charge()`, which overrides the virtual
* the handler calls, so it observes the very `Resource::Charge` object
* the handler constructed. Two other candidate witnesses were rejected:
* - `fee_` / `peekFeeCharge()`: this path never touches `fee_`, it
* goes through `charge()`.
* - `usage_.balance()`: `Entry::add()` returns
* `localBalance.add(...) + remoteBalance` and `DecayingSample::add()`
* returns `value_ / Window` with `Window == kDecayWindowSeconds ==
* 32`, so the balance is the applied cost divided by 32 with integer
* truncation. 99184 / 32 and 99176 / 32 are both 3099, so the
* balance cannot distinguish the mutation this test exists to catch.
* It also decays with wall-clock time (`BasicSecondsClock`), making
* any exact expectation racy.
*/
void
testChargeUsesRequestedCount()
{
testcase("Charge Uses Requested Count");
Env env(*this);
PeerTest::resetId();
auto peer = createPeer(env);
int const requested = static_cast<int>(Tuning::kHardMaxReplyNodes) + 1;
int const capped = static_cast<int>(Tuning::kHardMaxReplyNodes);
// Successful-setup assertion: nothing has been charged yet, so the
// post-condition below can only come from the handler call.
BEAST_EXPECT(!peer->getLastAppliedCharge().has_value());
// No hashes are stored, so every lookup misses and `found` is 0.
// Called directly, so the handler and the charge both run on this
// thread: `charge()` dispatches to strand_, and boost's strand
// `dispatch` runs the function inline when the caller is not already
// in the strand and the strand is idle. The capture in the override
// happens before that hop regardless, so the observation is
// deterministic either way.
peer->runProcessGetObjectByHash(createUnstoredRequest(requested));
auto reply = parseReply(peer);
BEAST_EXPECT(reply.has_value());
if (!reply)
return;
BEAST_EXPECT(reply->objects_size() == 0);
// State: a charge was applied at all.
auto const& applied = peer->getLastAppliedCharge();
BEAST_EXPECT(applied.has_value());
if (!applied)
return;
// Cause: it is exactly the requested-count price. This is the
// assertion the test is named for. Under either plausible
// mis-pricing it reads a different number and therefore fails:
// priced on `iterLimit` (12288) -> 99176
// priced on `found` (0) -> 0, since billable clamps to 0
// and the band drops to Small
BEAST_EXPECT(applied->cost() == 99184);
BEAST_EXPECT(applied->cost() == PeerTest::peekComputeFee(requested, 0).cost());
BEAST_EXPECT(applied->cost() != PeerTest::peekComputeFee(capped, 0).cost());
// Attribution: the charge came from the differential helper, not
// from one of the flat admission or rejection constants.
BEAST_EXPECT(applied->label() == "GetObject differential");
BEAST_EXPECT(peer->getLastChargeContext() == "processed get object by hash request");
// `fee_` is untouched on this path, which is why the override above
// exists rather than a peekFeeCharge() assertion.
BEAST_EXPECT(peer->peekFeeCharge().cost() == Resource::kFeeTrivialPeer.cost());
// Pricing on the requested count is strictly more expensive than
// pricing on the capped count, which is what makes the choice
// observable at all.
BEAST_EXPECT(
PeerTest::peekComputeFee(requested, 0).cost() >
PeerTest::peekComputeFee(capped, 0).cost());
// Exact values for both, so a change to either input is caught.
BEAST_EXPECT(PeerTest::peekComputeFee(requested, 0).cost() == 99184);
BEAST_EXPECT(PeerTest::peekComputeFee(capped, 0).cost() == 99176);
}
void
run() override
{
// NOTE ON METRIC COVERAGE. The five getobject_* instruments are
// recorded through the XRPL_METRIC_* macros, which push into the
// OpenTelemetry SDK. That API is write-only by design -- there is no
// read-back accessor and no in-memory metric reader in this build --
// and a default jtx::Env leaves telemetry disabled, so the macros do
// not execute at all here. These tests therefore assert the
// observable behaviour of each instrumented code path, which pins
// the values the instruments are fed:
// getobject_request_objects <- the request's objects_size()
// getobject_lookups_total <- reply size (hits) and the derived
// miss count, per testHitMissSplit
// getobject_charge <- the applied Resource::Charge,
// captured by PeerTest::charge()
// getobject_rejected_total <- the two gates' exact fee_ values
// plus "no charge was applied"
// Only getobject_lookup_us has no in-process witness, being a wall
// clock reading. The counter and histogram values themselves remain
// unverified by unit test and are checked live against Prometheus
// per the design's live-validation step.
int const limit = static_cast<int>(Tuning::kHardMaxReplyNodes);
testReplyLimit(limit + 1, limit);
testReplyLimit(limit, limit);
testReplyLimit(limit - 1, limit - 1);
testOversizeRejection();
testAtLimitNotRejected();
testMalformedLedgerHashRejection(uint256::size() - 1);
testMalformedLedgerHashRejection(uint256::size() + 1);
testMalformedLedgerHashRejection(0);
testWellFormedLedgerHashAccepted();
testHitMissSplit(5, 3);
testHitMissSplit(0, 4);
testHitMissSplit(4, 0);
testComputeFeeExactValues();
testChargeUsesRequestedCount();
}
};