make the mempool a bit more defensive on slow machines (#20594)

* make the mempool a bit more defensive on slow machines

* add tests
This commit is contained in:
Arvid Norberg
2026-03-09 09:42:14 -05:00
committed by GitHub
parent 4a5652da79
commit 1774049980
9 changed files with 89 additions and 14 deletions
+5 -1
View File
@@ -94,7 +94,11 @@ async def run_mempool_benchmark() -> None:
timestamp = uint64(1631794488)
with MempoolManager(
get_coin_record, get_unspent_lineage_info_for_puzzle_hash, DEFAULT_CONSTANTS, single_threaded=True
get_coin_record,
get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
validation_timeout=2,
single_threaded=True,
) as mempool:
print("\nrunning add_spend_bundle() + new_peak()")
+2
View File
@@ -179,6 +179,7 @@ async def run_mempool_benchmark() -> None:
get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
single_threaded=single_threaded,
validation_timeout=2,
) as mempool:
height = start_height
rec = fake_block_record(height, timestamp)
@@ -204,6 +205,7 @@ async def run_mempool_benchmark() -> None:
get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
single_threaded=single_threaded,
validation_timeout=2,
) as mempool:
height = start_height
rec = fake_block_record(height, timestamp)
+1 -1
View File
@@ -1322,7 +1322,7 @@ async def farmer_harvester_2_simulators_zero_bits_plot_filter(
)
)
config_overrides: dict[str, int] = {"full_node.max_sync_wait": 0}
config_overrides: dict[str, int] = {"full_node.max_sync_wait": 0, "full_node.block_creation_timeout": 10}
bts = [
await async_exit_stack.enter_async_context(
@@ -63,7 +63,10 @@ async def test_fee_increase() -> None:
async with DBConnection(db_version=2) as db_wrapper:
coin_store = await CoinStore.create(db_wrapper)
async with MempoolManager.managed(
coin_store.get_coin_records, coin_store.get_unspent_lineage_info_for_puzzle_hash, test_constants
coin_store.get_coin_records,
coin_store.get_unspent_lineage_info_for_puzzle_hash,
test_constants,
validation_timeout=10,
) as mempool_manager:
assert test_constants.MAX_BLOCK_COST_CLVM == mempool_manager.constants.MAX_BLOCK_COST_CLVM
btc_fee_estimator: BitcoinFeeEstimator = mempool_manager.mempool.fee_estimator # type: ignore
@@ -266,6 +266,7 @@ async def instantiate_mempool_manager(
zero_calls_get_unspent_lineage_info_for_puzzle_hash,
constants,
max_tx_clvm_cost=max_tx_clvm_cost,
validation_timeout=10,
) as mempool_manager:
test_block_record = create_test_block_record(height=block_height, timestamp=block_timestamp)
await mempool_manager.new_peak(test_block_record, None)
@@ -711,6 +712,33 @@ async def test_reserve_fee_condition(zero_mempool_manager: MempoolManager) -> No
await zero_mempool_manager.pre_validate_spendbundle(sb)
@pytest.mark.anyio
async def test_validation_timeout() -> None:
async with MempoolManager.managed(
zero_calls_get_coin_records,
zero_calls_get_unspent_lineage_info_for_puzzle_hash,
DEFAULT_CONSTANTS,
validation_timeout=0,
) as mempool_manager:
await mempool_manager.new_peak(create_test_block_record(), None)
conditions = [[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, 1]]
sb = spend_bundle_from_conditions(conditions)
with pytest.raises(ValueError, match="timeout"):
await mempool_manager.pre_validate_spendbundle(sb)
@pytest.mark.anyio
async def test_too_many_atoms() -> None:
# a very large MAX_BLOCK_COST_CLVM makes the per-cost atom/pair threshold
# effectively 0, triggering the density check on any spend
constants = DEFAULT_CONSTANTS.replace(MAX_BLOCK_COST_CLVM=uint64(10**18))
async with instantiate_mempool_manager(zero_calls_get_coin_records, constants=constants) as mempool_manager:
conditions = [[ConditionOpcode.CREATE_COIN, IDENTITY_PUZZLE_HASH, 1]]
sb = spend_bundle_from_conditions(conditions)
with pytest.raises(ValueError, match="too many atoms"):
await mempool_manager.pre_validate_spendbundle(sb)
@pytest.mark.anyio
async def test_unknown_unspent() -> None:
async def get_coin_records(_: Collection[bytes32]) -> list[CoinRecord]:
@@ -2420,6 +2448,7 @@ async def setup_mempool(coins: TestCoins) -> AsyncGenerator[MempoolManager, None
coins.get_coin_records,
coins.get_unspent_lineage_info,
DEFAULT_CONSTANTS,
validation_timeout=10,
) as mempool_manager:
test_block_record = create_test_block_record(height=uint32(5000000), timestamp=uint64(12345678))
await mempool_manager.new_peak(test_block_record, None)
@@ -2673,6 +2702,7 @@ def test_no_peak(old: bool, transactions_1000: list[SpendBundle]) -> None:
coins.get_coin_records,
coins.get_unspent_lineage_info,
DEFAULT_CONSTANTS,
validation_timeout=10,
) as mempool_manager:
create_block = mempool_manager.create_block_generator if old else mempool_manager.create_block_generator2
+16 -3
View File
@@ -91,7 +91,11 @@ async def setup_two_nodes(
Setup and teardown of two full nodes, with blockchains and separate DBs.
"""
config_overrides = {"full_node.max_sync_wait": 0, "full_node.log_coins": True}
config_overrides = {
"full_node.max_sync_wait": 0,
"full_node.log_coins": True,
"full_node.block_creation_timeout": 10,
}
with TempKeyring(populate=True) as keychain1, TempKeyring(populate=True) as keychain2:
async with (
create_block_tools_async(
@@ -132,7 +136,11 @@ async def setup_n_nodes(
"""
Setup and teardown of n full nodes, with blockchains and separate DBs.
"""
config_overrides = {"full_node.max_sync_wait": 0, "full_node.log_coins": True}
config_overrides = {
"full_node.max_sync_wait": 0,
"full_node.log_coins": True,
"full_node.block_creation_timeout": 10,
}
with ExitStack() as stack:
keychains = [stack.enter_context(TempKeyring(populate=True)) for _ in range(n)]
async with AsyncExitStack() as async_exit_stack:
@@ -260,6 +268,7 @@ async def setup_simulators_and_wallets_inner(
if config_overrides is not None and "full_node.max_sync_wait" not in config_overrides:
config_overrides["full_node.max_sync_wait"] = 0
config_overrides["full_node.log_coins"] = True
config_overrides["full_node.block_creation_timeout"] = 10
async with AsyncExitStack() as async_exit_stack:
bt_tools: list[BlockTools] = [
await async_exit_stack.enter_async_context(
@@ -380,7 +389,11 @@ async def setup_full_system_inner(
keychain2: Keychain,
shared_b_tools: BlockTools,
) -> AsyncIterator[FullSystem]:
config_overrides = {"full_node.max_sync_wait": 0, "full_node.log_coins": True}
config_overrides = {
"full_node.max_sync_wait": 0,
"full_node.log_coins": True,
"full_node.block_creation_timeout": 10,
}
self_hostname = shared_b_tools.config["self_hostname"]
+4 -1
View File
@@ -169,7 +169,10 @@ class SpendSim:
self.defaults = defaults
async with MempoolManager.managed(
self.coin_store.get_coin_records, self.coin_store.get_unspent_lineage_info_for_puzzle_hash, defaults
self.coin_store.get_coin_records,
self.coin_store.get_unspent_lineage_info_for_puzzle_hash,
defaults,
validation_timeout=10,
) as self.mempool_manager:
# Load the next data if there is any
async with self.db_wrapper.writer_maybe_transaction() as conn:
+8 -1
View File
@@ -285,6 +285,7 @@ class FullNode:
get_unspent_lineage_info_for_puzzle_hash=self.coin_store.get_unspent_lineage_info_for_puzzle_hash,
consensus_constants=self.constants,
single_threaded=single_threaded,
validation_timeout=self.config.get("block_creation_timeout", 2.0),
) as self._mempool_manager:
# Transactions go into this queue from the server, and get sent to respond_transaction
self._transaction_queue = TransactionQueue(
@@ -2783,7 +2784,13 @@ class FullNode:
if self.sync_store.get_sync_mode() or self.mempool_manager.peak is None:
return MempoolInclusionStatus.FAILED, Err.NO_TRANSACTIONS_WHILE_SYNCING
cost_result = await self.mempool_manager.pre_validate_spendbundle(transaction, spend_name, self._bls_cache)
try:
cost_result = await self.mempool_manager.pre_validate_spendbundle(transaction, spend_name, self._bls_cache)
except ValueError as e:
# ValueError is used to indicate a soft failure. We don't want to
# ban the peer
self.log.info(f"Rejecting transaction {spend_name}: {e}")
return MempoolInclusionStatus.FAILED, Err.INVALID_SPEND_BUNDLE
self.mempool_manager.add_and_maybe_pop_seen(spend_name)
+19 -6
View File
@@ -312,6 +312,7 @@ class MempoolManager:
_worker_queue_size: int
max_block_clvm_cost: uint64
max_tx_clvm_cost: uint64
validation_timeout: float
def __init__(
self,
@@ -319,6 +320,7 @@ class MempoolManager:
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]],
consensus_constants: ConsensusConstants,
*,
validation_timeout: float,
single_threaded: bool = False,
max_tx_clvm_cost: uint64 | None = None,
):
@@ -350,6 +352,7 @@ class MempoolManager:
self._pending_cache = PendingTxCache(self.constants.MAX_BLOCK_COST_CLVM * 1, 1000)
self.seen_cache_size = 10000
self._worker_queue_size = 0
self.validation_timeout = validation_timeout
if single_threaded:
self.pool = InlineExecutor()
else:
@@ -372,6 +375,8 @@ class MempoolManager:
get_coin_records: Callable[[Collection[bytes32]], Awaitable[list[CoinRecord]]],
get_unspent_lineage_info_for_puzzle_hash: Callable[[bytes32], Awaitable[UnspentLineageInfo | None]],
consensus_constants: ConsensusConstants,
*,
validation_timeout: float,
single_threaded: bool = False,
max_tx_clvm_cost: uint64 | None = None,
) -> AsyncIterator[Self]:
@@ -381,6 +386,7 @@ class MempoolManager:
consensus_constants,
single_threaded=single_threaded,
max_tx_clvm_cost=max_tx_clvm_cost,
validation_timeout=validation_timeout,
)
try:
yield self
@@ -504,10 +510,17 @@ class MempoolManager:
self._worker_queue_size -= 1
if sbc.num_atoms > sbc.cost * 60_000_000 / self.constants.MAX_BLOCK_COST_CLVM:
raise ValidationError(Err.INVALID_SPEND_BUNDLE, "too many atoms")
raise ValueError("too many atoms")
if sbc.num_pairs > sbc.cost * 60_000_000 / self.constants.MAX_BLOCK_COST_CLVM:
raise ValidationError(Err.INVALID_SPEND_BUNDLE, "too many pairs")
raise ValueError("too many pairs")
if duration > self.validation_timeout:
raise ValueError(f"timeout {duration:0.4} s")
cost = sbc.execution_cost + sbc.condition_cost
if cost == 0 or (duration > 0.1 and duration * 1e9 / cost > self.validation_timeout * 5.0):
raise ValueError(f"timeout ({duration * 1e9 / cost:0.4} ns/cost)")
if bls_cache is not None:
bls_cache.update(new_cache_entries)
@@ -516,8 +529,8 @@ class MempoolManager:
spend_bundle_id = spend_bundle.name()
log.log(
logging.DEBUG if duration < 2 else logging.WARNING,
f"pre_validate_spendbundle took {duration:0.4f} seconds "
logging.DEBUG if duration < self.validation_timeout else logging.WARNING,
f"pre_validate_spendbundle took {duration:0.4f} seconds {duration * 1e9 / cost:0.4} ns/cost "
f"for {spend_bundle_id} (queue-size: {self._worker_queue_size})",
)
return sbc
@@ -808,12 +821,12 @@ class MempoolManager:
duration = time.monotonic() - start_time
log.log(
logging.DEBUG if duration < 2 else logging.WARNING,
logging.DEBUG if duration < self.validation_timeout else logging.WARNING,
f"add_spendbundle {spend_name} took {duration:0.2f} seconds. "
f"Cost: {cost} ({round(100.0 * cost / self.constants.MAX_BLOCK_COST_CLVM, 3)}% of max block cost)",
)
if duration > 2:
if duration > self.validation_timeout:
log.warning("validating spend took too long, rejecting")
return Err.INVALID_SPEND_BUNDLE, None, []