mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-22 06:40:53 +00:00
Sync-critical job types run at very low concurrency limits (ledgerRequest
and ledgerData allow 3 each), so a node can stall simply because those
jobs are held back behind other work. Nothing exposed that until now:
the existing job metrics are rates and quantiles of jobs that already
moved, or a single queue-wide depth.
- jobq_backlog{metric,job_type}: instantaneous waiting, running and
deferred counts per job type. Deferred is the starvation signal and had
no exposure anywhere; it is set when a type is at its concurrency limit.
- jobq_saturation{metric}: running tasks, worker-thread count and total
waiting, so a slowdown spanning several subsystems can be attributed to
worker-pool exhaustion instead of being diagnosed once per victim.
Both read through two new const accessors on JobQueue that take the
existing mutex once and copy integers, so a single reading is internally
consistent and no per-job cost is added. The job_type label reuses the
same JobTypes name helper the existing job counters use, so the two label
sets join.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
551 lines
17 KiB
C++
551 lines
17 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>
|
|
#include <vector>
|
|
|
|
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;
|
|
|
|
/**
|
|
* Occupancy snapshot for a single JobType.
|
|
*
|
|
* A plain value type so it can cross the libxrpl/xrpld boundary: xrpld
|
|
* telemetry observes queue occupancy without libxrpl gaining any
|
|
* dependency on the telemetry code.
|
|
*
|
|
* `deferred` is the field with no other exposure anywhere. The
|
|
* sync-critical types run at tiny concurrency limits (`JtLedgerReq` and
|
|
* `JtLedgerData` are capped at 3 in JobTypes.h), so a job of those types
|
|
* is commonly held back rather than merely queued, and a held-back job is
|
|
* invisible in `waiting` and `running` alike.
|
|
*/
|
|
struct JobTypeCount
|
|
{
|
|
/**
|
|
* The job type these counts describe. The caller turns this into a
|
|
* label via JobTypes::name(), so the name is not duplicated here.
|
|
*/
|
|
JobType type{JtInvalid};
|
|
|
|
/**
|
|
* Jobs enqueued and not yet dispatched to a worker thread.
|
|
*/
|
|
int waiting{0};
|
|
|
|
/**
|
|
* Jobs currently executing on a worker thread.
|
|
*/
|
|
int running{0};
|
|
|
|
/**
|
|
* Jobs held back because this type is already at its concurrency
|
|
* limit. A non-zero value means work of this type exists and is
|
|
* being denied a worker: starvation, not idleness.
|
|
*/
|
|
int deferred{0};
|
|
};
|
|
|
|
/**
|
|
* Snapshot the occupancy of every registered job type.
|
|
*
|
|
* One mutex acquire copies three integers per type, which is the same
|
|
* lock and the same fields getJobCount() already reads — the counting
|
|
* logic is not duplicated, only batched, so the caller does not have to
|
|
* take the lock once per type to build a full picture.
|
|
*
|
|
* @return One JobTypeCount per registered JobType, in JobType order.
|
|
*
|
|
* @note Thread-safe; takes the internal mutex briefly. The values are a
|
|
* point-in-time reading and are mutually consistent with each other
|
|
* because they come from one acquire.
|
|
* @note Intended for a periodic observer (the telemetry reader ticks
|
|
* every ~10 s). It is not free enough to call from a hot path.
|
|
*/
|
|
[[nodiscard]] std::vector<JobTypeCount>
|
|
getJobTypeCounts() const;
|
|
|
|
/**
|
|
* Worker-pool saturation reading: work in flight against capacity.
|
|
*
|
|
* Answers "is the whole pool exhausted?" in one place. Without it, a
|
|
* pool-wide slowdown shows up separately in every subsystem whose jobs
|
|
* are queued behind it, and each one looks like its own fault.
|
|
*/
|
|
struct WorkerSaturation
|
|
{
|
|
/**
|
|
* Calls to processTask() executing right now across all job types.
|
|
*/
|
|
int runningTasks{0};
|
|
|
|
/**
|
|
* Worker threads the pool is configured to run — the ceiling
|
|
* `runningTasks` is measured against.
|
|
*/
|
|
int workerThreads{0};
|
|
|
|
/**
|
|
* Jobs queued and not yet dispatched, summed over all job types.
|
|
*/
|
|
int totalWaiting{0};
|
|
};
|
|
|
|
/**
|
|
* Read the global worker-pool saturation.
|
|
*
|
|
* All three fields are produced under a single mutex acquire so the
|
|
* running/threads ratio and the backlog describe the same instant; read
|
|
* separately they could disagree and imply a saturation that never
|
|
* existed.
|
|
*
|
|
* @return The current saturation reading.
|
|
*
|
|
* @note Thread-safe with respect to the queue counters, which are read
|
|
* under the internal mutex. The configured thread count is a plain int
|
|
* that only changes at pool construction and at stop(); telemetry never
|
|
* races that write, because Application detaches the metric callbacks
|
|
* before it stops the JobQueue.
|
|
* @note Intended for a periodic observer, not a hot path.
|
|
*/
|
|
[[nodiscard]] WorkerSaturation
|
|
getWorkerSaturation() 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:
|
|
// <none>
|
|
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
|