mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-26 08:30:30 +00:00
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>
448 lines
14 KiB
C++
448 lines
14 KiB
C++
#pragma once
|
|
|
|
#include <xrpl/basics/LocalValue.h>
|
|
#include <xrpl/core/ClosureCounter.h>
|
|
#include <xrpl/core/JobTypeData.h>
|
|
#include <xrpl/core/detail/Workers.h>
|
|
#include <xrpl/json/json_value.h>
|
|
|
|
// Include only the specific Boost.Coroutine2 headers actually used here.
|
|
// Avoid `boost/coroutine2/all.hpp` because it transitively pulls in
|
|
// `boost/context/pooled_fixedsize_stack.hpp`, whose `.malloc()` / `.free()`
|
|
// member calls on `boost::pool` collide with MSVC's `_CRTDBG_MAP_ALLOC` macros
|
|
// in Debug builds (see cmake/XrplCompiler.cmake).
|
|
#include <xrpl/beast/insight/Collector.h>
|
|
#include <xrpl/beast/insight/Gauge.h>
|
|
#include <xrpl/beast/insight/Hook.h>
|
|
#include <xrpl/beast/utility/Journal.h>
|
|
#include <xrpl/core/Job.h>
|
|
#include <xrpl/core/LoadEvent.h>
|
|
|
|
#include <boost/context/protected_fixedsize_stack.hpp>
|
|
#include <boost/coroutine2/coroutine.hpp>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <set>
|
|
#include <string>
|
|
#include <type_traits>
|
|
|
|
namespace xrpl {
|
|
|
|
namespace perf {
|
|
class PerfLog;
|
|
} // namespace perf
|
|
|
|
class Logs;
|
|
struct CoroCreateT
|
|
{
|
|
explicit CoroCreateT() = default;
|
|
};
|
|
|
|
/**
|
|
* A pool of threads to perform work.
|
|
*
|
|
* A job posted will always run to completion.
|
|
*
|
|
* Coroutines that are suspended must be resumed,
|
|
* and run to completion.
|
|
*
|
|
* When the JobQueue stops, it waits for all jobs
|
|
* and coroutines to finish.
|
|
*/
|
|
class JobQueue : private Workers::Callback
|
|
{
|
|
public:
|
|
/**
|
|
* Coroutines must run to completion.
|
|
*/
|
|
class Coro : public std::enable_shared_from_this<Coro>
|
|
{
|
|
private:
|
|
detail::LocalValues lvs_;
|
|
JobQueue& jq_;
|
|
JobType type_;
|
|
std::string name_;
|
|
bool running_{false};
|
|
std::mutex mutex_;
|
|
std::mutex mutexRun_;
|
|
std::condition_variable cv_;
|
|
boost::coroutines2::coroutine<void>::push_type* yield_{};
|
|
boost::coroutines2::coroutine<void>::pull_type coro_;
|
|
#ifndef NDEBUG
|
|
bool finished_ = false;
|
|
#endif
|
|
|
|
public:
|
|
template <class F>
|
|
Coro(CoroCreateT, JobQueue&, JobType, std::string, F&&);
|
|
|
|
// Not copy-constructible or assignable
|
|
Coro(Coro const&) = delete;
|
|
Coro&
|
|
operator=(Coro const&) = delete;
|
|
|
|
~Coro();
|
|
|
|
/**
|
|
* Suspend coroutine execution.
|
|
* Effects:
|
|
* The coroutine's stack is saved.
|
|
* The associated Job thread is released.
|
|
* Note:
|
|
* The associated Job function returns.
|
|
* Undefined behavior if called consecutively without a corresponding
|
|
* post.
|
|
*/
|
|
void
|
|
yield() const;
|
|
|
|
/**
|
|
* Schedule coroutine execution.
|
|
* Effects:
|
|
* Returns immediately.
|
|
* A new job is scheduled to resume the execution of the coroutine.
|
|
* When the job runs, the coroutine's stack is restored and execution
|
|
* continues at the beginning of coroutine function or the
|
|
* statement after the previous call to yield. Undefined behavior if
|
|
* called after the coroutine has completed with a return (as opposed to
|
|
* a yield()). Undefined behavior if post() or resume() called
|
|
* consecutively without a corresponding yield.
|
|
*
|
|
* @return true if the Coro's job is added to the JobQueue.
|
|
*/
|
|
bool
|
|
post();
|
|
|
|
/**
|
|
* Resume coroutine execution.
|
|
* Effects:
|
|
* The coroutine continues execution from where it last left off
|
|
* using this same thread.
|
|
* If the coroutine has already completed, returns immediately
|
|
* (handles the documented post-before-yield race condition).
|
|
* Undefined behavior if resume() or post() called consecutively
|
|
* without a corresponding yield.
|
|
*/
|
|
void
|
|
resume();
|
|
|
|
/**
|
|
* Returns true if the Coro is still runnable (has not returned).
|
|
*/
|
|
[[nodiscard]] bool
|
|
runnable() const;
|
|
|
|
/**
|
|
* Once called, the Coro allows early exit without an assert.
|
|
*/
|
|
void
|
|
expectEarlyExit();
|
|
|
|
/**
|
|
* Waits until coroutine returns from the user function.
|
|
*/
|
|
void
|
|
join();
|
|
};
|
|
|
|
using JobFunction = std::function<void()>;
|
|
|
|
JobQueue(
|
|
int threadCount,
|
|
beast::insight::Collector::ptr const& collector,
|
|
beast::Journal journal,
|
|
Logs& logs,
|
|
perf::PerfLog& perfLog);
|
|
~JobQueue() override;
|
|
|
|
/**
|
|
* Adds a job to the JobQueue.
|
|
*
|
|
* @param type The type of job.
|
|
* @param name Name of the job.
|
|
* @param jobHandler Callable with signature void(). Called when the job is executed.
|
|
*
|
|
* @return true if jobHandler added to queue.
|
|
*/
|
|
template <typename JobHandler>
|
|
bool
|
|
addJob(JobType type, std::string const& name, JobHandler&& jobHandler)
|
|
requires(std::is_void_v<std::invoke_result_t<JobHandler>>)
|
|
{
|
|
if (auto optionalCountedJob = jobCounter_.wrap(std::forward<JobHandler>(jobHandler)))
|
|
{
|
|
return addRefCountedJob(type, name, std::move(*optionalCountedJob));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Creates a coroutine and adds a job to the queue which will run it.
|
|
*
|
|
* @param t The type of job.
|
|
* @param name Name of the job.
|
|
* @param f Has a signature of void(std::shared_ptr<Coro>). Called when the
|
|
* job executes.
|
|
*
|
|
* @return shared_ptr to posted Coro. nullptr if post was not successful.
|
|
*/
|
|
template <class F>
|
|
std::shared_ptr<Coro>
|
|
postCoro(JobType t, std::string const& name, F&& f);
|
|
|
|
/**
|
|
* Jobs waiting at this priority.
|
|
*/
|
|
int
|
|
getJobCount(JobType t) const;
|
|
|
|
/**
|
|
* Jobs waiting plus running at this priority.
|
|
*/
|
|
int
|
|
getJobCountTotal(JobType t) const;
|
|
|
|
/**
|
|
* All waiting jobs at or greater than this priority.
|
|
*/
|
|
int
|
|
getJobCountGE(JobType t) const;
|
|
|
|
/**
|
|
* Return a scoped LoadEvent.
|
|
*/
|
|
std::unique_ptr<LoadEvent>
|
|
makeLoadEvent(JobType t, std::string const& name);
|
|
|
|
/**
|
|
* Add multiple load events.
|
|
*/
|
|
void
|
|
addLoadEvents(JobType t, int count, std::chrono::milliseconds elapsed);
|
|
|
|
// Cannot be const because LoadMonitor has no const methods.
|
|
bool
|
|
isOverloaded();
|
|
|
|
// Cannot be const because LoadMonitor has no const methods.
|
|
json::Value
|
|
getJson(int c = 0);
|
|
|
|
/**
|
|
* Block until no jobs running.
|
|
*/
|
|
void
|
|
rendezvous();
|
|
|
|
void
|
|
stop();
|
|
|
|
bool
|
|
isStopping() const
|
|
{
|
|
return stopping_;
|
|
}
|
|
|
|
// We may be able to move away from this, but we can keep it during the
|
|
// transition.
|
|
bool
|
|
isStopped() const;
|
|
|
|
private:
|
|
friend class Coro;
|
|
|
|
using JobDataMap = std::map<JobType, JobTypeData>;
|
|
|
|
beast::Journal journal_;
|
|
mutable std::mutex mutex_;
|
|
std::uint64_t lastJob_{0};
|
|
std::set<Job> jobSet_;
|
|
JobCounter jobCounter_;
|
|
std::atomic_bool stopping_{false};
|
|
std::atomic_bool stopped_{false};
|
|
JobDataMap jobData_;
|
|
JobTypeData invalidJobData_;
|
|
|
|
// The number of jobs currently in processTask()
|
|
int processCount_{0};
|
|
|
|
// The number of suspended coroutines
|
|
int nSuspend_ = 0;
|
|
|
|
Workers workers_;
|
|
|
|
// Statistics tracking
|
|
perf::PerfLog& perfLog_;
|
|
beast::insight::Collector::ptr collector_;
|
|
beast::insight::Gauge jobCount_;
|
|
beast::insight::Hook hook_;
|
|
|
|
std::condition_variable cv_;
|
|
|
|
void
|
|
collect();
|
|
JobTypeData&
|
|
getJobTypeData(JobType type);
|
|
|
|
// Adds a reference counted job to the JobQueue.
|
|
//
|
|
// param type The type of job.
|
|
// param name Name of the job.
|
|
// param func std::function with signature void (Job&). Called when the
|
|
// job is executed.
|
|
//
|
|
// return true if func added to queue.
|
|
bool
|
|
addRefCountedJob(JobType type, std::string const& name, JobFunction const& func);
|
|
|
|
// Returns the next Job we should run now.
|
|
//
|
|
// RunnableJob:
|
|
// A Job in the JobSet whose slots count for its type is greater than zero.
|
|
//
|
|
// Pre-conditions:
|
|
// jobSet_ must not be empty.
|
|
// jobSet_ holds at least one RunnableJob
|
|
//
|
|
// Post-conditions:
|
|
// job is a valid Job object.
|
|
// job is removed from jobQueue_.
|
|
// Waiting job count of its type is decremented
|
|
// Running job count of its type is incremented
|
|
//
|
|
// Invariants:
|
|
// The calling thread owns the JobLock
|
|
void
|
|
getNextJob(Job& job);
|
|
|
|
// Indicates that a running Job has completed its task.
|
|
//
|
|
// Pre-conditions:
|
|
// Job must not exist in jobSet_.
|
|
// The JobType must not be invalid.
|
|
//
|
|
// Post-conditions:
|
|
// The running count of that JobType is decremented
|
|
// A new task is signaled if there are more waiting Jobs than the limit, if
|
|
// any.
|
|
//
|
|
// Invariants:
|
|
// The calling thread owns the JobLock. This function mutates the
|
|
// deferred and running counts, which mutex_ guards; collect() reads
|
|
// them under the same lock.
|
|
void
|
|
finishJob(JobType type);
|
|
|
|
// Runs the next appropriate waiting Job.
|
|
//
|
|
// Pre-conditions:
|
|
// A RunnableJob must exist in the JobSet
|
|
//
|
|
// Post-conditions:
|
|
// The chosen RunnableJob will have Job::doJob() called.
|
|
//
|
|
// Invariants:
|
|
// <none>
|
|
void
|
|
processTask(int instance) override;
|
|
|
|
// Returns the limit of running jobs for the given job type.
|
|
// For jobs with no limit, we return the largest int. Hopefully that
|
|
// will be enough.
|
|
static int
|
|
getJobLimit(JobType type);
|
|
};
|
|
|
|
/*
|
|
An RPC command is received and is handled via ServerHandler(HTTP) or
|
|
Handler(websocket), depending on the connection type. The handler then calls
|
|
the JobQueue::postCoro() method to create a coroutine and run it at a later
|
|
point. This frees up the handler thread and allows it to continue handling
|
|
other requests while the RPC command completes its work asynchronously.
|
|
|
|
postCoro() creates a Coro object. When the Coro ctor is called, and its
|
|
coro_ member is initialized (a boost::coroutines::pull_type), execution
|
|
automatically passes to the coroutine, which we don't want at this point,
|
|
since we are still in the handler thread context. It's important to note
|
|
here that construction of a boost pull_type automatically passes execution to
|
|
the coroutine. A pull_type object automatically generates a push_type that is
|
|
passed as a parameter (do_yield) in the signature of the function the
|
|
pull_type was created with. This function is immediately called during coro_
|
|
construction and within it, Coro::yield_ is assigned the push_type
|
|
parameter (do_yield) address and called (yield()) so we can return execution
|
|
back to the caller's stack.
|
|
|
|
postCoro() then calls Coro::post(), which schedules a job on the job
|
|
queue to continue execution of the coroutine in a JobQueue worker thread at
|
|
some later time. When the job runs, we lock on the Coro::mutex_ and call
|
|
coro_ which continues where we had left off. Since we the last thing we did
|
|
in coro_ was call yield(), the next thing we continue with is calling the
|
|
function param f, that was passed into Coro ctor. It is within this
|
|
function body that the caller specifies what he would like to do while
|
|
running in the coroutine and allow them to suspend and resume execution.
|
|
A task that relies on other events to complete, such as path finding, calls
|
|
Coro::yield() to suspend its execution while waiting on those events to
|
|
complete and continue when signaled via the Coro::post() method.
|
|
|
|
There is a potential race condition that exists here where post() can get
|
|
called before yield() after f is called. Technically the problem only occurs
|
|
if the job that post() scheduled is executed before yield() is called.
|
|
If the post() job were to be executed before yield(), undefined behavior
|
|
would occur. The lock ensures that coro_ is not called again until we exit
|
|
the coroutine. At which point a scheduled resume() job waiting on the lock
|
|
would gain entry. resume() checks if the coroutine has already completed
|
|
(coro_ converts to false) and, if so, skips invoking operator() since
|
|
calling operator() on a completed boost::coroutine2 pull_type is undefined
|
|
behavior.
|
|
|
|
The race condition occurs as follows:
|
|
|
|
1- The coroutine is running.
|
|
2- The coroutine is about to suspend, but before it can do so, it must
|
|
arrange for some event to wake it up.
|
|
3- The coroutine arranges for some event to wake it up.
|
|
4- Before the coroutine can suspend, that event occurs and the
|
|
resumption of the coroutine is scheduled on the job queue. 5- Again, before
|
|
the coroutine can suspend, the resumption of the coroutine is dispatched. 6-
|
|
Again, before the coroutine can suspend, the resumption code runs the
|
|
coroutine.
|
|
The coroutine is now running in two threads.
|
|
|
|
The lock prevents this from happening as step 6 will block until the
|
|
lock is released which only happens after the coroutine completes.
|
|
*/
|
|
|
|
} // namespace xrpl
|
|
|
|
#include <xrpl/core/Coro.ipp> // IWYU pragma: keep
|
|
|
|
namespace xrpl {
|
|
|
|
template <class F>
|
|
std::shared_ptr<JobQueue::Coro>
|
|
JobQueue::postCoro(JobType t, std::string const& name, F&& f)
|
|
{
|
|
/* First param is a detail type to make construction private.
|
|
Last param is the function the coroutine runs. Signature of
|
|
void(std::shared_ptr<Coro>).
|
|
*/
|
|
auto coro = std::make_shared<Coro>(CoroCreateT{}, *this, t, name, std::forward<F>(f));
|
|
if (!coro->post())
|
|
{
|
|
// The Coro was not successfully posted. Disable it so it's destructor
|
|
// can run with no negative side effects. Then destroy it.
|
|
coro->expectEarlyExit();
|
|
coro.reset();
|
|
}
|
|
return coro;
|
|
}
|
|
|
|
} // namespace xrpl
|