From 0240d832f56b7bb11dd6fd026a6a6adcefb42c9e Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Thu, 6 Aug 2026 17:13:59 +0800 Subject: [PATCH 1/3] refactor: align batch request interface --- docs/running_tests/execute/index.md | 3 +- .../plugins/execute/execute.py | 2 +- .../execute/rpc/chain_builder_eth_rpc.py | 4 +- .../plugins/execute/rpc/hive.py | 4 +- .../plugins/execute/rpc/remote.py | 6 +- .../execute/tests/test_execute_remote.py | 2 +- .../plugins/fill_stateful/fill_stateful.py | 2 +- .../plugins/shared/live_client_flags.py | 6 +- .../testing/src/execution_testing/rpc/rpc.py | 118 +++++++++--------- 9 files changed, 72 insertions(+), 75 deletions(-) diff --git a/docs/running_tests/execute/index.md b/docs/running_tests/execute/index.md index 6c13b2ad389..8fbaae1347b 100644 --- a/docs/running_tests/execute/index.md +++ b/docs/running_tests/execute/index.md @@ -58,6 +58,7 @@ When executing tests with many transactions (e.g., benchmark tests), the `execut - Transactions are sent in batches of up to 750 transactions by default - Each batch is sent and confirmed before the next batch begins - Progress logging shows batch number and transaction ranges +- The same limit caps every other batched JSON-RPC request (receipts, balances, code, account state) **CLI Configuration:** @@ -73,7 +74,7 @@ execute --max-tx-per-batch 1000 tests/ **Safety Threshold:** -A warning is logged when `max_transactions_per_batch` exceeds 1000, as this may cause RPC service instability or failures depending on the RPC endpoint's capacity. +A warning is logged when `max_batch_size` exceeds 1000, as this may cause RPC service instability or failures depending on the RPC endpoint's capacity. **Use Cases:** diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/execute.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/execute.py index b5a8830e4fd..e01fe7498df 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/execute.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/execute.py @@ -188,7 +188,7 @@ def pytest_html_report_title(report: Any) -> None: # NOTE: ``transactions_per_block``, ``default_gas_price``, ``dry_run``, -# ``max_transactions_per_batch``, ``use_testing_build_block``, +# ``max_batch_size``, ``use_testing_build_block``, # ``default_max_fee_per_gas``, ``default_max_priority_fee_per_gas``, # ``default_max_fee_per_blob_gas``, ``max_priority_fee_per_gas``, # ``max_fee_per_gas``, ``max_fee_per_blob_gas``, ``gas_price``, and diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py index 9097a3857fa..1577b4069b4 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py @@ -129,7 +129,7 @@ def __init__( get_payload_wait_time: float, initial_forkchoice_update_retries: int = 5, transaction_wait_timeout: int = 60, - max_transactions_per_batch: int | None = None, + max_batch_size: int | None = None, request_timeout: TimeoutType = DEFAULT_REQUEST_TIMEOUT, testing_rpc: TestingRPC | None = None, expected_genesis_header: FixtureHeader | None = None, @@ -138,7 +138,7 @@ def __init__( super().__init__( rpc_endpoint, transaction_wait_timeout=transaction_wait_timeout, - max_transactions_per_batch=max_transactions_per_batch, + max_batch_size=max_batch_size, request_timeout=request_timeout, ) self.fork = fork diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py index cc06ac10e3c..71fab9311f6 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py @@ -458,7 +458,7 @@ def eth_rpc( engine_rpc: EngineRPC, session_fork: Fork | TransitionFork, session_temp_folder: Path, - max_transactions_per_batch: int | None, + max_batch_size: int | None, use_testing_build_block: bool, base_pre_genesis: Tuple[Alloc, FixtureHeader], ) -> EthRPC: @@ -475,7 +475,7 @@ def eth_rpc( session_temp_folder=session_temp_folder, get_payload_wait_time=get_payload_wait_time, transaction_wait_timeout=tx_wait_timeout, - max_transactions_per_batch=max_transactions_per_batch, + max_batch_size=max_batch_size, testing_rpc=testing_rpc, expected_genesis_header=base_pre_genesis[1], ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py index fec6fe67e52..f28d90db83c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py @@ -175,7 +175,7 @@ def eth_rpc( engine_rpc: EngineRPC | None, session_fork: Fork | TransitionFork, session_temp_folder: Path, - max_transactions_per_batch: int | None, + max_batch_size: int | None, use_testing_build_block: bool, ) -> EthRPC: """Initialize ethereum RPC client for the execution client under test.""" @@ -189,7 +189,7 @@ def eth_rpc( return EthRPC( rpc_endpoint, transaction_wait_timeout=tx_wait_timeout, - max_transactions_per_batch=max_transactions_per_batch, + max_batch_size=max_batch_size, ) get_payload_wait_time = request.config.getoption("get_payload_wait_time") testing_rpc = None @@ -205,6 +205,6 @@ def eth_rpc( else session_temp_folder, get_payload_wait_time=get_payload_wait_time, transaction_wait_timeout=tx_wait_timeout, - max_transactions_per_batch=max_transactions_per_batch, + max_batch_size=max_batch_size, testing_rpc=testing_rpc, ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py index 471ad8244af..96b1b19e046 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py @@ -307,7 +307,7 @@ def chain_builder_eth_rpc( session_temp_folder=session_temp_folder, get_payload_wait_time=1, transaction_wait_timeout=20, - max_transactions_per_batch=10, + max_batch_size=10, testing_rpc=TestingRPC(rpc_endpoint), ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index e98f15a6573..743769544d9 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -484,7 +484,7 @@ def max_gas_limit_per_test( return fork_at_genesis.transaction_gas_limit_cap() -# Other live-client fixtures (``max_transactions_per_batch``, +# Other live-client fixtures (``max_batch_size``, # ``default_*``, fee fields, ``dry_run``, ...) come from # ``shared.live_client_flags``. ``skip_cleanup`` from ``execute.pre_alloc``; # we force it on in ``pytest_configure``. diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/live_client_flags.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/live_client_flags.py index bd89330f736..bcb4da01fae 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/live_client_flags.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/live_client_flags.py @@ -144,7 +144,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: type=int, default=None, help=( - "Maximum number of transactions to send in a single batch to " + "Maximum number of calls to send in a single batch request to " "the RPC. Default=750. Higher values may cause RPC instability." ), ) @@ -200,8 +200,8 @@ def dry_run(request: pytest.FixtureRequest) -> bool: @pytest.fixture(scope="session") -def max_transactions_per_batch(request: pytest.FixtureRequest) -> int | None: - """Return max transactions per batch, or None for default.""" +def max_batch_size(request: pytest.FixtureRequest) -> int | None: + """Return max calls per batch request, or None for default.""" return request.config.getoption("max_tx_per_batch") diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 72e49c43a7a..b3105a2e1cf 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -213,9 +213,13 @@ class BaseRPC: simulators. """ + OVERLOAD_THRESHOLD: int = 1000 + DEFAULT_MAX_BATCH_SIZE: int = 750 + namespace: ClassVar[str] response_validation_context: Any | None request_timeout: TimeoutType + max_batch_size: int def __init__( self, @@ -223,17 +227,25 @@ def __init__( *, response_validation_context: Any | None = None, request_timeout: TimeoutType = DEFAULT_REQUEST_TIMEOUT, + max_batch_size: int | None = None, ): """ Initialize BaseRPC class with the given url. - `request_timeout` bounds every request made through this client; + - `request_timeout` bounds every request made through this client; `None` disables the bound. + - `max_batch_size` caps how many calls in a single batch request. """ self.url = url self.request_id_counter = count(1) self.response_validation_context = response_validation_context self.request_timeout = request_timeout + self.max_batch_size = max_batch_size or self.DEFAULT_MAX_BATCH_SIZE + if self.max_batch_size > self.OVERLOAD_THRESHOLD: + logger.warning( + f"max_batch_size ({max_batch_size}) exceeds safe threshold " + f"({self.OVERLOAD_THRESHOLD}) and may cause RPC instability." + ) self.session = requests.Session() def close(self) -> None: @@ -366,22 +378,13 @@ def post_request( return JSONRPCResponse.model_validate(response.json()) - def post_batch_request( + def _post_single_batch( self, - *, calls: Sequence[RPCCall], - extra_headers: Dict[str, str] | None = None, - timeout: TimeoutType = None, + extra_headers: Dict[str, str], + timeout: TimeoutType, ) -> List[JSONRPCResponse]: - """ - Send a JSON-RPC batch POST request to the client RPC server at port - defined in the url. - - A `timeout` of `None` applies the client's `request_timeout`. - """ - if extra_headers is None: - extra_headers = {} - + """Send one batch POST and return responses in request order.""" json_rpc_requests = [ self._build_json_rpc_request(call) for call in calls ] @@ -418,9 +421,36 @@ def post_batch_request( ) results.append(response_map[json_rpc_request.id]) - logger.info(f"Batch RPC: {len(results)} responses received") return results + def post_batch_request( + self, + *, + calls: Sequence[RPCCall], + extra_headers: Dict[str, str] | None = None, + timeout: TimeoutType = None, + ) -> List[JSONRPCResponse]: + """ + Send JSON-RPC batch POST requests to the client RPC server at port + defined in the url. + + Responses are returned in the same order as `calls`. + """ + if not calls: + return [] + if extra_headers is None: + extra_headers = {} + + responses: List[JSONRPCResponse] = [] + for start in range(0, len(calls), self.max_batch_size): + chunk = calls[start : start + self.max_batch_size] + responses.extend( + self._post_single_batch(chunk, extra_headers, timeout) + ) + + logger.info(f"Batch RPC: {len(responses)} responses received") + return responses + class BaseJwtRPC(BaseRPC): """ @@ -462,12 +492,8 @@ class EthRPC(BaseRPC): within EEST based hive simulators. """ - OVERLOAD_THRESHOLD: int = 1000 - DEFAULT_MAX_TRANSACTIONS_PER_BATCH: int = 750 - transaction_wait_timeout: int = 60 poll_interval: float = 1.0 # how often to poll for tx inclusion - max_transactions_per_batch: int = DEFAULT_MAX_TRANSACTIONS_PER_BATCH gas_information_stale_seconds: int @@ -482,7 +508,6 @@ def __init__( transaction_wait_timeout: int = 60, poll_interval: float | None = None, gas_information_stale_seconds: int = 12, - max_transactions_per_batch: int | None = None, **kwargs: Any, ) -> None: """Initialize JWT-authenticated RPC class with the given JWT secret.""" @@ -517,19 +542,6 @@ def __init__( "blobBaseFee": 0.0, } - # Transaction batching configuration - if max_transactions_per_batch is None: - max_transactions_per_batch = ( - self.DEFAULT_MAX_TRANSACTIONS_PER_BATCH - ) - self.max_transactions_per_batch = max_transactions_per_batch - if max_transactions_per_batch > self.OVERLOAD_THRESHOLD: - logger.warning( - f"max_transactions_per_batch ({max_transactions_per_batch}) " - f"exceeds the safe threshold ({self.OVERLOAD_THRESHOLD}). " - "This may cause RPC service instability or failures." - ) - def config(self, timeout: int | None = None) -> EthConfigResponse | None: """ `eth_config`: Returns information about a fork configuration of the @@ -838,41 +850,25 @@ def get_transaction_receipt( ).result_or_raise() def get_transaction_receipts( - self, - transaction_hashes: Sequence[Hash], - *, - chunk_size: int = 500, + self, transaction_hashes: Sequence[Hash] ) -> List[dict[str, Any] | None]: """ `eth_getTransactionReceipt` batch: receipts for many transactions. - Returns one entry per input hash, in the same order (see - `post_batch_request`, which maps responses back by request id). - - Requests are chunked because clients cap batch size -- geth's - `--rpc.batchrequestlimit` defaults to 1000 -- and because a single - response carrying thousands of receipts is several megabytes. + Returns one entry per input hash, in the same order. """ if not transaction_hashes: return [] - logger.info( - f"Batch requesting {len(transaction_hashes)} tx receipts " - f"in chunks of {chunk_size}" - ) - receipts: List[dict[str, Any] | None] = [] - for start in range(0, len(transaction_hashes), chunk_size): - chunk = transaction_hashes[start : start + chunk_size] - responses = self.post_batch_request( - calls=[ - RPCCall( - method="getTransactionReceipt", - params=[f"{tx_hash}"], - ) - for tx_hash in chunk - ] + logger.info(f"Batch requesting {len(transaction_hashes)} tx receipts") + calls = [ + RPCCall( + method="getTransactionReceipt", + params=[f"{tx_hash}"], ) - receipts.extend(r.result_or_raise() for r in responses) - return receipts + for tx_hash in transaction_hashes + ] + responses = self.post_batch_request(calls=calls) + return [r.result_or_raise() for r in responses] def get_storage_at( self, @@ -1297,7 +1293,7 @@ def send_wait_transactions( block. Transactions are sent in batches to avoid RPC overload. """ results: List[Any] = [] - batch_size = self.max_transactions_per_batch + batch_size = self.max_batch_size total_txs = len(transactions) for i in range(0, total_txs, batch_size): From 4a9b028ce570c96eef85c1f9e175ecceb850e4d4 Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Thu, 6 Aug 2026 17:19:41 +0800 Subject: [PATCH 2/3] test: batch request chunking --- .../rpc/tests/test_batch_requests.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/testing/src/execution_testing/rpc/tests/test_batch_requests.py diff --git a/packages/testing/src/execution_testing/rpc/tests/test_batch_requests.py b/packages/testing/src/execution_testing/rpc/tests/test_batch_requests.py new file mode 100644 index 00000000000..91759728a48 --- /dev/null +++ b/packages/testing/src/execution_testing/rpc/tests/test_batch_requests.py @@ -0,0 +1,77 @@ +"""Test the batch request chunking of `BaseRPC` clients.""" + +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from execution_testing.base_types import Hash +from execution_testing.rpc import EthRPC, RPCCall + + +def echo_batch_response(*_args: Any, **kwargs: Any) -> MagicMock: + """Return a mock batch response echoing each request id as its result.""" + response = MagicMock() + response.json.return_value = [ + {"jsonrpc": "2.0", "id": request["id"], "result": hex(request["id"])} + for request in kwargs["json"] + ] + return response + + +@pytest.fixture +def max_batch_size() -> int | None: + """Batch size cap of the client under test; `None` uses the default.""" + return None + + +@pytest.fixture +def rpc(max_batch_size: int | None) -> EthRPC: + """Return an `eth` RPC client pointed at a local endpoint.""" + return EthRPC("http://localhost:8545", max_batch_size=max_batch_size) + + +@pytest.fixture +def post(rpc: EthRPC) -> Iterator[MagicMock]: + """Patch the client's HTTP POST to echo back every batched call.""" + with patch.object( + rpc.session, "post", side_effect=echo_batch_response + ) as post_mock: + yield post_mock + + +@pytest.mark.parametrize("max_batch_size", [2]) +def test_batch_request_is_chunked(rpc: EthRPC, post: MagicMock) -> None: + """Calls beyond `max_batch_size` are split over several requests.""" + calls = [RPCCall(method="blockNumber") for _ in range(5)] + responses = rpc.post_batch_request(calls=calls) + chunk_sizes = [len(c.kwargs["json"]) for c in post.call_args_list] + assert chunk_sizes == [2, 2, 1] + assert [r.result for r in responses] == [hex(i) for i in range(1, 6)] + + +@pytest.mark.parametrize("max_batch_size", [5]) +def test_batch_request_within_limit_is_a_single_request( + rpc: EthRPC, post: MagicMock +) -> None: + """A call list at the limit is sent as one request.""" + calls = [RPCCall(method="blockNumber") for _ in range(5)] + rpc.post_batch_request(calls=calls) + assert post.call_count == 1 + + +def test_empty_batch_request_is_not_sent(rpc: EthRPC, post: MagicMock) -> None: + """An empty call list short-circuits without an HTTP request.""" + assert rpc.post_batch_request(calls=[]) == [] + post.assert_not_called() + + +@pytest.mark.parametrize("max_batch_size", [2]) +def test_chunked_receipts_keep_request_order( + rpc: EthRPC, post: MagicMock +) -> None: + """Chunked receipts are returned in the order of the input hashes.""" + hashes = [Hash(i) for i in range(1, 6)] + receipts = rpc.get_transaction_receipts(hashes) + assert post.call_count == 3 + assert receipts == [hex(i) for i in range(1, 6)] From 89d9917dc07df63e714086c2f268d89a046755e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:32 +0800 Subject: [PATCH 3/3] Update packages/testing/src/execution_testing/rpc/rpc.py Co-authored-by: spencer --- packages/testing/src/execution_testing/rpc/rpc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index b3105a2e1cf..f8018d39069 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -240,6 +240,10 @@ def __init__( self.request_id_counter = count(1) self.response_validation_context = response_validation_context self.request_timeout = request_timeout + if max_batch_size is not None and max_batch_size < 1: + raise ValueError( + f"max_batch_size must be >= 1, got {max_batch_size}" + ) self.max_batch_size = max_batch_size or self.DEFAULT_MAX_BATCH_SIZE if self.max_batch_size > self.OVERLOAD_THRESHOLD: logger.warning(