perf: serialize JSON-RPC responses once - #1698
Conversation
|
|
/bench scenarios="test mocha,test vitest" |
There was a problem hiding this comment.
Pull request overview
Optimizes provider JSON-RPC serialization by storing serialized results as RawValue and reusing those bytes through batching and N-API marshaling.
Changes:
- Replaces intermediate
serde_json::Valueresults withBox<RawValue>. - Pre-sizes response envelopes and directly assembles serialized batches.
- Updates consumers, tests, workspace features, and release notes.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
Cargo.toml |
Enables serde_json raw_value. |
.changeset/tidy-jars-serialize.md |
Records the serialization optimization. |
crates/edr_rpc_client/src/jsonrpc.rs |
Documents RawValue deserialization constraints. |
crates/edr_provider/src/lib.rs |
Stores raw results and adds typed deserialization. |
crates/edr_provider/src/requests/dispatch.rs |
Directly assembles serialized batch arrays. |
crates/edr_provider/src/test_utils.rs |
Adapts provider test helpers. |
crates/edr_provider/tests/common/provider.rs |
Adapts transaction helper deserialization. |
crates/edr_provider/tests/integration/timestamp.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/rip7212.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/issues/issue_407.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/issues/issue_356.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/issues/issue_325.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/issues/issue_324.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/interval_mining.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eth_max_priority_fee_per_gas.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eth_get_proof.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/estimate_gas.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip7843.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip7778.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip7702.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip7691.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip4844.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/eip2537.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/coverage.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/calldata_floor.rs |
Uses typed result deserialization. |
crates/edr_provider/tests/integration/block_timestamp_in_logs.rs |
Uses typed result deserialization. |
crates/edr_op/tests/integration/provider.rs |
Adapts OP provider tests. |
crates/edr_op/tests/integration/isthmus_operator_fee.rs |
Adapts OP fee tests. |
crates/edr_napi/src/mock.rs |
Stores mock responses as raw JSON. |
crates/edr_napi/src/context.rs |
Propagates mock serialization errors. |
crates/edr_napi_core/src/spec.rs |
Optimizes envelope serialization and sizing. |
Suppressed comments (1)
crates/edr_napi_core/src/spec.rs:197
- This function's buffer can grow for error responses because their capacity is fixed at 128 bytes while the serialized error may be larger. Limit the claim to the success path so the documentation matches the implementation.
/// Serializes a JSON-RPC response into a buffer that never has to grow.
///
/// `serde_json::to_string` starts at 128 bytes and doubles, so it copies a
/// large result several times over.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1698 +/- ##
==========================================
+ Coverage 79.97% 80.01% +0.04%
==========================================
Files 462 462
Lines 80509 80685 +176
Branches 80509 80685 +176
==========================================
+ Hits 64388 64564 +176
+ Misses 13893 13891 -2
- Partials 2228 2230 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
🚀 Starting regression benchmark for |
|
❌ Regression benchmark failed for |
|
/bench benchmarks="test mocha,test vitest" |
|
⏳ EDR CI for this commit hasn't passed yet, so the regression benchmark was not started. Comment |
0cc3ee1 to
6509d71
Compare
|
/bench benchmarks="test mocha,test vitest" |
6509d71 to
e074e5c
Compare
|
⏳ EDR CI for this commit hasn't passed yet, so the regression benchmark was not started. Comment |
e074e5c to
be36bad
Compare
|
/bench benchmarks="test mocha,test vitest" |
|
🚀 Starting regression benchmark for |
|
✅ Regression benchmark passed for |
be36bad to
d77f283
Compare
`ResponseWithCallTraces.result` was a `serde_json::Value`, built by `to_json` and walked again by `marshal_response_data`. Holding the serialized bytes instead makes the second pass a copy, and lets a future response cache hand out clones for the price of one memcpy. The wire format is unchanged: a `Box<RawValue>` inside the untagged `jsonrpc::ResponseData` serializes byte-for-byte as the `Value` did, through `#[serde(flatten)]` as well. `result` and `marshal_response_data`'s parameter change type. Readers go through `RawValue::get` or the new `deserialize_result`. Enabling serde_json's `raw_value` feature is workspace-wide, as the crate's other features already are. `MockProvider` now converts its mocked response once, at construction, rather than on every request.
`serde_json::to_string` starts at 128 bytes and doubles, so it copies a large result several times over. A `RawValue` result makes the envelope's length known before it is built, so the buffer can be sized once. That same length also settles the string-limit question up front. An oversized response no longer serializes a discarded 250 MB string before falling back to a `serde_json::Value`. Both arms are pinned against serde's output by `marshal_response_data_matches_serde_for_success` and its error counterpart.
`ResponseData<Box<RawValue>>` compiles and always fails at runtime, and only on the `Success` variant, so a test covering the error path passes. `client::parse_response_str` is generic over `SuccessT`, so anyone forwarding a remote result without re-serializing it would hit this with an error message that points nowhere near the cause.
The batch tests reached every branch, but only through a provider fixture, and neither asserted the serialized text. Nothing covered a value containing a separator, which is what hand-building the array risks getting wrong. Also fixes the reservation for an empty array, which asked for one byte and needed two. `json_array_len` is now a named function so a test can assert the reservation equals the output length.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The limit becomes a parameter of a private helper so a test can reach it without allocating 250 MB. Both `Either::B` sites are now exercised: the oversized-success preflight and the post-serialization fallback. `envelope_len_matches_the_serialized_success` pins the hard-coded envelope overhead against serde, which the limit decision depends on. The precision test records that the fallback re-parses the raw value and that `arbitrary_precision` keeps the digits intact. Mutating the preflight to `>=`, the fallback check to `<`, or the overhead to 10 each fails at least one of these tests.
The name promised more than the test checks. `napi` panics marshalling a `serde_json::Value` number wider than `u64`, so the digits this test pins never reach JS through the `Value` arm. Verified against napi 3.12.1: `as_u64().unwrap()` at serde.rs:180 has no arm for an `arbitrary_precision` integer that fits neither `i64`, `u64` nor `f64`.
d77f283 to
c73070d
Compare
popescuoctavian
left a comment
There was a problem hiding this comment.
LGTM, thanks! Just a small nit
The three `map_err` arms in `marshal_response_data_with_limit` built the same `napi::Error` from a `serde_json::Error`.
Follow-up to #1486, and stacked on it.
ResponseWithCallTraces.resultwas aserde_json::Value. Handlers built it withserde_json::to_value, thenmarshal_response_datawalked the tree again withserde_json::to_string. Holding the serialized bytes instead makes the second pass a copy.The performance gain is not significant but it is a prerequisite for further JSON-RPC round-trip optimisations.
What changes
resultbecomes aBox<serde_json::value::RawValue>, so each handler serializes once. Readers go throughRawValue::getor the newdeserialize_result.marshal_response_datasizes its buffer from the result's length rather than growing from 128 bytes. That same length also decides the string limit up front, so an oversized response no longer serializes a 250 MB string only to discard it.Value::Array.raw_valuefeature is enabled workspace-wide, alongside the features already declared there.Compatibility
The JSON reaching callers is byte-for-byte unchanged. A
Box<RawValue>inside the untaggedjsonrpc::ResponseDataserializes exactly as theValuedid, including through#[serde(flatten)], and arbitrary-precision numbers survive.marshal_response_data_matches_serde_for_successand its error counterpart pin both arms against serde's own output.No
#[napi]signature changes, soindex.d.tsis untouched.Benchmark Results
JS scenario runner benchmark (CI, vs latest main):
synthetix_9a3a109fneptune-mutual-blue-protocol_8db6480rocketpool_6a9dbfd8openzeppelin-contracts_0a5fba7aseaport_585b2ef8uniswap-v3-core_d8b1c63safe-contracts_914d0f8HH3 regression benchmark (CI, vs latest main):
ens-contracts / test vitest3.9861710216199997s (± 0.09548535425823759)4.06399684068s (± 0.012688302868408073)0.98ens-contracts / test vitest (peak RSS)637MB638MB1.00ens-contracts / test vitest (cpu)81.23737028s (± 0)80.07114505999999s (± 0)1.01lidofinance-core / test mocha10.451533232740001s (± 0.11958584155861957)10.36830099496s (± 0.020690816987286226)1.01lidofinance-core / test mocha (peak RSS)795MB804MB0.99lidofinance-core / test mocha (cpu)379.8732149s (± 0)378.19546216s (± 0)1.00openzeppelin-contracts / test mocha57.92275289228s (± 0.019179380485141086)57.84666742306s (± 0.010488213657848557)1.00openzeppelin-contracts / test mocha (peak RSS)3531MB3498MB1.01openzeppelin-contracts / test mocha (cpu)62.59357925999999s (± 0)62.39426504s (± 0)1.00Validation
Notes for review
raw_valueis declared workspace-wide deliberately. Four crates nameRawValue, andedr_rpc_clientsits upstream ofedr_provider, so it never inherits the feature.serde_json::to_writerinto a pre-sized buffer rather than by hand. Concatenating{"result":measures the same but duplicates the wire format of a type in another crate.