mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 06:10:58 +00:00
fix: Bound and offload per-connection subscription cleanup
This commit is contained in:
@@ -488,6 +488,17 @@
|
||||
# Must be a number between 100 and 1000, defaults to 250
|
||||
#
|
||||
#
|
||||
# [max_subscriptions_per_connection]
|
||||
#
|
||||
# Maximum number of account, real-time account, and account-history
|
||||
# subscriptions a single client connection may hold at once. Bounds the
|
||||
# per-connection state torn down when the connection disconnects. Book
|
||||
# subscriptions are tracked separately and are not counted here.
|
||||
#
|
||||
# Defaults to 100000 if not set; large enough for legitimate power users
|
||||
# such as block explorers.
|
||||
#
|
||||
#
|
||||
# [overlay]
|
||||
#
|
||||
# Controls settings related to the peer to peer overlay.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
#include <xrpl/protocol/Book.h>
|
||||
#include <xrpl/resource/Consumer.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
@@ -64,6 +66,9 @@ InfoSub::InfoSub(Source& source, Consumer consumer)
|
||||
|
||||
InfoSub::~InfoSub()
|
||||
{
|
||||
// Stream unsubscribes are O(1): each erases this connection's single seq_
|
||||
// from one stream map, so they are cheap enough to run inline on the
|
||||
// disconnect thread.
|
||||
// Each Source teardown call below acquires a server-side lock and
|
||||
// can throw. Wrap each independent call so partial failure does not
|
||||
// skip the remaining teardown steps.
|
||||
@@ -79,29 +84,48 @@ InfoSub::~InfoSub()
|
||||
safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j);
|
||||
safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j);
|
||||
|
||||
// Use the internal unsubscribe so that it won't call
|
||||
// back to us and modify its own parameter
|
||||
if (!realTimeSubscriptions_.empty())
|
||||
{
|
||||
safeUnsub(
|
||||
seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j);
|
||||
}
|
||||
|
||||
if (!normalSubscriptions_.empty())
|
||||
{
|
||||
safeUnsub(
|
||||
seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j);
|
||||
}
|
||||
|
||||
for (auto const& account : accountHistorySubscriptions_)
|
||||
{
|
||||
safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j);
|
||||
}
|
||||
|
||||
// Book subscriptions are torn down inline here, keyed on seq_, rather than
|
||||
// through the chunked account cleanup below. The book set is not capped, so
|
||||
// it can be large; but each unsubBookInternal takes bookLock_ for a single
|
||||
// O(1) erase and releases it, so even a large set never holds a lock across
|
||||
// the whole loop - a competing book publish can interleave between erases.
|
||||
// The disconnect thread still does O(N) brief acquisitions. Use the internal
|
||||
// variant so it does not write back to bookSubscriptions_ on this
|
||||
// partially-destroyed object.
|
||||
for (auto const& book : bookSubscriptions_)
|
||||
{
|
||||
safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j);
|
||||
}
|
||||
|
||||
// Hand the account sets off (by move) to the Source for a chunked,
|
||||
// off-thread teardown keyed on seq_, instead of erasing them inline here.
|
||||
// This keeps the destructor from holding the account lock across a large
|
||||
// erase loop. The job never references this object, which is being
|
||||
// destroyed.
|
||||
//
|
||||
// Moving the sets without holding lock_ is safe: the destructor runs only
|
||||
// when the last shared_ptr to this InfoSub is released, so by the
|
||||
// shared_ptr contract no other thread holds a reference. Subscription maps
|
||||
// store weak_ptrs, so a concurrent publisher must weak_ptr::lock() first;
|
||||
// that succeeds only while a strong reference exists, which cannot overlap
|
||||
// with destruction. No other thread can observe the moved-from sets.
|
||||
//
|
||||
// Wrapped like the steps above: scheduleAccountCleanup enqueues a JobQueue
|
||||
// task, which allocates and locks and so can throw. A throw out of this
|
||||
// noexcept destructor would terminate the process. Skipping the cleanup on
|
||||
// throw is harmless: the account/rt maps hold weak_ptrs that the next
|
||||
// publish prunes once this InfoSub is gone, and any history paging job
|
||||
// self-terminates when its weak sink can no longer be locked.
|
||||
safeUnsub(
|
||||
seq_,
|
||||
[&] {
|
||||
source_.scheduleAccountCleanup(
|
||||
seq_,
|
||||
std::move(realTimeSubscriptions_),
|
||||
std::move(normalSubscriptions_),
|
||||
std::move(accountHistorySubscriptions_));
|
||||
},
|
||||
j);
|
||||
}
|
||||
|
||||
Resource::Consumer&
|
||||
@@ -121,6 +145,53 @@ InfoSub::onSendEmpty()
|
||||
{
|
||||
}
|
||||
|
||||
std::size_t
|
||||
InfoSub::totalSubscriptionCount() const
|
||||
{
|
||||
// Hold lock_ for the whole read so the three sets cannot be mutated
|
||||
// mid-count by a concurrent (un)subscribe on this connection.
|
||||
std::scoped_lock const sl(lock_);
|
||||
|
||||
// Combined tally the per-connection cap is enforced against.
|
||||
return normalSubscriptions_.size() + realTimeSubscriptions_.size() +
|
||||
accountHistorySubscriptions_.size();
|
||||
}
|
||||
|
||||
bool
|
||||
InfoSub::tryReserveAccountSubscriptions(
|
||||
hash_set<AccountID> const& proposedAccounts,
|
||||
hash_set<AccountID> const& normalAccounts,
|
||||
std::size_t cap)
|
||||
{
|
||||
// One lock hold covers the count, the check and the insert.
|
||||
std::scoped_lock const sl(lock_);
|
||||
|
||||
// Entries not already tracked; re-subscribing held accounts is not charged.
|
||||
auto const countNew = [](hash_set<AccountID> const& requested,
|
||||
hash_set<AccountID> const& existing) {
|
||||
std::size_t fresh = 0;
|
||||
for (auto const& account : requested)
|
||||
{
|
||||
if (!existing.contains(account))
|
||||
++fresh;
|
||||
}
|
||||
return fresh;
|
||||
};
|
||||
|
||||
std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) +
|
||||
countNew(normalAccounts, normalSubscriptions_);
|
||||
|
||||
std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() +
|
||||
accountHistorySubscriptions_.size();
|
||||
|
||||
if (exceedsSubscriptionCap(current, additional, cap))
|
||||
return false;
|
||||
|
||||
realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end());
|
||||
normalSubscriptions_.insert(normalAccounts.begin(), normalAccounts.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
void
|
||||
InfoSub::insertSubAccountInfo(AccountID const& account, bool rt)
|
||||
{
|
||||
@@ -165,6 +236,13 @@ InfoSub::deleteSubAccountHistory(AccountID const& account)
|
||||
accountHistorySubscriptions_.erase(account);
|
||||
}
|
||||
|
||||
bool
|
||||
InfoSub::hasAccountHistorySubscription(AccountID const& account) const
|
||||
{
|
||||
std::scoped_lock const sl(lock_);
|
||||
return accountHistorySubscriptions_.contains(account);
|
||||
}
|
||||
|
||||
void
|
||||
InfoSub::insertBookSubscription(Book const& book)
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <xrpl/core/NetworkIDService.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/json/to_string.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/KeyType.h>
|
||||
@@ -1548,6 +1549,413 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Subscription limit / teardown verification ----------------------
|
||||
//
|
||||
// The helpers and tests below exercise:
|
||||
// * the per-connection subscription cap + proportional charge enforced
|
||||
// in doSubscribe (Subscribe.cpp), and
|
||||
// * the asynchronous, chunked teardown of a disconnecting connection's
|
||||
// account subscriptions (~InfoSub -> scheduleAccountCleanup -> JobQueue).
|
||||
//
|
||||
// The cap-exceeded error is rpcINVALID_PARAMS with the message "Too many
|
||||
// subscriptions for this connection."; the tests assert that exactly.
|
||||
//
|
||||
// There is no public accessor for the server-side per-connection count, so
|
||||
// the async cleanup is verified behaviorally: publishing still flows to a
|
||||
// live subscriber, rather than by reading a count to zero.
|
||||
|
||||
// Build `count` distinct, valid, base58-encoded account strings cheaply by
|
||||
// incrementing an AccountID. parseAccountIds dedups into a hash_set, so the
|
||||
// strings MUST be distinct for the cap arithmetic to be exact; incrementing
|
||||
// guarantees distinctness without deriving `count` keypairs.
|
||||
static std::vector<std::string>
|
||||
makeAccountStrings(std::size_t count, std::uint32_t seed = 1)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
out.reserve(count);
|
||||
// Start at `seed` so separate calls produce non-overlapping ranges,
|
||||
// letting a test subscribe disjoint batches across requests.
|
||||
AccountID id{static_cast<std::uint64_t>(seed)};
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
{
|
||||
out.push_back(toBase58(id));
|
||||
++id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Append the given account strings as a jss::accounts array onto a fresh
|
||||
// subscribe request object.
|
||||
static json::Value
|
||||
accountsRequest(std::vector<std::string> const& accts)
|
||||
{
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::accounts] = json::ValueType::Array;
|
||||
for (auto const& a : accts)
|
||||
jv[jss::accounts].append(a);
|
||||
return jv;
|
||||
}
|
||||
|
||||
// Append the given account strings as a jss::accounts_proposed array onto a
|
||||
// fresh subscribe request object.
|
||||
static json::Value
|
||||
accountsProposedRequest(std::vector<std::string> const& accts)
|
||||
{
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::accounts_proposed] = json::ValueType::Array;
|
||||
for (auto const& a : accts)
|
||||
jv[jss::accounts_proposed].append(a);
|
||||
return jv;
|
||||
}
|
||||
|
||||
// A single, valid XRP/USD order book request, as one entry of a
|
||||
// jss::books array.
|
||||
static json::Value
|
||||
oneBookRequest()
|
||||
{
|
||||
using namespace jtx;
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::books] = json::ValueType::Array;
|
||||
json::Value& book = jv[jss::books][0u];
|
||||
book[jss::taker_gets] = json::ValueType::Object;
|
||||
book[jss::taker_gets][jss::currency] = "XRP";
|
||||
book[jss::taker_pays] = json::ValueType::Object;
|
||||
book[jss::taker_pays][jss::currency] = "USD";
|
||||
book[jss::taker_pays][jss::issuer] = Account("alice").human();
|
||||
return jv;
|
||||
}
|
||||
|
||||
// A single account_history_tx_stream subscribe request for `acct`.
|
||||
static json::Value
|
||||
accountHistoryRequest(std::string const& acct)
|
||||
{
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::account_history_tx_stream] = json::ValueType::Object;
|
||||
jv[jss::account_history_tx_stream][jss::account] = acct;
|
||||
return jv;
|
||||
}
|
||||
|
||||
// An envconfig modifier that lowers the per-connection subscription cap to
|
||||
// `cap`, so the cap logic in doSubscribe can be driven without subscribing
|
||||
// the production default (100'000) entries. (Env is non-movable, so this
|
||||
// returns the config modifier rather than a ready-made Env.)
|
||||
static auto
|
||||
cappedConfig(std::size_t cap)
|
||||
{
|
||||
return [cap](std::unique_ptr<Config> cfg) {
|
||||
cfg->maxSubscriptionsPerConnection = cap;
|
||||
return jtx::singleThreadIo(std::move(cfg));
|
||||
};
|
||||
}
|
||||
|
||||
void
|
||||
testSubscriptionCapRejects()
|
||||
{
|
||||
// A request that alone exceeds the cap is rejected with the exact
|
||||
// cap error, before any state is recorded. Baseline negative path.
|
||||
testcase("subscription cap rejects an over-cap request");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(5))};
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// Six accounts against a cap of five: rejected.
|
||||
auto const jr =
|
||||
wsc->invoke("subscribe", accountsRequest(makeAccountStrings(6)))[jss::result];
|
||||
BEAST_EXPECT(jr[jss::error] == "invalidParams");
|
||||
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
|
||||
}
|
||||
|
||||
void
|
||||
testReSubscribeNotOvercounted()
|
||||
{
|
||||
// Re-subscribing accounts already held by this connection adds no new
|
||||
// tracked state, so it must be admitted even at the cap. The cap check
|
||||
// must count only NET-NEW accounts, not the raw request size.
|
||||
testcase("re-subscribe at the cap is not over-counted");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(5))};
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// Fill the cap exactly with five distinct accounts.
|
||||
auto const five = makeAccountStrings(5);
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountsRequest(five));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// Re-subscribe the same five: net-new is zero, so it stays within the
|
||||
// cap and must succeed. (Pre-fix this was wrongly rejected.)
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountsRequest(five));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testBooksCapIndependentOfAccounts()
|
||||
{
|
||||
// Book subscriptions are tracked separately (OrderBookDB) and are not
|
||||
// part of totalSubscriptionCount(). An account set at the cap must not
|
||||
// block an unrelated book subscription.
|
||||
testcase("books cap is independent of account count");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(5))};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(10000), alice);
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// Fill the account cap exactly.
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(5)));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// A single book subscription must still be admitted: it does not count
|
||||
// against the account cap. (Pre-fix this was wrongly rejected.)
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", oneBookRequest());
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testMultiFieldNoPartialSubscribe()
|
||||
{
|
||||
// A single request mixing fields must be all-or-nothing: if a later
|
||||
// field trips the cap, an earlier field must NOT have subscribed. The
|
||||
// leak is detected through the cap arithmetic itself - a follow-up
|
||||
// request succeeds only if no state leaked from the rejected one.
|
||||
testcase("multi-field subscribe does not partially subscribe");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(5))};
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// accounts_proposed (3, evaluated first, would subscribe) +
|
||||
// accounts (3): combined 6 exceeds the cap of 5, so the request is
|
||||
// rejected. The proposed branch must not have leaked its 3 entries.
|
||||
json::Value req = accountsProposedRequest(makeAccountStrings(3, 1));
|
||||
for (auto const& a : makeAccountStrings(3, 100))
|
||||
req[jss::accounts].append(a);
|
||||
{
|
||||
auto const jr = wsc->invoke("subscribe", req)[jss::result];
|
||||
BEAST_EXPECT(jr[jss::error] == "invalidParams");
|
||||
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
|
||||
}
|
||||
|
||||
// If the rejected request leaked its 3 proposed subscriptions, the
|
||||
// connection's count is already 3 and this 3-account request would be
|
||||
// rejected (3 + 3 > 5). With no leak the count is 0 and it succeeds.
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3, 200)));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testHistoryReSubscribeNotOvercounted()
|
||||
{
|
||||
// An account_history_tx_stream subscribe is charged against the cap only
|
||||
// when it is net-new, matching the account branches. Re-subscribing an
|
||||
// account-history already held on this connection adds no tracked entry,
|
||||
// so it must NOT be rejected at the cap. The two rejection causes are
|
||||
// told apart by their exact error_message: the cap check yields "Too
|
||||
// many subscriptions for this connection."; a duplicate that gets past
|
||||
// the cap and is rejected downstream by subAccountHistory yields the
|
||||
// generic "Invalid parameters.".
|
||||
testcase("account_history re-subscribe at the cap is not over-counted");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(1))};
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(10000), alice);
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// First account-history subscribe is net-new: charge 1 fills the cap of
|
||||
// 1 exactly, so it is admitted. Positive path.
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// Re-subscribe the same account-history while sitting exactly at the
|
||||
// cap. Net-new is zero, so the cap check must pass; the request is then
|
||||
// rejected by subAccountHistory as a duplicate, NOT by the cap. Proven
|
||||
// by the exact message: it is the duplicate error, not the cap error.
|
||||
// (Pre-fix, the flat charge of 1 made the cap check reject this with the
|
||||
// cap message instead.)
|
||||
{
|
||||
auto const jr =
|
||||
wsc->invoke("subscribe", accountHistoryRequest(alice.human()))[jss::result];
|
||||
BEAST_EXPECT(jr[jss::error] == "invalidParams");
|
||||
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
|
||||
BEAST_EXPECT(jr[jss::error_message] != "Too many subscriptions for this connection.");
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testHistoryCapRejectsNetNew()
|
||||
{
|
||||
// A genuinely net-new account-history subscribe on a connection already
|
||||
// at the cap IS rejected, with the cap error. Negative path, and the
|
||||
// counterpart to testHistoryReSubscribeNotOvercounted: it confirms the
|
||||
// net-new charge still rejects when the entry really is new.
|
||||
testcase("account_history net-new subscribe is rejected at the cap");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, envconfig(cappedConfig(1))};
|
||||
Account const alice{"alice"};
|
||||
Account const bob{"bob"};
|
||||
env.fund(XRP(10000), alice, bob);
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// Fill the cap of 1 with alice's account-history.
|
||||
{
|
||||
auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// A different account-history (bob) is net-new: charge 1 over a cap of 1
|
||||
// already full, so it is rejected with the cap error.
|
||||
{
|
||||
auto const jr =
|
||||
wsc->invoke("subscribe", accountHistoryRequest(bob.human()))[jss::result];
|
||||
BEAST_EXPECT(jr[jss::error] == "invalidParams");
|
||||
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testAsyncTeardownDoesNotStall()
|
||||
{
|
||||
// Test C (core regression): disconnecting a connection with many
|
||||
// account subscriptions must NOT block subsequent operations or
|
||||
// publishing. The teardown is now posted to a JobQueue job
|
||||
// (scheduleAccountCleanup), so it runs off the disconnect thread.
|
||||
testcase("async teardown does not stall publishing");
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
using namespace jtx;
|
||||
Env env{*this, singleThreadIo(envconfig())};
|
||||
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(10000), alice);
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
|
||||
// A second, long-lived subscriber to alice that must keep receiving
|
||||
// publishes after the first connection disconnects.
|
||||
auto wscLive = makeWSClient(env.app().config());
|
||||
{
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::accounts] = json::ValueType::Array;
|
||||
jv[jss::accounts].append(alice.human());
|
||||
auto const r = wscLive->invoke("subscribe", jv);
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// A connection that subscribes to many accounts, then disconnects. A
|
||||
// few thousand entries is enough to be a real teardown while still
|
||||
// running fast in CI.
|
||||
constexpr std::size_t kBulk = 3000;
|
||||
{
|
||||
auto wscBulk = makeWSClient(env.app().config());
|
||||
auto const r =
|
||||
wscBulk->invoke("subscribe", accountsRequest(makeAccountStrings(kBulk, 10)));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
// Destroying the client closes the WS connection, which destroys
|
||||
// the server-side InfoSub and posts the chunked async cleanup job.
|
||||
// WSClient exposes no explicit close(); resetting the owning
|
||||
// unique_ptr is the disconnect path.
|
||||
wscBulk.reset();
|
||||
}
|
||||
|
||||
// Immediately after the disconnect, an unrelated operation completes
|
||||
// promptly (it would block for seconds with inline teardown). This is a
|
||||
// cheap liveness check; the publish assertion below is the real proof.
|
||||
{
|
||||
auto const info = env.app().getOPs().getServerInfo(false, true, false);
|
||||
BEAST_EXPECT(info.isMember(jss::server_state));
|
||||
}
|
||||
|
||||
// The live subscriber still receives a published transaction for alice
|
||||
// within a short timeout, proving account-publishing was not stalled by
|
||||
// the concurrent teardown.
|
||||
{
|
||||
env(pay(env.master, alice, XRP(100)));
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
BEAST_EXPECT(wscLive->findMsg(5s, [&](auto const& jv) {
|
||||
return jv.isMember(jss::transaction) &&
|
||||
jv[jss::transaction][jss::TransactionType] == jss::Payment &&
|
||||
jv[jss::transaction][jss::Destination] == alice.human();
|
||||
}));
|
||||
}
|
||||
|
||||
wscLive->invoke("unsubscribe", accountsRequest({alice.human()}));
|
||||
}
|
||||
|
||||
void
|
||||
testResubscribeAfterDisconnect()
|
||||
{
|
||||
// Test D (Phase 3 correctness): connection A subscribes to account X
|
||||
// and disconnects (async cleanup pending, keyed on A's seq). A new
|
||||
// connection B subscribes to X and MUST still receive publishes for X -
|
||||
// A's deferred, seq-keyed cleanup must not remove B's subscription.
|
||||
testcase("re-subscribe after disconnect still delivers");
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
using namespace jtx;
|
||||
Env env{*this, singleThreadIo(envconfig())};
|
||||
|
||||
Account const alice{"alice"};
|
||||
env.fund(XRP(10000), alice);
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
|
||||
// Connection A subscribes to alice, then disconnects. A also subscribes
|
||||
// to a bulk set so its deferred cleanup is non-trivial and races with B.
|
||||
{
|
||||
auto wscA = makeWSClient(env.app().config());
|
||||
auto bulk = makeAccountStrings(2000, 10);
|
||||
bulk.push_back(alice.human());
|
||||
auto const r = wscA->invoke("subscribe", accountsRequest(bulk));
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
// Disconnect A by destroying its client (no explicit close()).
|
||||
wscA.reset();
|
||||
}
|
||||
|
||||
// Connection B (a new InfoSub with a distinct seq) subscribes to alice.
|
||||
auto wscB = makeWSClient(env.app().config());
|
||||
{
|
||||
json::Value jv{json::ValueType::Object};
|
||||
jv[jss::accounts] = json::ValueType::Array;
|
||||
jv[jss::accounts].append(alice.human());
|
||||
auto const r = wscB->invoke("subscribe", jv);
|
||||
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
|
||||
}
|
||||
|
||||
// A publish for alice must reach B. If A's seq-keyed cleanup had wrongly
|
||||
// removed the shared alice entry, B would receive nothing.
|
||||
{
|
||||
env(pay(env.master, alice, XRP(100)));
|
||||
BEAST_EXPECT(env.syncClose());
|
||||
BEAST_EXPECT(wscB->findMsg(5s, [&](auto const& jv) {
|
||||
return jv.isMember(jss::transaction) &&
|
||||
jv[jss::transaction][jss::TransactionType] == jss::Payment &&
|
||||
jv[jss::transaction][jss::Destination] == alice.human();
|
||||
}));
|
||||
}
|
||||
|
||||
wscB->invoke("unsubscribe", accountsRequest({alice.human()}));
|
||||
}
|
||||
|
||||
void
|
||||
run() override
|
||||
{
|
||||
@@ -1569,6 +1977,14 @@ public:
|
||||
testSubBookChanges();
|
||||
testNFToken(all);
|
||||
testNFToken(all - featureNFTokenMintOffer);
|
||||
testAsyncTeardownDoesNotStall();
|
||||
testResubscribeAfterDisconnect();
|
||||
testSubscriptionCapRejects();
|
||||
testReSubscribeNotOvercounted();
|
||||
testBooksCapIndependentOfAccounts();
|
||||
testMultiFieldNoPartialSubscribe();
|
||||
testHistoryReSubscribeNotOvercounted();
|
||||
testHistoryCapRejectsNetNew();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ set(test_modules
|
||||
shamap
|
||||
tx
|
||||
protocol_autogen
|
||||
server
|
||||
)
|
||||
if(NOT WIN32)
|
||||
list(APPEND test_modules net)
|
||||
|
||||
60
src/tests/libxrpl/server/InfoSub.cpp
Normal file
60
src/tests/libxrpl/server/InfoSub.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include <xrpl/server/InfoSub.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
using namespace xrpl;
|
||||
|
||||
// The per-connection subscription cap is enforced by the pure predicate
|
||||
// exceedsSubscriptionCap(current, additional). Testing it directly (rather than
|
||||
// by subscribing the real cap through a WebSocket, which would exceed the frame
|
||||
// limit and drop the connection before the check runs) lets the boundary be
|
||||
// asserted exactly.
|
||||
TEST(InfoSubSubscriptionCap, Boundary)
|
||||
{
|
||||
constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
|
||||
|
||||
// Empty connection: anything up to the cap is admitted, cap+1 is not.
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(0, 0));
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(0, cap));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1));
|
||||
|
||||
// Exactly at the cap: zero more is fine, one more is rejected.
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(cap, 0));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(cap, 1));
|
||||
|
||||
// One below the cap: exactly one more reaches the cap; two exceed it.
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2));
|
||||
}
|
||||
|
||||
TEST(InfoSubSubscriptionCap, NoOverflow)
|
||||
{
|
||||
constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
|
||||
constexpr std::size_t max = std::numeric_limits<std::size_t>::max();
|
||||
|
||||
// current + additional must not wrap: a huge additional is rejected even
|
||||
// when current is 0 (the additional > cap term guards the subtraction).
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(0, max));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(cap, max));
|
||||
}
|
||||
|
||||
TEST(InfoSubSubscriptionCap, ExplicitCap)
|
||||
{
|
||||
// A configured override is honored: the boundary tracks the passed cap, not
|
||||
// the built-in default. This is the seam doSubscribe uses to enforce a
|
||||
// per-connection cap set via [max_subscriptions_per_connection].
|
||||
constexpr std::size_t cap = 5;
|
||||
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(0, cap, cap));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1, cap));
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(cap, 0, cap));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(cap, 1, cap));
|
||||
EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1, cap));
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2, cap));
|
||||
|
||||
// The overflow guard still holds with a small explicit cap.
|
||||
EXPECT_TRUE(exceedsSubscriptionCap(0, std::numeric_limits<std::size_t>::max(), cap));
|
||||
}
|
||||
@@ -57,6 +57,15 @@ public:
|
||||
return transactions_.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* The last accepted transaction. Precondition: size() > 0.
|
||||
*/
|
||||
[[nodiscard]] AcceptedLedgerTx const&
|
||||
back() const
|
||||
{
|
||||
return *transactions_.back();
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<ReadView const> ledger_;
|
||||
std::vector<std::unique_ptr<AcceptedLedgerTx>> transactions_;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,6 +229,12 @@ public:
|
||||
static constexpr int kMaxJobQueueTx = 1000;
|
||||
static constexpr int kMinJobQueueTx = 100;
|
||||
|
||||
// Optional override for the per-connection subscription cap. Unset means
|
||||
// use the built-in default (kMaxSubscriptionsPerConnection in InfoSub.h).
|
||||
// Kept as an override here, rather than the default itself, so the core
|
||||
// module need not depend on the server module that owns the constant.
|
||||
std::optional<std::size_t> maxSubscriptionsPerConnection;
|
||||
|
||||
// Amendment majority time
|
||||
std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime;
|
||||
|
||||
|
||||
@@ -677,6 +677,9 @@ Config::loadFromString(std::string const& fileContents)
|
||||
if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_))
|
||||
networkQuorum = beast::lexicalCastThrow<std::size_t>(strTemp);
|
||||
|
||||
if (getSingleSection(secConfig, Sections::kMaxSubscriptionsPerConnection, strTemp, j_))
|
||||
maxSubscriptionsPerConnection = beast::lexicalCastThrow<std::size_t>(strTemp);
|
||||
|
||||
fees = setupFeeVote(section(Sections::kVoting));
|
||||
/* [fee_default] is documented in the example config files as useful for
|
||||
* things like offline transaction signing. Until that's completely
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <xrpld/rpc/detail/Tuning.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/UnorderedContainers.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
@@ -19,6 +20,7 @@
|
||||
#include <xrpl/server/InfoSub.h>
|
||||
#include <xrpl/server/NetworkOPs.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
@@ -26,6 +28,24 @@
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
* Test whether admitting `additional` subscriptions would exceed the cap.
|
||||
*
|
||||
* @param ispSub The connection's InfoSub, queried for its current count.
|
||||
* @param additional Number of new items this branch would add.
|
||||
* @param cap The effective per-connection cap for this request.
|
||||
* @return true if the request must be rejected to stay within the cap.
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
wouldExceedSubscriptionCap(InfoSub::ref ispSub, std::size_t additional, std::size_t cap)
|
||||
{
|
||||
return exceedsSubscriptionCap(ispSub->totalSubscriptionCount(), additional, cap);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
json::Value
|
||||
doSubscribe(RPC::JsonContext& context)
|
||||
{
|
||||
@@ -105,6 +125,11 @@ doSubscribe(RPC::JsonContext& context)
|
||||
}
|
||||
ispSub->setApiVersion(context.apiVersion);
|
||||
|
||||
// Effective per-connection subscription cap: a configured override if set,
|
||||
// otherwise the built-in default. Resolved once and reused by every branch.
|
||||
std::size_t const subscriptionCap =
|
||||
context.app.config().maxSubscriptionsPerConnection.value_or(kMaxSubscriptionsPerConnection);
|
||||
|
||||
if (context.params.isMember(jss::streams))
|
||||
{
|
||||
if (!context.params[jss::streams].isArray())
|
||||
@@ -166,30 +191,59 @@ doSubscribe(RPC::JsonContext& context)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the proposed (real-time) and normal account sets first, then check
|
||||
// the cap against their COMBINED net-new total before subscribing either.
|
||||
// This keeps the account pair all-or-nothing: it never subscribes one set
|
||||
// and then rejects on the other. Other fields (streams and account_history)
|
||||
// are still checked and subscribed independently, as they always have been,
|
||||
// so a later field can be rejected after an earlier one subscribed. The cap
|
||||
// counts only NET-NEW accounts (those not already tracked on this
|
||||
// connection), so re-subscribing accounts already held is never wrongly
|
||||
// rejected.
|
||||
auto accountsProposed = context.params.isMember(jss::accounts_proposed)
|
||||
? jss::accounts_proposed
|
||||
: jss::rt_accounts; // DEPRECATED
|
||||
if (context.params.isMember(accountsProposed))
|
||||
bool const hasProposed = context.params.isMember(accountsProposed);
|
||||
bool const hasAccounts = context.params.isMember(jss::accounts);
|
||||
|
||||
hash_set<AccountID> proposedIds;
|
||||
hash_set<AccountID> accountIds;
|
||||
|
||||
if (hasProposed)
|
||||
{
|
||||
if (!context.params[accountsProposed].isArray())
|
||||
return rpcError(RpcInvalidParams);
|
||||
|
||||
auto ids = RPC::parseAccountIds(context.params[accountsProposed]);
|
||||
if (ids.empty())
|
||||
proposedIds = RPC::parseAccountIds(context.params[accountsProposed]);
|
||||
if (proposedIds.empty())
|
||||
return rpcError(RpcActMalformed);
|
||||
context.netOps.subAccount(ispSub, ids, true);
|
||||
}
|
||||
|
||||
if (context.params.isMember(jss::accounts))
|
||||
if (hasAccounts)
|
||||
{
|
||||
if (!context.params[jss::accounts].isArray())
|
||||
return rpcError(RpcInvalidParams);
|
||||
|
||||
auto ids = RPC::parseAccountIds(context.params[jss::accounts]);
|
||||
if (ids.empty())
|
||||
accountIds = RPC::parseAccountIds(context.params[jss::accounts]);
|
||||
if (accountIds.empty())
|
||||
return rpcError(RpcActMalformed);
|
||||
context.netOps.subAccount(ispSub, ids, false);
|
||||
JLOG(context.j.debug()) << "doSubscribe: accounts: " << ids.size();
|
||||
}
|
||||
|
||||
if (hasProposed || hasAccounts)
|
||||
{
|
||||
// Atomic check-and-reserve, so two concurrent requests sharing this
|
||||
// InfoSub (admin subscribe-by-url) cannot both pass the cap check.
|
||||
if (!ispSub->tryReserveAccountSubscriptions(proposedIds, accountIds, subscriptionCap))
|
||||
return RPC::makeParamError("Too many subscriptions for this connection.");
|
||||
}
|
||||
|
||||
if (hasProposed)
|
||||
context.netOps.subAccount(ispSub, proposedIds, true);
|
||||
|
||||
if (hasAccounts)
|
||||
{
|
||||
context.netOps.subAccount(ispSub, accountIds, false);
|
||||
JLOG(context.j.debug()) << "doSubscribe: accounts: " << accountIds.size();
|
||||
}
|
||||
|
||||
if (context.params.isMember(jss::account_history_tx_stream))
|
||||
@@ -206,6 +260,13 @@ doSubscribe(RPC::JsonContext& context)
|
||||
if (!id)
|
||||
return rpcError(RpcInvalidParams);
|
||||
|
||||
// Charge the cap only when net-new, like the account branches. Not
|
||||
// atomic here (subAccountHistory does its own dup-detecting insert), but
|
||||
// a concurrent race adds at most one entry, so the overshoot is trivial.
|
||||
std::size_t const historyCharge = ispSub->hasAccountHistorySubscription(*id) ? 0 : 1;
|
||||
if (wouldExceedSubscriptionCap(ispSub, historyCharge, subscriptionCap))
|
||||
return RPC::makeParamError("Too many subscriptions for this connection.");
|
||||
|
||||
if (auto result = context.netOps.subAccountHistory(ispSub, *id); result != RpcSuccess)
|
||||
{
|
||||
return rpcError(result);
|
||||
@@ -222,6 +283,10 @@ doSubscribe(RPC::JsonContext& context)
|
||||
if (!context.params[jss::books].isArray())
|
||||
return rpcError(RpcInvalidParams);
|
||||
|
||||
// Book subscriptions are tracked separately (OrderBookDB) and are not
|
||||
// part of totalSubscriptionCount(), so they are not gated by the
|
||||
// per-connection account cap. Each book entry is validated and
|
||||
// subscribed below.
|
||||
for (auto& j : context.params[jss::books])
|
||||
{
|
||||
if (!j.isObject() || !j.isMember(jss::taker_pays) || !j.isMember(jss::taker_gets) ||
|
||||
|
||||
Reference in New Issue
Block a user