Skip to main content

forest/rpc/
parallel_batch_layer.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use std::{borrow::Cow, sync::Arc};
5
6use ahash::HashMap;
7use jsonrpsee::{
8    MethodResponse,
9    core::middleware::{Batch, BatchEntry, Notification},
10    server::{BatchResponseBuilder, middleware::rpc::RpcServiceT},
11    types::{ErrorCode, ErrorObject, Id, Request},
12};
13use tokio::task::JoinSet;
14use tower::Layer;
15
16/// Parallelize batch RPC requests across the `tokio` worker pool.
17///
18/// jsonrpsee processes batches sequentially by default. The
19/// [JSON-RPC spec](https://www.jsonrpc.org/specification#batch) does not
20/// require sequential processing or response ordering, but order is
21/// preserved here to avoid surprising clients.
22#[derive(Clone, derive_more::Constructor)]
23pub(super) struct ParallelBatchLayer {
24    max_response_body_size: usize,
25}
26
27impl<S> Layer<S> for ParallelBatchLayer {
28    type Service = ParallelBatchService<S>;
29
30    fn layer(&self, service: S) -> Self::Service {
31        ParallelBatchService {
32            service: Arc::new(service),
33            max_response_body_size: self.max_response_body_size,
34        }
35    }
36}
37
38#[derive(Clone)]
39pub(super) struct ParallelBatchService<S> {
40    service: Arc<S>,
41    max_response_body_size: usize,
42}
43
44impl<S> RpcServiceT for ParallelBatchService<S>
45where
46    S: RpcServiceT<
47            MethodResponse = MethodResponse,
48            NotificationResponse = MethodResponse,
49            BatchResponse = MethodResponse,
50        > + Send
51        + Sync
52        + 'static,
53{
54    type MethodResponse = S::MethodResponse;
55    type NotificationResponse = S::NotificationResponse;
56    type BatchResponse = S::BatchResponse;
57
58    fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
59        self.service.call(req)
60    }
61
62    fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
63        let max = self.max_response_body_size;
64        let mut got_notification = false;
65        // JoinSet aborts in-flight tasks on drop.
66        let mut join_set: JoinSet<(usize, Option<MethodResponse>)> = JoinSet::new();
67        // Lets a panicked call task be turned into a per-entry error with the
68        // original request id.
69        let mut call_meta: HashMap<tokio::task::Id, (usize, Id<'static>)> = HashMap::default();
70        let mut results: Vec<(usize, Option<MethodResponse>)> = Vec::new();
71
72        for (idx, entry) in batch.into_iter().enumerate() {
73            let service = Arc::clone(&self.service);
74            match entry {
75                Ok(BatchEntry::Call(req)) => {
76                    let req_id = req.id().into_owned();
77                    let req = into_owned_request(req);
78                    let handle =
79                        join_set.spawn(async move { (idx, Some(service.call(req).await)) });
80                    call_meta.insert(handle.id(), (idx, req_id));
81                }
82                Ok(BatchEntry::Notification(n)) => {
83                    got_notification = true;
84                    let n = into_owned_notification(n);
85                    join_set.spawn(async move {
86                        service.notification(n).await;
87                        (idx, None)
88                    });
89                }
90                Err(err) => {
91                    let (err, id) = err.into_parts();
92                    results.push((
93                        idx,
94                        Some(MethodResponse::error(id.into_owned(), err.into_owned())),
95                    ));
96                }
97            }
98        }
99
100        async move {
101            results.reserve(join_set.len());
102            while let Some(joined) = join_set.join_next_with_id().await {
103                match joined {
104                    Ok((_, r)) => results.push(r),
105                    Err(e) if e.is_panic() => {
106                        if let Some((idx, req_id)) = call_meta.remove(&e.id()) {
107                            tracing::error!(idx, "RPC call panicked in batch entry");
108                            let err = ErrorObject::owned::<()>(
109                                ErrorCode::InternalError.code(),
110                                "RPC handler panicked",
111                                None,
112                            );
113                            results.push((idx, Some(MethodResponse::error(req_id, err))));
114                        } else {
115                            tracing::error!("RPC notification panicked in batch entry");
116                        }
117                    }
118                    Err(_) => unreachable!("JoinSet only cancels tasks on drop"),
119                }
120            }
121            results.sort_by_key(|(idx, _)| *idx);
122
123            let mut batch_rp = BatchResponseBuilder::new_with_limit(max);
124            for (_, rp) in results {
125                if let Some(rp) = rp
126                    && let Err(err) = batch_rp.append(rp)
127                {
128                    return err;
129                }
130            }
131
132            // Empty builder + at least one notification is the spec's
133            // "no response" case for a notification-only batch.
134            if batch_rp.is_empty() && got_notification {
135                MethodResponse::notification()
136            } else {
137                MethodResponse::from_batch(batch_rp.finish())
138            }
139        }
140    }
141
142    fn notification<'a>(
143        &self,
144        n: Notification<'a>,
145    ) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
146        self.service.notification(n)
147    }
148}
149
150fn into_owned_request(req: Request<'_>) -> Request<'static> {
151    Request {
152        jsonrpc: req.jsonrpc,
153        id: req.id.into_owned(),
154        method: Cow::Owned(req.method.into_owned()),
155        params: req.params.map(|p| Cow::Owned(p.into_owned())),
156        extensions: req.extensions,
157    }
158}
159
160fn into_owned_notification(n: Notification<'_>) -> Notification<'static> {
161    Notification {
162        jsonrpc: n.jsonrpc,
163        method: Cow::Owned(n.method.into_owned()),
164        params: n.params.map(|p| Cow::Owned(p.into_owned())),
165        extensions: n.extensions,
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use jsonrpsee::core::middleware::BatchEntryErr;
173    use jsonrpsee::server::ResponsePayload;
174    use jsonrpsee::types::{Extensions, TwoPointZero};
175    use std::time::Duration;
176
177    const MAX_RESP: usize = 1024 * 1024;
178
179    /// Method conventions used by tests:
180    ///   "ok"        – success response carrying the method name.
181    ///   "slow:<ms>" – sleep, then succeed.
182    ///   "panic"     – panic inside the call task.
183    #[derive(Clone, Default)]
184    struct TestService;
185
186    impl RpcServiceT for TestService {
187        type MethodResponse = MethodResponse;
188        type NotificationResponse = MethodResponse;
189        type BatchResponse = MethodResponse;
190
191        fn call<'a>(
192            &self,
193            req: Request<'a>,
194        ) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
195            let id = req.id().into_owned();
196            let method = req.method_name().to_string();
197            async move {
198                if method == "panic" {
199                    panic!("test panic");
200                }
201                if let Some(rest) = method.strip_prefix("slow:") {
202                    let ms: u64 = rest.parse().unwrap();
203                    tokio::time::sleep(Duration::from_millis(ms)).await;
204                }
205                MethodResponse::response(id, ResponsePayload::success(method), MAX_RESP)
206            }
207        }
208
209        // `async fn` form drops the explicit `'a` capture the trait wants,
210        // and the `manual_async_fn` lint fires on trivial `async {}` bodies.
211        #[expect(clippy::manual_async_fn, reason = "trait demands explicit 'a")]
212        fn batch<'a>(
213            &self,
214            _b: Batch<'a>,
215        ) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
216            async { unreachable!("ParallelBatchLayer overrides this") }
217        }
218
219        #[expect(clippy::manual_async_fn, reason = "trait demands explicit 'a")]
220        fn notification<'a>(
221            &self,
222            _n: Notification<'a>,
223        ) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
224            async { MethodResponse::notification() }
225        }
226    }
227
228    fn layer() -> ParallelBatchService<TestService> {
229        ParallelBatchService {
230            service: Arc::new(TestService),
231            max_response_body_size: MAX_RESP,
232        }
233    }
234
235    fn call(id: u64, method: &str) -> Request<'static> {
236        Request::owned(method.to_string(), None, Id::Number(id))
237    }
238
239    fn notification(method: &str) -> Notification<'static> {
240        Notification {
241            jsonrpc: TwoPointZero,
242            method: Cow::Owned(method.to_string()),
243            params: None,
244            extensions: Extensions::new(),
245        }
246    }
247
248    fn as_array(rp: &MethodResponse) -> Vec<serde_json::Value> {
249        serde_json::from_str::<Vec<serde_json::Value>>(rp.as_json().get()).unwrap()
250    }
251
252    #[tokio::test]
253    async fn preserves_order_under_heterogeneous_latency() {
254        let svc = layer();
255        let batch = Batch::from(vec![
256            Ok(BatchEntry::Call(call(1, "slow:50"))),
257            Ok(BatchEntry::Call(call(2, "ok"))),
258            Ok(BatchEntry::Call(call(3, "slow:25"))),
259        ]);
260        let arr = as_array(&svc.batch(batch).await);
261        assert_eq!(arr.len(), 3);
262        assert_eq!(arr[0]["id"], 1);
263        assert_eq!(arr[1]["id"], 2);
264        assert_eq!(arr[2]["id"], 3);
265    }
266
267    #[tokio::test]
268    async fn panicked_call_yields_per_entry_error() {
269        let svc = layer();
270        let batch = Batch::from(vec![
271            Ok(BatchEntry::Call(call(1, "ok"))),
272            Ok(BatchEntry::Call(call(2, "panic"))),
273            Ok(BatchEntry::Call(call(3, "ok"))),
274        ]);
275        let arr = as_array(&svc.batch(batch).await);
276        assert_eq!(arr.len(), 3);
277        assert_eq!(arr[0]["id"], 1);
278        assert!(arr[0]["result"].is_string(), "first entry should succeed");
279        assert_eq!(arr[1]["id"], 2);
280        assert!(
281            arr[1]["error"].is_object(),
282            "panicked entry must carry its own error"
283        );
284        assert_eq!(arr[2]["id"], 3);
285        assert!(arr[2]["result"].is_string(), "third entry should succeed");
286    }
287
288    #[tokio::test]
289    async fn notification_only_batch_returns_notification() {
290        let svc = layer();
291        let batch = Batch::from(vec![Ok(BatchEntry::Notification(notification("ok")))]);
292        let resp = svc.batch(batch).await;
293        assert!(resp.is_notification());
294    }
295
296    #[tokio::test]
297    async fn entry_err_preserves_index() {
298        let svc = layer();
299        let batch = Batch::from(vec![
300            Ok(BatchEntry::Call(call(1, "ok"))),
301            Err(BatchEntryErr::new(
302                Id::Number(2),
303                ErrorObject::from(ErrorCode::InvalidRequest),
304            )),
305            Ok(BatchEntry::Call(call(3, "ok"))),
306        ]);
307        let arr = as_array(&svc.batch(batch).await);
308        assert_eq!(arr.len(), 3);
309        assert_eq!(arr[0]["id"], 1);
310        assert_eq!(arr[1]["id"], 2);
311        assert!(arr[1]["error"].is_object());
312        assert_eq!(arr[2]["id"], 3);
313    }
314}