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> Default for ReclaimableBody<B, D, E>
254where
255    B: Default + Send + 'static,
256    D: Send + 'static,
257{
258    /// A body with no deadline that hands over `B::default()` and holds no
259    /// `until_deadline` or `until_end`.
260    fn default() -> Self {
261        Self::new(B::default(), None, None)
262    }
263}
264
265impl<B, D, E> http_body::Body for ReclaimableBody<B, D, E>
266where
267    B: http_body::Body<Data = Bytes> + Unpin,
268    B::Error: Into<BoxError>,
269{
270    type Data = Bytes;
271    type Error = BoxError;
272
273    fn poll_frame(
274        self: Pin<&mut Self>,
275        cx: &mut Context<'_>,
276    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
277        let this = self.project();
278        let mut held = this.held.lock().unwrap_or_else(PoisonError::into_inner);
279        let Some(state) = held.as_mut() else {
280            return Poll::Ready(Some(Err(Box::new(Status::deadline_exceeded(
281                "response was not read in time",
282            )))));
283        };
284        if state.pending.is_empty() {
285            match ready!(Pin::new(&mut state.inner).poll_frame(cx)) {
286                None => {
287                    // The stream is over; nothing is left to reclaim.
288                    if let Some(timer) = this.timer.take() {
289                        timer.abort();
290                    }
291                    return Poll::Ready(None);
292                }
293                Some(Err(error)) => return Poll::Ready(Some(Err(error.into()))),
294                Some(Ok(frame)) => match frame.into_data() {
295                    // Without a deadline nothing is reclaimed, so the chunk goes out whole.
296                    Ok(data) if this.timer.is_none() => {
297                        return Poll::Ready(Some(Ok(Frame::data(data))));
298                    }
299                    Ok(data) => state.pending = data,
300                    Err(frame) => {
301                        if let Some(timer) = this.timer.take() {
302                            timer.abort();
303                        }
304                        return Poll::Ready(Some(Ok(frame)));
305                    }
306                },
307            }
308        }
309        let piece = state
310            .pending
311            .split_to(state.pending.len().min(MAX_FRAME_BYTES));
312        // A copy: a slice would keep the whole buffer it came from alive in the
313        // send buffer.
314        Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(&piece)))))
315    }
316
317    fn is_end_stream(&self) -> bool {
318        let held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
319        held.as_ref()
320            .is_some_and(|state| state.pending.is_empty() && state.inner.is_end_stream())
321    }
322
323    fn size_hint(&self) -> http_body::SizeHint {
324        let held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
325        match held.as_ref() {
326            Some(state) => {
327                let pending = state.pending.len() as u64;
328                let mut hint = state.inner.size_hint();
329                // Upper first: `set_lower` asserts the new lower bound is within it.
330                if let Some(upper) = hint.upper() {
331                    hint.set_upper(upper.saturating_add(pending));
332                }
333                hint.set_lower(hint.lower().saturating_add(pending));
334                hint
335            }
336            None => http_body::SizeHint::default(),
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use std::sync::atomic::{AtomicBool, Ordering};
344
345    use http_body::Body as _;
346    use tower::ServiceExt;
347
348    use super::*;
349
350    /// Inner service whose responses only complete once `release` is
351    /// notified, keeping requests in flight for as long as the test needs.
352    #[derive(Clone)]
353    struct BlockingService {
354        release: Arc<tokio::sync::Notify>,
355    }
356
357    impl Service<Request<Body>> for BlockingService {
358        type Response = Response<Body>;
359        type Error = Infallible;
360        type Future = BoxFuture<'static, Result<Response<Body>, Infallible>>;
361
362        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
363            Poll::Ready(Ok(()))
364        }
365
366        fn call(&mut self, _request: Request<Body>) -> Self::Future {
367            let release = self.release.clone();
368            Box::pin(async move {
369                release.notified().await;
370                Ok(Response::new(Body::default()))
371            })
372        }
373    }
374
375    fn request() -> Request<Body> {
376        Request::new(Body::default())
377    }
378
379    #[tokio::test]
380    async fn load_shedding_rejects_requests_over_the_limit() {
381        let release = Arc::new(tokio::sync::Notify::new());
382        let service = ServiceConcurrencyLimit::new(
383            BlockingService {
384                release: release.clone(),
385            },
386            NonZeroUsize::MIN,
387            true,
388        );
389
390        let in_flight = tokio::spawn(service.clone().oneshot(request()));
391        tokio::task::yield_now().await;
392
393        let shed = service.clone().oneshot(request()).await.unwrap();
394        assert_eq!(
395            shed.headers().get("grpc-status").unwrap(),
396            &(tonic::Code::ResourceExhausted as i32).to_string()
397        );
398
399        release.notify_one();
400        let response = in_flight.await.unwrap().unwrap();
401        assert!(response.headers().get("grpc-status").is_none());
402    }
403
404    #[tokio::test]
405    async fn without_load_shedding_requests_over_the_limit_wait() {
406        let release = Arc::new(tokio::sync::Notify::new());
407        let service = ServiceConcurrencyLimit::new(
408            BlockingService {
409                release: release.clone(),
410            },
411            NonZeroUsize::MIN,
412            false,
413        );
414
415        let first = tokio::spawn(service.clone().oneshot(request()));
416        tokio::task::yield_now().await;
417
418        let mut second = tokio::spawn(service.clone().oneshot(request()));
419        let waiting = tokio::time::timeout(Duration::from_millis(50), &mut second).await;
420        assert!(waiting.is_err(), "second request should wait for a slot");
421
422        release.notify_one();
423        first.await.unwrap().unwrap();
424        release.notify_one();
425        second.await.unwrap().unwrap();
426    }
427
428    #[tokio::test]
429    async fn limits_are_independent_per_service() {
430        let release = Arc::new(tokio::sync::Notify::new());
431        let blocking = BlockingService {
432            release: release.clone(),
433        };
434        let saturated = ServiceConcurrencyLimit::new(blocking.clone(), NonZeroUsize::MIN, true);
435        let other = ServiceConcurrencyLimit::new(blocking, NonZeroUsize::MIN, true);
436
437        let in_flight = tokio::spawn(saturated.clone().oneshot(request()));
438        tokio::task::yield_now().await;
439
440        // The other service has its own semaphore and still admits requests.
441        let admitted = tokio::spawn(other.oneshot(request()));
442        tokio::task::yield_now().await;
443
444        release.notify_waiters();
445        assert!(
446            in_flight
447                .await
448                .unwrap()
449                .unwrap()
450                .headers()
451                .get("grpc-status")
452                .is_none()
453        );
454        assert!(
455            admitted
456                .await
457                .unwrap()
458                .unwrap()
459                .headers()
460                .get("grpc-status")
461                .is_none()
462        );
463    }
464
465    /// Guard recording whether it has been dropped.
466    struct DropFlag(Arc<AtomicBool>);
467
468    impl Drop for DropFlag {
469        fn drop(&mut self) {
470            self.0.store(true, Ordering::SeqCst);
471        }
472    }
473
474    /// Body that never yields a frame, standing in for a response still being
475    /// streamed.
476    struct PendingBody;
477
478    impl http_body::Body for PendingBody {
479        type Data = bytes::Bytes;
480        type Error = Infallible;
481
482        fn poll_frame(
483            self: Pin<&mut Self>,
484            _cx: &mut Context<'_>,
485        ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
486            Poll::Pending
487        }
488    }
489
490    #[tokio::test]
491    async fn the_guard_survives_the_end_of_the_stream() {
492        let dropped = Arc::new(AtomicBool::new(false));
493        let mut body = PermitGuardedBody::new(Body::default(), Some(DropFlag(dropped.clone())));
494
495        let end = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await;
496        assert!(end.is_none());
497        assert!(!dropped.load(Ordering::SeqCst));
498
499        drop(body);
500        assert!(dropped.load(Ordering::SeqCst));
501    }
502
503    #[tokio::test]
504    async fn dropping_an_unfinished_body_releases_the_guard() {
505        let dropped = Arc::new(AtomicBool::new(false));
506        let mut body = PermitGuardedBody::new(PendingBody, Some(DropFlag(dropped.clone())));
507
508        let polled = tokio::time::timeout(
509            Duration::from_millis(10),
510            std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)),
511        )
512        .await;
513        assert!(polled.is_err());
514
515        drop(body);
516        assert!(dropped.load(Ordering::SeqCst));
517    }
518
519    const DEADLINE: Duration = Duration::from_millis(30);
520
521    fn flag() -> (Arc<AtomicBool>, DropFlag) {
522        let raised = Arc::new(AtomicBool::new(false));
523        (raised.clone(), DropFlag(raised))
524    }
525
526    /// A reclaim callback and the flag it raises when called.
527    fn report() -> (Arc<AtomicBool>, impl FnOnce() + Send + 'static) {
528        let reported = Arc::new(AtomicBool::new(false));
529        let raise = reported.clone();
530        (reported, move || raise.store(true, Ordering::SeqCst))
531    }
532
533    /// Reads one frame, or the end of the body.
534    async fn next_frame<B: http_body::Body + Unpin>(
535        body: &mut B,
536    ) -> Option<Result<Frame<B::Data>, B::Error>> {
537        std::future::poll_fn(|cx| Pin::new(&mut *body).poll_frame(cx)).await
538    }
539
540    #[tokio::test]
541    async fn reclaimable_body_hands_over_frames_of_bounded_size() {
542        let inner = http_body_util::Full::new(Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2 + 5]));
543        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
544            inner,
545            None,
546            None,
547            Duration::from_secs(3600),
548            || {},
549        );
550
551        let mut sizes = Vec::new();
552        let mut remaining = vec![body.size_hint().exact()];
553        while let Some(frame) = next_frame(&mut body).await {
554            sizes.push(frame.unwrap().into_data().unwrap().len());
555            remaining.push(body.size_hint().exact());
556        }
557        assert_eq!(sizes, [MAX_FRAME_BYTES, MAX_FRAME_BYTES, 5]);
558        let frame = MAX_FRAME_BYTES as u64;
559        assert_eq!(
560            remaining,
561            [Some(2 * frame + 5), Some(frame + 5), Some(5), Some(0)]
562        );
563    }
564
565    #[tokio::test]
566    async fn the_deadline_reclaims_the_body_and_keeps_the_end_guard() {
567        let (reclaimed_deadline, until_deadline) = flag();
568        let (reclaimed_end, until_end) = flag();
569        let (reported, on_reclaim) = report();
570        let mut body = ReclaimableBody::with_deadline(
571            PendingBody,
572            Some(until_deadline),
573            Some(until_end),
574            DEADLINE,
575            on_reclaim,
576        );
577
578        tokio::time::sleep(DEADLINE * 3).await;
579        assert!(reclaimed_deadline.load(Ordering::SeqCst));
580        assert!(reported.load(Ordering::SeqCst));
581        assert!(!reclaimed_end.load(Ordering::SeqCst));
582        // The transport, once the peer lets it poll again, gets an error.
583        let error = next_frame(&mut body).await.unwrap().unwrap_err();
584        assert_eq!(
585            Status::from_error(error).code(),
586            tonic::Code::DeadlineExceeded
587        );
588
589        drop(body);
590        assert!(reclaimed_end.load(Ordering::SeqCst));
591    }
592
593    #[tokio::test]
594    async fn a_body_read_to_the_end_is_never_reclaimed() {
595        let (reclaimed_deadline, until_deadline) = flag();
596        let (reported, on_reclaim) = report();
597        let mut body = ReclaimableBody::<_, _, DropFlag>::with_deadline(
598            Body::default(),
599            Some(until_deadline),
600            None,
601            DEADLINE,
602            on_reclaim,
603        );
604
605        assert!(next_frame(&mut body).await.is_none());
606        tokio::time::sleep(DEADLINE * 3).await;
607        assert!(!reclaimed_deadline.load(Ordering::SeqCst));
608        assert!(!reported.load(Ordering::SeqCst));
609
610        drop(body);
611        assert!(reclaimed_deadline.load(Ordering::SeqCst));
612    }
613
614    #[tokio::test]
615    async fn dropping_a_reclaimable_body_stops_its_deadline() {
616        let (reported, on_reclaim) = report();
617        let body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
618            PendingBody,
619            None,
620            None,
621            DEADLINE,
622            on_reclaim,
623        );
624
625        drop(body);
626        tokio::time::sleep(DEADLINE * 3).await;
627        assert!(!reported.load(Ordering::SeqCst));
628    }
629
630    #[tokio::test]
631    async fn the_deadline_frees_the_buffer_behind_a_piece_already_handed_over() {
632        let response = Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2]);
633        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::with_deadline(
634            http_body_util::Full::new(response.clone()),
635            None,
636            None,
637            DEADLINE,
638            || {},
639        );
640        // The transport holds on to the piece while the peer is not reading.
641        let piece = next_frame(&mut body)
642            .await
643            .unwrap()
644            .unwrap()
645            .into_data()
646            .unwrap();
647
648        tokio::time::sleep(DEADLINE * 3).await;
649        assert!(
650            response.is_unique(),
651            "the handed-over piece must not keep the response buffer alive"
652        );
653        drop((piece, body));
654    }
655
656    #[tokio::test]
657    async fn a_body_without_a_deadline_hands_over_frames_whole() {
658        let chunk = Bytes::from(vec![7u8; MAX_FRAME_BYTES * 2 + 5]);
659        let mut body = ReclaimableBody::<_, DropFlag, DropFlag>::new(
660            http_body_util::Full::new(chunk.clone()),
661            None,
662            None,
663        );
664
665        let frame = next_frame(&mut body)
666            .await
667            .unwrap()
668            .unwrap()
669            .into_data()
670            .unwrap();
671        assert_eq!(frame.len(), chunk.len());
672        assert_eq!(
673            frame.as_ptr(),
674            chunk.as_ptr(),
675            "the chunk must not be copied"
676        );
677        assert!(next_frame(&mut body).await.is_none());
678    }
679}