Files
rippled/include/xrpl/core/JobQueue.h
Pratik Mankawde 4115617eb9 feat(telemetry): add job-queue occupancy and saturation gauges (WP-A4)
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>
2026-07-25 13:46:03 +01:00

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