mirror of
https://github.com/bitcoin/bitcoin.git
synced 2026-09-12 13:42:10 +02:00
Merge bitcoin/bitcoin#34628: p2p: Replace per-peer transaction rate-limiting with global rate limits
349c72ee00net_processing: Drop unnecessary txid arg from InitiateTxBroadcastToAll (Anthony Towns)12b0dc33c4doc: Add release note for -txsendrate etc (Anthony Towns)5cde66341atests: basic functional test for tx rate limiting (Anthony Towns)4842903ac1rpc: report -txsendrate and bucket info via getnetworkinfo (Anthony Towns)74a47a5207init: add -txsendrate configuration parameter (Anthony Towns)6307bd034bnet_processing: Provide a 30bpm heartbeat log while inv backlog is in use (Anthony Towns)df31ee57aanet_processing: add a global delay queue for sending txs (Anthony Towns)7927650e56util/tokenbucket.h: Provide a generic TokenBucket class (Anthony Towns)749bb447f8txmempool: Drop CompareMiningScoreWithTopology (Anthony Towns)e1b7490fbcnet_processing: Replace CompareInvMempoolOrder (Anthony Towns)6cfc65d210txmempool: Add ExtractBestByMiningScoreWithTopology (Anthony Towns)026f70e05fnet_processing: Remove per-peer rate-limiting (Anthony Towns)46c8c471dcnet_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 ACK349c72ee00. 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: reACK349c72ee00mzumsande: ACK349c72ee00Tree-SHA512: 2196a23308cb7fe36738cf638edf5c5b0e9ba32b11c083609fd8b50291e05bb33484f9921f8beab28d94c58d1adddea4c8ae1182a60a7f53f54be7370e2a0e47
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"""Test mempool limiting together/eviction with the wallet."""
|
||||
|
||||
from decimal import Decimal
|
||||
import time
|
||||
|
||||
from test_framework.mempool_util import (
|
||||
fill_mempool,
|
||||
@@ -205,7 +206,9 @@ class MempoolLimitTest(BitcoinTestFramework):
|
||||
self.log.info('Check that mempoolminfee is minrelaytxfee')
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], node.getmempoolinfo()["mempoolminfee"])
|
||||
|
||||
node.setmocktime(int(time.time())-3600)
|
||||
fill_mempool(self, node)
|
||||
node.setmocktime(0) # bump time forward so the rate limit buckets refresh and don't block broadcast
|
||||
|
||||
# Deliberately try to create a tx with a fee less than the minimum mempool fee to assert that it does not get added to the mempool
|
||||
self.log.info('Create a mempool tx that will not pass mempoolminfee')
|
||||
|
||||
114
test/functional/p2p_tx_relay_rate_limit.py
Executable file
114
test/functional/p2p_tx_relay_rate_limit.py
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) The Bitcoin Core developers
|
||||
# Distributed under the MIT software license, see the accompanying
|
||||
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
"""Test transaction relay rate limiting via token buckets.
|
||||
|
||||
With -txsendrate=R, the inbound count bucket has capacity R*30. A broadcast
|
||||
transaction is relayed immediately while the bucket has tokens; once it is
|
||||
exhausted the excess transactions queue in a global backlog and drain as the
|
||||
bucket refills (R tokens/second as mocktime advances).
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
import time
|
||||
|
||||
from test_framework.blocktools import COINBASE_MATURITY
|
||||
from test_framework.p2p import P2PTxInvStore
|
||||
from test_framework.test_framework import BitcoinTestFramework
|
||||
from test_framework.util import assert_equal
|
||||
from test_framework.wallet import MiniWallet
|
||||
|
||||
SEND_RATE = 2 # -txsendrate value
|
||||
BUCKET_CAP = SEND_RATE * 30 # count bucket capacity (60)
|
||||
NUM_TXS = 80 # total transactions to submit
|
||||
|
||||
|
||||
class TxRelayRateLimitTest(BitcoinTestFramework):
|
||||
def set_test_params(self):
|
||||
self.num_nodes = 1
|
||||
self.extra_args = [[f'-txsendrate={SEND_RATE}']]
|
||||
|
||||
def inbound_backlog(self, node):
|
||||
return node.getnetworkinfo()['inv_buckets']['inbound']['backlog']
|
||||
|
||||
def run_test(self):
|
||||
node = self.nodes[0]
|
||||
wallet = MiniWallet(node)
|
||||
|
||||
node.setmocktime(int(time.time()))
|
||||
|
||||
# Mine enough blocks for mature coinbase UTXOs.
|
||||
self.generate(wallet, COINBASE_MATURITY + NUM_TXS + 50)
|
||||
|
||||
# Connect an inbound peer (negotiates wtxid relay by default)
|
||||
peer = node.add_p2p_connection(P2PTxInvStore())
|
||||
|
||||
# Advance time so the peer's trickle timer initializes
|
||||
node.bumpmocktime(10)
|
||||
peer.sync_with_ping()
|
||||
assert_equal(len(peer.get_invs()), 0)
|
||||
|
||||
# Verify the configured send rate
|
||||
assert_equal(node.getnetworkinfo()['tx_send_rate'], SEND_RATE)
|
||||
|
||||
self.test_rate_limit_and_rbf(node, wallet, peer)
|
||||
|
||||
def test_rate_limit_and_rbf(self, node, wallet, peer):
|
||||
self.log.info(f"Submitting {NUM_TXS} transactions at frozen time (bucket capacity {BUCKET_CAP})")
|
||||
|
||||
# Prepare an RBF pair: original and replacement spending the same UTXO.
|
||||
# Both are created upfront so we can submit the replacement later.
|
||||
rbf_utxo = wallet.get_utxo()
|
||||
tx_rbf_orig = wallet.create_self_transfer(utxo_to_spend=rbf_utxo)
|
||||
tx_rbf_repl = wallet.create_self_transfer(utxo_to_spend=rbf_utxo, fee_rate=Decimal("0.009"))
|
||||
|
||||
# Submit NUM_TXS transactions at frozen time. Each broadcast is relayed
|
||||
# immediately while the count bucket has tokens, so the first BUCKET_CAP
|
||||
# are handed straight to the peer and the rest queue in the backlog. The
|
||||
# RBF original is placed in the backlogged tail.
|
||||
RBF_INDEX = NUM_TXS - 5
|
||||
for i in range(NUM_TXS):
|
||||
if i == RBF_INDEX:
|
||||
node.sendrawtransaction(tx_rbf_orig['hex'])
|
||||
else:
|
||||
wallet.send_self_transfer(from_node=node)
|
||||
|
||||
# The excess beyond the bucket capacity is backlogged. Nothing is
|
||||
# announced yet -- the per-peer trickle timer hasn't fired (frozen time).
|
||||
self.log.info(f"Backlog after burst: {self.inbound_backlog(node)}")
|
||||
assert_equal(self.inbound_backlog(node), NUM_TXS - BUCKET_CAP)
|
||||
assert_equal(len(peer.get_invs()), 0)
|
||||
|
||||
# RBF the backlogged original while time is still frozen, so the
|
||||
# replacement also queues in the backlog (the bucket is exhausted). The
|
||||
# original's wtxid stays in the backlog vector for now but is dropped
|
||||
# when the backlog is processed, since it is no longer in the mempool.
|
||||
self.log.info("RBF'ing a backlogged transaction")
|
||||
node.sendrawtransaction(tx_rbf_repl['hex'])
|
||||
assert_equal(self.inbound_backlog(node), NUM_TXS - BUCKET_CAP + 1)
|
||||
|
||||
# Advance time so the bucket refills and the backlog drains. Loop until
|
||||
# the backlog is empty and every surviving tx has trickled out.
|
||||
self.log.info("Advancing time to drain the backlog")
|
||||
for _ in range(30):
|
||||
if self.inbound_backlog(node) == 0 and len(peer.get_invs()) == NUM_TXS:
|
||||
break
|
||||
node.bumpmocktime(4)
|
||||
peer.sync_with_ping()
|
||||
|
||||
announced = set(peer.get_invs())
|
||||
self.log.info(f"Total announced: {len(announced)}")
|
||||
|
||||
# Every surviving transaction is announced: the burst minus the dropped
|
||||
# RBF original, plus the replacement.
|
||||
assert_equal(self.inbound_backlog(node), 0)
|
||||
assert_equal(len(announced), NUM_TXS)
|
||||
assert int(tx_rbf_orig['wtxid'], 16) not in announced
|
||||
assert int(tx_rbf_repl['wtxid'], 16) in announced
|
||||
|
||||
self.log.info("Rate limiting and RBF backlog cleanup test passed")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
TxRelayRateLimitTest(__file__).main()
|
||||
@@ -275,6 +275,7 @@ BASE_SCRIPTS = [
|
||||
'wallet_importprunedfunds.py',
|
||||
'p2p_leak_tx.py --v1transport',
|
||||
'p2p_leak_tx.py --v2transport',
|
||||
'p2p_tx_relay_rate_limit.py',
|
||||
'p2p_eviction.py',
|
||||
'p2p_outbound_eviction.py',
|
||||
'p2p_ibd_stalling.py --v1transport',
|
||||
|
||||
Reference in New Issue
Block a user