Skip to main content

iota_network_stack/
concurrency.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-service concurrency limiting for gRPC servers.
5//!
6//! Unlike [`tower::limit::GlobalConcurrencyLimitLayer`] applied around a whole
7//! server, [`ServiceConcurrencyLimit`] bounds the in-flight requests of a
8//! single gRPC service, so services sharing one listener cannot crowd each
9//! other out of admission slots.
10//!
11//! Tower's per-service [`tower::limit::ConcurrencyLimitLayer`] cannot be used
12//! here: its `ConcurrencyLimit` wrapper does not implement tonic's
13//! [`NamedService`], which `Routes::add_service` requires for routing, and
14//! shedding through tower's `LoadShed` surfaces as a `BoxError`, incompatible
15//! with the router's `Error = Infallible` bound — over-limit requests must be
16//! answered in-band with a gRPC `RESOURCE_EXHAUSTED` response instead.
17
18use std::{
19    convert::Infallible,
20    num::NonZeroUsize,
21    pin::Pin,
22    sync::{Arc, Mutex, PoisonError},
23    task::{Context, Poll, ready},
24    time::Duration,
25};
26
27use bytes::Bytes;
28use futures::future::BoxFuture;
29use http_body::Frame;
30use pin_project_lite::pin_project;
31use tokio::{sync::Semaphore, task::AbortHandle};
32use tonic::{
33    Status,
34    body::Body,
35    codegen::http::{Request, Response},
36    server::NamedService,
37};
38use tower::Service;
39
40type BoxError = Box<dyn std::error::Error + Send + Sync>;
41
42/// Bounds the number of concurrent in-flight requests to the wrapped gRPC
43/// service, independently of any other service registered on the same server.
44///
45/// With `load_shed` enabled, requests over the limit are rejected immediately
46/// with gRPC `RESOURCE_EXHAUSTED`; otherwise they wait for a slot to free up.
47/// Clones share the same limit.
48#[derive(Clone)]
49pub struct ServiceConcurrencyLimit<S> {
50    inner: S,
51    semaphore: Arc<Semaphore>,
52    load_shed: bool,
53}
54
55impl<S> ServiceConcurrencyLimit<S> {
56    pub fn new(inner: S, limit: NonZeroUsize, load_shed: bool) -> Self {
57        Self {
58            inner,
59            // Clamp: `Semaphore::new` panics above `MAX_PERMITS`, and
60            // effectively-unlimited configs multiply large values by the CPU
61            // core count.
62            semaphore: Arc::new(Semaphore::new(limit.get().min(Semaphore::MAX_PERMITS))),
63            load_shed,
64        }
65    }
66}
67
68impl<S> Service<Request<Body>> for ServiceConcurrencyLimit<S>
69where
70    S: Service<Request<Body>, Response = Response<Body>, Error = Infallible>
71        + Clone
72        + Send
73        + 'static,
74    S::Future: Send + 'static,
75{
76    type Response = Response<Body>;
77    type Error = Infallible;
78    type Future = BoxFuture<'static, Result<Response<Body>, Infallible>>;
79
80    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
81        self.inner.poll_ready(cx)
82    }
83
84    fn call(&mut self, request: Request<Body>) -> Self::Future {
85        // Admission happens here rather than in `poll_ready` (where
86        // `tower::limit::ConcurrencyLimit` acquires its permit): a shed
87        // request must be answered with a gRPC response, and `poll_ready`
88        // can only signal Ready or Pending — converting Pending into an
89        // error via an outer load-shed layer is ruled out by the router's
90        // `Error = Infallible` bound. Readiness-based acquisition would
91        // also buy no upstream backpressure: the axum router dispatches
92        // every request on a fresh clone of this service.
93        //
94        // Take the instance that was driven to readiness and leave the clone
95        // for later calls, as `poll_ready` readiness does not transfer to
96        // clones.
97        let clone = self.inner.clone();
98        let mut inner = std::mem::replace(&mut self.inner, clone);
99        let semaphore = self.semaphore.clone();
100        let load_shed = self.load_shed;
101
102        Box::pin(async move {
103            // The permit is held until the response future resolves, mirroring
104            // `tower::limit::ConcurrencyLimit`.
105            let _permit = if load_shed {
106                match semaphore.try_acquire_owned() {
107                    Ok(permit) => permit,
108                    Err(_) => {
109                        return Ok(tonic::Status::resource_exhausted(
110                            "service concurrency limit reached",
111                        )
112                        .into_http());
113                    }
114                }
115            } else {
116                semaphore
117                    .acquire_owned()
118                    .await
119                    .expect("the semaphore is never closed")
120            };
121            inner.call(request).await
122        })
123    }
124}
125
126impl<S: NamedService> NamedService for ServiceConcurrencyLimit<S> {
127    const NAME: &'static str = S::NAME;
128}
129
130pin_project! {
131    /// Response body owning a guard, typically a semaphore permit, that is
132    /// released when the body is dropped: once the response has been written,
133    /// reset, or abandoned.
134    pub struct PermitGuardedBody<B, G> {
135        #[pin]
136        inner: B,
137        _guard: Option<G>,
138    }
139}
140
141impl<B, G> PermitGuardedBody<B, G> {
142    /// `guard` is `None` when the caller holds no permit for this response.
143    pub fn new(inner: B, guard: Option<G>) -> Self {
144        Self {
145            inner,
146            _guard: guard,
147        }
148    }
149}
150
151impl<B, G> http_body::Body for PermitGuardedBody<B, G>
152where
153    B: http_body::Body,
154{
155    type Data = B::Data;
156    type Error = B::Error;
157
158    fn poll_frame(
159        self: Pin<&mut Self>,
160        cx: &mut Context<'_>,
161    ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
162        self.project().inner.poll_frame(cx)
163    }
164
165    fn is_end_stream(&self) -> bool {
166        self.inner.is_end_stream()
167    }
168
169    fn size_hint(&self) -> http_body::SizeHint {
170        self.inner.size_hint()
171    }
172}
173
174/// Largest data frame a `ReclaimableBody` with a deadline hands to the
175/// transport. Whatever has been handed over sits in the stream's send buffer
176/// out of reach, so a small frame keeps what a reclaim cannot free small.
177const MAX_FRAME_BYTES: usize = 64 * 1024;
178
179/// What a `ReclaimableBody` gives up at its deadline.
180struct Held<B, D> {
181    inner: B,
182    /// Rest of the data frame being handed over in `MAX_FRAME_BYTES` pieces.
183    pending: Bytes,
184    _until_deadline: Option<D>,
185}
186
187pin_project! {
188    /// Response body that gives up its contents when a deadline passes before
189    /// it has been sent. The transport polls a body only when the receiver
190    /// grants it window, so a receiver that stops reading leaves the body
191    /// parked, together with everything it owns; the deadline takes the inner
192    /// body and `until_deadline` away from under it, and any later poll yields
193    /// an error. `until_end` stays until the body itself is dropped, once the
194    /// stream has been written, reset or abandoned.
195    pub struct ReclaimableBody<B, D, E> {
196        held: Arc<Mutex<Option<Held<B, D>>>>,
197        _until_end: Option<E>,
198        timer: Option<AbortHandle>,
199    }
200
201    impl<B, D, E> PinnedDrop for ReclaimableBody<B, D, E> {
202        fn drop(this: Pin<&mut Self>) {
203            if let Some(timer) = &this.timer {
204                timer.abort();
205            }
206        }
207    }
208}
209
210impl<B, D, E> ReclaimableBody<B, D, E>
211where
212    B: Send + 'static,
213    D: Send + 'static,
214{
215    /// A body with no deadline, handing over the inner body's frames as they
216    /// come; `until_deadline` and `until_end` are then both released when it is
217    /// dropped.
218    pub fn new(inner: B, until_deadline: Option<D>, until_end: Option<E>) -> Self {
219        Self {
220            held: Arc::new(Mutex::new(Some(Held {
221                inner,
222                pending: Bytes::new(),
223                _until_deadline: until_deadline,
224            }))),
225            _until_end: until_end,
226            timer: None,
227        }
228    }
229
230    /// A body that gives up `inner` and `until_deadline` once `deadline` has
231    /// passed, calling `on_reclaim` when it does.
232    pub fn with_deadline(
233        inner: B,
234        until_deadline: Option<D>,
235        until_end: Option<E>,
236        deadline: Duration,
237        on_reclaim: impl FnOnce() + Send + 'static,
238    ) -> Self {
239        let mut body = Self::new(inner, until_deadline, until_end);
240        let held = body.held.clone();
241        let timer = tokio::spawn(async move {
242            tokio::time::sleep(deadline).await;
243            let reclaimed = held.lock().unwrap_or_else(PoisonError::into_inner).take();
244            if reclaimed.is_some() {
245                on_reclaim();
246            }
247        });
248        body.timer = Some(timer.abort_handle());
249        body
250    }
251}
252
253impl<B, D, E> http_body::Body for ReclaimableBody<B, D, E>
254where
255    B: http_body::Body<Data = Bytes> + Unpin,
256    B::Error: Into<BoxError>,
257{
258    type Data = Bytes;
259    type Error = BoxError;
260
261    fn poll_frame(
262        self: Pin<&mut Self>,
263        cx: &mut Context<'_>,
264    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
265        let this = self.project();
266        let mut held = this.held.lock().unwrap_or_else(PoisonError::into_inner);
267        let Some(state) = held.as_mut() else {
268            return Poll::Ready(Some(Err(Box::new(Status::deadline_exceeded(
269                "response was not read in time",
270            )))));
271        };
272        if state.pending.is_empty() {
273            match ready!(Pin::new(&mut state.inner).poll_frame(cx)) {
274                None => {
275                    // The stream is over; nothing is left to reclaim.
276                    if let Some(timer) = this.timer.take() {
277                        timer.abort();
278                    }
279                    return Poll::Ready(None);
280                }
281                Some(Err(error)) => return Poll::Ready(Some(Err(error.into()))),
282                Some(Ok(frame)) => match frame.into_data() {
283                    // Without a deadline nothing is reclaimed, so the chunk goes out whole.
284                    Ok(data) if this.timer.is_none() => {
285                        return Poll::Ready(Some(Ok(Frame::data(data))));
286                    }
287                    Ok(data) => state.pending = data,
288                    Err(frame) => {
289                        if let Some(timer) = this.timer.take() {
290                            timer.abort();
291                        }
292                        return Poll::Ready(Some(Ok(frame)));
293                    }
294                },
295            }
296        }
297        let piece = state
298            .pending
299            .split_to(state.pending.len().min(MAX_FRAME_BYTES));
300        // A copy: a slice would keep the whole buffer it came from alive in the
301        // send buffer.
302        Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(&piece)))))
303    }
304
305    fn is_end_stream(&self) -> bool {
306        let held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
307        held.as_ref()
308            .is_some_and(|state| state.pending.is_empty() && state.inner.is_end_stream())
309    }
310
311    fn size_hint(&self) -> http_body::SizeHint {
312        let held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
313        match held.as_ref() {
314            Some(state) => {
315                let pending = state.pending.len() as u64;
316                let mut hint = state.inner.size_hint();
317                // Upper first: `set_lower` asserts the new lower bound is within it.
318                if let Some(upper) = hint.upper() {
319                    hint.set_upper(upper.saturating_add(pending));
320                }
321                hint.set_lower(hint.lower().saturating_add(pending));
322                hint
323            }
324            None => http_body::SizeHint::default(),
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use std::sync::atomic::{AtomicBool, Ordering};
332
333    use http_body::Body as _;
334    use tower::ServiceExt;
335
336    use super::*;
337
338    /// Inner service whose responses only complete once `release` is
339    /// notified, keeping requests in flight for as long as the test needs.
340    #[derive(Clone)]
341    struct BlockingService {
342        release: Arc<tokio::sync::Notify>,
343    }
344
345    impl Service<Request<Body>> for BlockingService {
346        type Response = Response<Body>;
347        type Error = Infallible;
348        type Future = BoxFuture<'static, Result<Response<Body>, Infallible>>;
349
350        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
351            Poll::Ready(Ok(()))
352        }
353
354        fn call(&mut self, _request: Request<Body>) -> Self::Future {
355            let release = self.release.clone();
356            Box::pin(async move {
357                release.notified().await;
358                Ok(Response::new(Body::default()))
359            })
360        }
361    }
362
363    fn request() -> Request<Body> {
364        Request::new(Body::default())
365    }
366
367    #[tokio::test]
368    async fn load_shedding_rejects_requests_over_the_limit() {
369        let release = Arc::new(tokio::sync::Notify::new());
370        let service = ServiceConcurrencyLimit::new(
371            BlockingService {
372                release: release.clone(),
373            },
374            NonZeroUsize::MIN,
375            true,
376        );
377
378        let in_flight = tokio::spawn(service.clone().oneshot(request()));
379        tokio::task::yield_now().await;
380
381        let shed = service.clone().oneshot(request()).await.unwrap();
382        assert_eq!(
383            shed.headers().get("grpc-status").unwrap(),
384            &(tonic::Code::ResourceExhausted as i32).to_string()
385        );
386
387        release.notify_one();
388        let response = in_flight.await.unwrap().unwrap();
389        assert!(response.headers().get("grpc-status").is_none());
390    }
391
392    #[tokio::test]
393    async fn without_load_shedding_requests_over_the_limit_wait() {
394        let release = Arc::new(tokio::sync::Notify::new());
395        let service = ServiceConcurrencyLimit::new(
396            BlockingService {
397                release: release.clone(),
398            },
399            NonZeroUsize::MIN,
400            false,
401        );
402
403        let first = tokio::spawn(service.clone().oneshot(request()));
404        tokio::task::yield_now().await;
405
406        let mut second = tokio::spawn(service.clone().oneshot(request()));
407        let waiting = tokio::time::timeout(Duration::from_millis(50), &mut second).await;
408        assert!(waiting.is_err(), "second request should wait for a slot");
409
410        release.notify_one();
411        first.await.unwrap().unwrap();
412        release.notify_one();
413        second.await.unwrap().unwrap();
414    }
415
416    #[tokio::test]
417    async fn limits_are_independent_per_service() {
418        let release = Arc::new(tokio::sync::Notify::new());
419        let blocking = BlockingService {
420            release: release.clone(),
421        };
422        let saturated = ServiceConcurrencyLimit::new(blocking.clone(), NonZeroUsize::MIN, true);
423        let other = ServiceConcurrencyLimit::new(blocking, NonZeroUsize::MIN, true);
424
425        let in_flight = tokio::spawn(saturated.clone().oneshot(request()));
426        tokio::task::yield_now().await;
427
428        // The other service has its own semaphore and still admits requests.
429        let admitted = tokio::spawn(other.oneshot(request()));
430        tokio::task::yield_now().await;
431
432        release.notify_waiters();
433        assert!(
434            in_flight
435                .await
436                .unwrap()
437                .unwrap()
438                .headers()
439                .get("grpc-status")
440                .is_none()
441        );
442        assert!(
443            admitted
444                .await
445                .unwrap()
446                .unwrap()
447                .headers()
448                .get("grpc-status")
449                .is_none()
450        );
451    }
452
453    /// Guard recording whether it has been dropped.
454    struct DropFlag(Arc<AtomicBool>);
455
456    impl Drop for DropFlag {
457        fn drop(&mut self) {
458            self.0.store(true, Ordering::SeqCst);
459        }
460    }
461
462    /// Body that never yields a frame, standing in for a response still being
463    /// streamed.
464    struct PendingBody;
465
466    impl http_body::Body for PendingBody {
467        type Data = bytes::Bytes;
468        type Error = Infallible;
469
470        fn poll_frame(
471            self: Pin<&mut Self>,
472            _cx: &mut Context<'_>,
473        ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
474            Poll::Pending
475        }
476    }
477
478    #[tokio::test]
479    async fn the_guard_survives_the_end_of_the_stream() {
480        let dropped = Arc::new(AtomicBool::new(false));
481        let mut body = PermitGuardedBody::new(Body::default(), Some(DropFlag(dropped.clone())));
482
483        let end = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await;
484        assert!(end.is_none());
485        assert!(!dropped.load(Ordering::SeqCst));
486
487        drop(body);
488        assert!(dropped.load(Ordering::SeqCst));
489    }
490
491    #[tokio::test]
492    async fn dropping_an_unfinished_body_releases_the_guard() {
493        let dropped = Arc::new(AtomicBool::new(false));
494        let mut body = PermitGuardedBody::new(PendingBody, Some(DropFlag(dropped.clone())));
495
496        let polled = tokio::time::timeout(
497            Duration::from_millis(10),
498            std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)),
499        )
500        .await;
501        assert!(polled.is_err());
502
503        drop(body);
504        assert!(dropped.load(Ordering::SeqCst));
505    }
506
507    const DEADLINE: Duration = Duration::from_millis(30);
508
509    fn flag() -> (Arc<AtomicBool>, DropFlag) {
510        let raised = Arc::new(AtomicBool::new(false));
511        (raised.clone(), DropFlag(raised))
512    }
513
514    /// A reclaim callback and the flag it raises when called.
515    fn report() -> (Arc<AtomicBool>, impl FnOnce() + Send + 'static) {
516        let reported = Arc::new(AtomicBool::new(false));
517        let raise = reported.clone();
518        (reported, move || raise.store(true, Ordering::SeqCst))
519    }
520
521    /// Reads one frame, or the end of the body.
522    async fn next_frame<B: http_body::Body + Unpin>(
523        body: &mut B,
524    ) -> Option<Result<Frame<B::Data>, B::Error>> {
525        std::future::poll_fn(|cx| Pin::new(&mut *body).poll_frame(cx)).await
526    }
527
528    #[tokio::test]
529    async fn reclaimable_body_hands_over_frames_of_bounded_size() {
530        let inner = http_body_util::Full::new(Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2 + 5]));
531        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
532            inner,
533            None,
534            None,
535            Duration::from_secs(3600),
536            || {},
537        );
538
539        let mut sizes = Vec::new();
540        let mut remaining = vec![body.size_hint().exact()];
541        while let Some(frame) = next_frame(&mut body).await {
542            sizes.push(frame.unwrap().into_data().unwrap().len());
543            remaining.push(body.size_hint().exact());
544        }
545        assert_eq!(sizes, [MAX_FRAME_BYTES, MAX_FRAME_BYTES, 5]);
546        let frame = MAX_FRAME_BYTES as u64;
547        assert_eq!(
548            remaining,
549            [Some(2 * frame + 5), Some(frame + 5), Some(5), Some(0)]
550        );
551    }
552
553    #[tokio::test]
554    async fn the_deadline_reclaims_the_body_and_keeps_the_end_guard() {
555        let (reclaimed_deadline, until_deadline) = flag();
556        let (reclaimed_end, until_end) = flag();
557        let (reported, on_reclaim) = report();
558        let mut body = ReclaimableBody::with_deadline(
559            PendingBody,
560            Some(until_deadline),
561            Some(until_end),
562            DEADLINE,
563            on_reclaim,
564        );
565
566        tokio::time::sleep(DEADLINE * 3).await;
567        assert!(reclaimed_deadline.load(Ordering::SeqCst));
568        assert!(reported.load(Ordering::SeqCst));
569        assert!(!reclaimed_end.load(Ordering::SeqCst));
570        // The transport, once the peer lets it poll again, gets an error.
571        let error = next_frame(&mut body).await.unwrap().unwrap_err();
572        assert_eq!(
573            Status::from_error(error).code(),
574            tonic::Code::DeadlineExceeded
575        );
576
577        drop(body);
578        assert!(reclaimed_end.load(Ordering::SeqCst));
579    }
580
581    #[tokio::test]
582    async fn a_body_read_to_the_end_is_never_reclaimed() {
583        let (reclaimed_deadline, until_deadline) = flag();
584        let (reported, on_reclaim) = report();
585        let mut body = ReclaimableBody::<_, _, DropFlag>::with_deadline(
586            Body::default(),
587            Some(until_deadline),
588            None,
589            DEADLINE,
590            on_reclaim,
591        );
592
593        assert!(next_frame(&mut body).await.is_none());
594        tokio::time::sleep(DEADLINE * 3).await;
595        assert!(!reclaimed_deadline.load(Ordering::SeqCst));
596        assert!(!reported.load(Ordering::SeqCst));
597
598        drop(body);
599        assert!(reclaimed_deadline.load(Ordering::SeqCst));
600    }
601
602    #[tokio::test]
603    async fn dropping_a_reclaimable_body_stops_its_deadline() {
604        let (reported, on_reclaim) = report();
605        let body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
606            PendingBody,
607            None,
608            None,
609            DEADLINE,
610            on_reclaim,
611        );
612
613        drop(body);
614        tokio::time::sleep(DEADLINE * 3).await;
615        assert!(!reported.load(Ordering::SeqCst));
616    }
617
618    #[tokio::test]
619    async fn the_deadline_frees_the_buffer_behind_a_piece_already_handed_over() {
620        let response = Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2]);
621        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
622            http_body_util::Full::new(response.clone()),
623            None,
624            None,
625            DEADLINE,
626            || {},
627        );
628        // The transport holds on to the piece while the peer is not reading.
629        let piece = next_frame(&mut body)
630            .await
631            .unwrap()
632            .unwrap()
633            .into_data()
634            .unwrap();
635
636        tokio::time::sleep(DEADLINE * 3).await;
637        assert!(
638            response.is_unique(),
639            "the handed-over piece must not keep the response buffer alive"
640        );
641        drop((piece, body));
642    }
643
644    #[tokio::test]
645    async fn a_body_without_a_deadline_hands_over_frames_whole() {
646        let chunk = Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2 + 5]);
647        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::new(
648            http_body_util::Full::new(chunk.clone()),
649            None,
650            None,
651        );
652
653        let frame = next_frame(&mut body)
654            .await
655            .unwrap()
656            .unwrap()
657            .into_data()
658            .unwrap();
659        assert_eq!(frame.len(), chunk.len());
660        assert_eq!(
661            frame.as_ptr(),
662            chunk.as_ptr(),
663            "the chunk must not be copied"
664        );
665        assert!(next_frame(&mut body).await.is_none());
666    }
667}