Merge bitcoin/bitcoin#34628: p2p: Replace per-peer transaction rate-limiting with global rate limits

349c72ee00 net_processing: Drop unnecessary txid arg from InitiateTxBroadcastToAll (Anthony Towns)
12b0dc33c4 doc: Add release note for -txsendrate etc (Anthony Towns)
5cde66341a tests: basic functional test for tx rate limiting (Anthony Towns)
4842903ac1 rpc: report -txsendrate and bucket info via getnetworkinfo (Anthony Towns)
74a47a5207 init: add -txsendrate configuration parameter (Anthony Towns)
6307bd034b net_processing: Provide a 30bpm heartbeat log while inv backlog is in use (Anthony Towns)
df31ee57aa net_processing: add a global delay queue for sending txs (Anthony Towns)
7927650e56 util/tokenbucket.h: Provide a generic TokenBucket class (Anthony Towns)
749bb447f8 txmempool: Drop CompareMiningScoreWithTopology (Anthony Towns)
e1b7490fbc net_processing: Replace CompareInvMempoolOrder (Anthony Towns)
6cfc65d210 txmempool: Add ExtractBestByMiningScoreWithTopology (Anthony Towns)
026f70e05f net_processing: Remove per-peer rate-limiting (Anthony Towns)
46c8c471dc net_processing: bump last_inv_sequence for bip35 messages explicitly (Anthony Towns)

Pull request description:

  Per-peer `m_tx_inventory_to_send` queues have CPU and memory costs that scale with both queue size and peer count. Under high transaction volume, this has previously caused severe issues ([May 2023 disclosure][1]) and still can cause measurable delays ([Feb 2026 Runestone surge][2], with the msghand thread observed hitting 100% CPU and queue memory reaching ~95MB).

  This PR replaces the per-peer rate limiting with a global queue using dual token buckets (limiting transaction by both count and serialized size). Transactions that arrive within the bucket capacity still relay nearly immediately, but excess transactions queue in a global backlog and drain as the token buckets refill.

  Key parameters:
    - Count bucket: 14 tx/s, 420 capacity (30s buffer)
    - Size bucket: 20 kB/s (~12 MB/600s), 50 MB capacity
    - Outbound peers refill faster by a factor of 2.5

  Per-peer queues are retained solely for privacy batching and are always fully emptied, removing the old `INVENTORY_BROADCAST_MAX` cap.

  This reduces the memory and CPU burden during transaction spikes when the queuing logic is engaged from O(queue * peers) to O(queue), as the queued transactions no longer need to be retained per-peer or re-sorted per-peer.

  Design discussion: https://gist.github.com/ajtowns/d61bea974a07190fa6c6c8eaef3638b9

  [1]: https://bitcoincore.org/en/2024/10/08/disclose-large-inv-to-send/
  [2]: https://bnoc.xyz/t/increased-b-msghand-thread-utilization-due-to-runestone-transactions-on-2026-02-17/81

ACKs for top commit:
  sipa:
    Code review ACK 349c72ee00. I haven't tested it myself yet (though switched my well-connected node to it now), but the posted benchmarks and analyses look convincing.
  instagibbs:
    reACK 349c72ee00
  mzumsande:
    ACK 349c72ee00

Tree-SHA512: 2196a23308cb7fe36738cf638edf5c5b0e9ba32b11c083609fd8b50291e05bb33484f9921f8beab28d94c58d1adddea4c8ae1182a60a7f53f54be7370e2a0e47
This commit is contained in:
merge-script
2026-07-25 12:15:44 +02:00
15 changed files with 707 additions and 120 deletions

View File

