fix: Bound and offload per-connection subscription cleanup

This commit is contained in:
Bart
2026-07-16 16:24:06 -04:00
committed by Ayaz Salikhov
parent 5ab95748d4
commit 1dcaf4b54e
12 changed files with 1301 additions and 214 deletions

View File

@@ -25,6 +25,7 @@ struct Sections
static constexpr auto kLedgerHistory = "ledger_history";
static constexpr auto kLedgerReplay = "ledger_replay";
static constexpr auto kLedgerTxTables = "ledger_tx_tables";
static constexpr auto kMaxSubscriptionsPerConnection = "max_subscriptions_per_connection";
static constexpr auto kMaxTransactions = "max_transactions";
static constexpr auto kNetworkId = "network_id";
static constexpr auto kNetworkQuorum = "network_quorum";

View File

@@ -11,6 +11,7 @@
#include <xrpl/server/Manifest.h>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
@@ -22,6 +23,39 @@ namespace xrpl {
// Operations that clients may wish to perform against the network
// Master operational handler, server sequencer, network tracker
/**
* Maximum number of subscriptions a single client connection may hold at once.
*
* Applies to the account, real-time account, and account-history subscriptions
* tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding
* the disconnect-time cleanup of those sets. Book subscriptions are tracked
* separately (OrderBookDB) and are not counted here. Generous enough for
* legitimate power users such as block explorers.
*/
constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000;
/**
* Whether adding @p additional subscriptions to a connection already holding
* @p current would exceed the cap.
*
* Pure arithmetic split out so it can be unit-tested without a live
* connection. The first term avoids underflow in the subtraction.
*
* @param current Subscriptions already tracked on the connection.
* @param additional Subscriptions a request would add.
* @param cap The effective per-connection cap. Defaults to the
* built-in limit; callers may pass a configured override.
* @return true if the request must be rejected to stay within the cap.
*/
[[nodiscard]] constexpr bool
exceedsSubscriptionCap(
std::size_t current,
std::size_t additional,
std::size_t cap = kMaxSubscriptionsPerConnection)
{
return additional > cap || current > cap - additional;
}
class InfoSubRequest : public CountedObject<InfoSubRequest>
{
public:
@@ -44,12 +78,12 @@ public:
* map.
*
* @note Lifetime contract: every `InfoSub` instance MUST be destroyed
* before the backing `Source`. NetworkOPsImp shutdown drops all
* subscriber strong refs before its own teardown to satisfy this.
* before the backing `Source`. NetworkOPsImp shutdown drops all
* subscriber strong refs before its own teardown to satisfy this.
* @note Thread-safety: per-instance state is guarded by `lock_`. The
* destructor reads tracking sets without taking `lock_` because
* the strong-pointer ref-count is zero at destruction time, so
* no other thread can be calling the public mutators.
* destructor reads tracking sets without taking `lock_` because
* the strong-pointer ref-count is zero at destruction time, so
* no other thread can be calling the public mutators.
*/
class InfoSub : public CountedObject<InfoSub>
{
@@ -117,6 +151,34 @@ public:
AccountID const& account,
bool historyOnly) = 0;
/**
* Schedule the server-side teardown of a disconnecting connection's
* account subscriptions off the destructor thread.
*
* The implementation posts a low-priority JobQueue task that erases the
* entries in bounded chunks, so `~InfoSub` returns immediately instead
* of running the erase loop inline. The sets are taken by value so the
* job owns its copies and never references the destroyed `InfoSub`.
* Cleanup is keyed on `seq` (unique per connection), so deferring it
* cannot disturb a reconnected client reusing the same accounts.
*
* @param seq The disconnecting connection's unique subscription id.
* @param rtAccounts Real-time account subscriptions to remove.
* @param normalAccounts Normal account subscriptions to remove.
* @param historyAccounts Account-history subscriptions to remove.
*
* @note The implementing `Source` must outlive any job it posts. If the
* JobQueue is already stopping (process shutdown), the job is not
* enqueued; the cleanup is skipped because the server-side maps
* are about to be destroyed and no publishing can run.
*/
virtual void
scheduleAccountCleanup(
std::uint64_t seq,
hash_set<AccountID> rtAccounts,
hash_set<AccountID> normalAccounts,
hash_set<AccountID> historyAccounts) = 0;
// VFALCO TODO Document the bool return value
virtual bool
subLedger(ref ispListener, json::Value& jvResult) = 0;
@@ -153,12 +215,12 @@ public:
* @param ispListener The subscriber requesting removal.
* @param book The order book to unsubscribe from.
* @return true if the entry was present and removed, false if the
* subscriber was not subscribed to @p book.
* subscriber was not subscribed to @p book.
*
* @note Thread-safety: acquires subLock_ internally.
* @note Thread-safety: acquires bookLock_ internally.
* @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead
* to avoid a redundant write-back to bookSubscriptions_ on a
* partially-destroyed object.
* to avoid a redundant write-back to bookSubscriptions_ on a
* partially-destroyed object.
*/
virtual bool
unsubBook(ref ispListener, Book const&) = 0;
@@ -173,9 +235,9 @@ public:
* @param uListener The sequence number of the subscriber being torn down.
* @param book The order book entry to remove.
* @return true if the entry was present and removed, false otherwise
* (e.g., already removed by a concurrent RPC unsubscribe).
* (e.g., already removed by a concurrent RPC unsubscribe).
*
* @note Thread-safety: acquires subLock_ internally.
* @note Thread-safety: acquires bookLock_ internally.
*/
virtual bool
unsubBookInternal(std::uint64_t uListener, Book const&) = 0;
@@ -221,8 +283,8 @@ public:
/**
* Journal used by InfoSub for diagnostics that occur after the
* owning subsystem (e.g. application-level Logs) is the only
* surviving sink — primarily destructor-time cleanup failures.
* owning subsystem (e.g. application-level Logs) is the only
* surviving sink — primarily destructor-time cleanup failures.
*/
[[nodiscard]] virtual beast::Journal const&
journal() const = 0;
@@ -243,6 +305,56 @@ public:
[[nodiscard]] std::uint64_t
getSeq() const;
/**
* Return the number of subscriptions currently tracked on this
* connection.
*
* The combined size of the per-connection account, real-time account, and
* account-history subscription sets. `doSubscribe` reads this to enforce
* the per-connection subscription cap before admitting more.
*
* @return The total tracked subscription count for this connection.
*
* @note Thread-safe: takes `lock_` for the read; read-only.
*/
[[nodiscard]] std::size_t
totalSubscriptionCount() const;
/**
* Enforce the cap and reserve a request's net-new accounts, atomically.
*
* Under one hold of `lock_`: count the net-new entries in the two sets,
* check the total against @p cap, and insert them only if it fits.
* All-or-nothing. Doing check and insert together stops two concurrent
* requests sharing an InfoSub (the admin subscribe-by-url path) from both
* passing the check before either records its accounts. The server-side
* maps are populated afterwards by subAccount, whose re-insert is a no-op.
*
* @param proposedAccounts Real-time (accounts_proposed) ids to reserve.
* @param normalAccounts Normal (accounts) ids to reserve.
* @param cap The effective per-connection cap.
* @return true if reserved; false if the request must be rejected.
* @note Thread-safe: takes `lock_`.
*/
[[nodiscard]] bool
tryReserveAccountSubscriptions(
hash_set<AccountID> const& proposedAccounts,
hash_set<AccountID> const& normalAccounts,
std::size_t cap);
/**
* Whether this connection already tracks an account-history for @p account.
*
* `doSubscribe` reads this to charge the cap for an account_history_tx_stream
* only when it is net-new, matching the account branches.
*
* @param account The account an account_history_tx_stream would add.
* @return true if @p account is already in the account-history set.
* @note Thread-safe: takes `lock_`; read-only.
*/
[[nodiscard]] bool
hasAccountHistorySubscription(AccountID const& account) const;
void
onSendEmpty();
@@ -302,7 +414,9 @@ public:
getApiVersion() const noexcept;
protected:
std::mutex lock_;
// Mutable so the read-only totalSubscriptionCount() accessor can lock it
// from a const method; locking semantics are otherwise unchanged.
mutable std::mutex lock_;
private:
Consumer consumer_;