@@ -25,6 +25,7 @@
#include <util/strencodings.h>
#include <util/string.h>
#include <util/time.h>
#include <util/tokenbucket.h>
#include <util/vector.h>
#include <array>
@@ -1929,4 +1930,135 @@ BOOST_AUTO_TEST_CASE(gib_string_literal_test)
BOOST_CHECK_EQUAL(32_GiB, 32768_MiB);
}
BOOST_AUTO_TEST_CASE(token_bucket_initial_value)
{
// Initial value is clamped to cap
util::TokenBucket<NodeClock> b1(/*rate=*/1, /*value=*/100, /*cap=*/10);
BOOST_CHECK_EQUAL(b1.value(), 10);
// Initial value below cap is kept as-is
util::TokenBucket<NodeClock> b2(/*rate=*/1, /*value=*/5, /*cap=*/10);
BOOST_CHECK_EQUAL(b2.value(), 5);
}
BOOST_AUTO_TEST_CASE(token_bucket_first_increment)
{
// First increment establishes the time baseline but does not refill
util::TokenBucket<NodeClock> b(/*rate=*/100, /*value=*/0, /*cap=*/1000);
b.increment(NodeClock::time_point{10s});
BOOST_CHECK_EQUAL(b.value(), 0);
// Second increment refills based on elapsed time
b.increment(NodeClock::time_point{15s});
BOOST_CHECK_EQUAL(b.value(), 500); // 100/s * 5s
}
BOOST_AUTO_TEST_CASE(token_bucket_refill_caps)
{
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/90, /*cap=*/100);
b.increment(NodeClock::time_point{1s});
b.increment(NodeClock::time_point{100s}); // would add 990, but cap is 100
BOOST_CHECK_EQUAL(b.value(), 100);
}
BOOST_AUTO_TEST_CASE(token_bucket_time_backwards)
{
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/50, /*cap=*/200);
b.increment(NodeClock::time_point{10s});
b.increment(NodeClock::time_point{5s}); // backwards, no change
BOOST_CHECK_EQUAL(b.value(), 50);
b.increment(NodeClock::time_point{15s}); // forwards takes backwards into account
BOOST_CHECK_EQUAL(b.value(), 150);
}
BOOST_AUTO_TEST_CASE(token_bucket_decrement_no_debt)
{
// Default debt=0: returns false at exactly 0
util::TokenBucket<NodeClock> b(/*rate=*/1, /*value=*/3, /*cap=*/10);
BOOST_CHECK(b.decrement(1)); // 3 -> 2
BOOST_CHECK(b.decrement(1)); // 2 -> 1
BOOST_CHECK(!b.decrement(1)); // 1 -> 0, at floor
BOOST_CHECK_EQUAL(b.value(), 0);
BOOST_CHECK(!b.decrement(1)); // 0 -> -1, despite being at floor
BOOST_CHECK_EQUAL(b.value(), -1);
}
BOOST_AUTO_TEST_CASE(token_bucket_decrement_with_debt)
{
util::TokenBucket<NodeClock> b(/*rate=*/1, /*value=*/2, /*cap=*/10);
BOOST_CHECK(b.decrement(1, -3)); // 2 -> 1
BOOST_CHECK(b.decrement(1, -3)); // 1 -> 0
BOOST_CHECK(b.decrement(1, -3)); // 0 -> -1, still above -3
BOOST_CHECK(b.decrement(1, -3)); // -1 -> -2, still above -3
BOOST_CHECK(!b.decrement(1, -3)); // -2 -> -3, at floor
BOOST_CHECK_EQUAL(b.value(), -3);
}
BOOST_AUTO_TEST_CASE(token_bucket_drain_and_refill)
{
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/20, /*cap=*/100);
b.decrement(20); // drain to 0
BOOST_CHECK_EQUAL(b.value(), 0);
b.increment(NodeClock::time_point{1s});
b.increment(NodeClock::time_point{4s}); // +30
BOOST_CHECK_EQUAL(b.value(), 30);
}
BOOST_AUTO_TEST_CASE(token_bucket_first_increment_at_epoch)
{
// The first increment establishes the baseline (no refill) even when it
// lands exactly on the clock epoch; later increments then refill normally.
util::TokenBucket<NodeClock> b(/*rate=*/100, /*value=*/0, /*cap=*/1000);
b.increment(NodeClock::time_point{0s});
BOOST_CHECK_EQUAL(b.value(), 0);
b.increment(NodeClock::time_point{5s});
BOOST_CHECK_EQUAL(b.value(), 500); // 100/s * 5s
}
BOOST_AUTO_TEST_CASE(token_bucket_at_cap_advances_baseline)
{
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/100, /*cap=*/100);
BOOST_CHECK_EQUAL(b.value(), 100); // already at cap
b.increment(NodeClock::time_point{1s}); // baseline established at 1s
b.increment(NodeClock::time_point{100s}); // 99s spent at the cap; baseline -> 100s
BOOST_CHECK_EQUAL(b.value(), 100);
b.decrement(100); // drain to 0
BOOST_CHECK_EQUAL(b.value(), 0);
// refill doesn't "bank" the extra 99s we were at cap
b.increment(NodeClock::time_point{101s});
BOOST_CHECK_EQUAL(b.value(), 10);
// And when real time genuinely elapses, a single increment refills straight
// back to the cap immediately.
b.increment(NodeClock::time_point{200s}); // 99s elapsed -> +990, clamped to cap
BOOST_CHECK_EQUAL(b.value(), 100);
}
BOOST_AUTO_TEST_CASE(token_bucket_fractional_refill)
{
// Sub-second elapsed time accumulates fractional tokens via double math.
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/0, /*cap=*/100);
b.increment(NodeClock::time_point{1s});
b.increment(NodeClock::time_point{1250ms}); // 10/s * 0.25s = 2.5
BOOST_CHECK_EQUAL(b.value(), 2.5);
}
BOOST_AUTO_TEST_CASE(token_bucket_refill_from_debt)
{
// Refilling from a negative (debt) balance accrues normally and still
// clamps to the cap rather than to debt + increment.
util::TokenBucket<NodeClock> b(/*rate=*/10, /*value=*/0, /*cap=*/100);
BOOST_CHECK(!b.decrement(50)); // -> -50, below floor 0
BOOST_CHECK_EQUAL(b.value(), -50);
b.increment(NodeClock::time_point{1s}); // baseline
b.increment(NodeClock::time_point{4s}); // +30 -> -20
BOOST_CHECK_EQUAL(b.value(), -20);
b.increment(NodeClock::time_point{100s}); // +960 but clamped to cap
BOOST_CHECK_EQUAL(b.value(), 100);
}
BOOST_AUTO_TEST_SUITE_END()