Skip to main content

spin_connection_semaphore/
lib.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::Duration;
4
5use anyhow::anyhow;
6use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
7use tokio::time;
8
9/// A semaphore paired with its configured permit limit, so utilization can be computed.
10///
11/// The two are bound together because a semaphore's `available_permits()` is meaningless for
12/// utilization without knowing the limit it was created with. Construct this where the semaphore
13/// is created so the pair can never drift.
14#[derive(Clone, Debug)]
15pub struct LimitedSemaphore {
16    sem: Arc<Semaphore>,
17    limit: usize,
18}
19
20impl LimitedSemaphore {
21    /// Creates a semaphore with `limit` permits, paired with that limit. Constructing the
22    /// semaphore here (rather than accepting a pre-built one) guarantees the stored limit always
23    /// matches the semaphore's actual capacity.
24    pub fn new(limit: usize) -> Self {
25        Self {
26            sem: Arc::new(Semaphore::new(limit)),
27            limit,
28        }
29    }
30
31    /// The configured permit limit this semaphore was created with.
32    pub fn limit(&self) -> usize {
33        self.limit
34    }
35
36    /// Returns a handle to the underlying semaphore so tests can inspect `available_permits()`
37    /// or acquire permits out-of-band to drive contention.
38    #[cfg(test)]
39    pub(crate) fn semaphore(&self) -> Arc<Semaphore> {
40        self.sem.clone()
41    }
42}
43
44/// Wraps an optional global and an optional factor-specific semaphore.
45#[derive(Clone, Debug)]
46pub struct ConnectionSemaphore {
47    /// Optional semaphore shared across factors.
48    /// When configured, this limits the total number of concurrent connections across all factors that
49    /// share this global instance.
50    global: Option<LimitedSemaphore>,
51    /// Optional semaphore specific to this factor.
52    ///
53    /// When configured, this limits the number of concurrent connections of this specific factor,
54    /// independent of the global limit.
55    factor_specific: Option<LimitedSemaphore>,
56    /// Label for this factor, used in emitted telemetry to differentiate factors sharing a global pool.
57    factor: &'static str,
58    /// Optional duration to wait for a permit before giving up and returning an error.
59    ///
60    /// When `None`, `acquire()` will wait indefinitely until a permit is available.
61    wait_timeout: Option<Duration>,
62    /// Identifier of the app this semaphore is scoped to.
63    ///
64    /// Used as a structured field on rejection tracing events so operators can attribute breaches to a tenant
65    /// without putting `app_id` on any metric label (which would explode cardinality).
66    app_id: Arc<str>,
67    /// Edge-trigger guard for the rejection warning.
68    ///
69    /// Set to `true` once a rejection has been logged, and reset to `false` on the next successful acquire.
70    rejecting: Arc<AtomicBool>,
71}
72
73impl ConnectionSemaphore {
74    /// Creates a new `ConnectionSemaphore`.
75    ///
76    /// `global` is an optional [`LimitedSemaphore`] shared across factors; `factor_specific` is an
77    /// optional [`LimitedSemaphore`] for this specific factor. If either is `None`, that level of
78    /// limiting is disabled. `factor` is a label used in emitted telemetry, `app_id` identifies
79    /// the owning app for tenant-attribution in tracing events, and `wait_timeout` is an optional
80    /// duration to wait for a permit before giving up and returning an error.
81    pub fn new(
82        global: Option<LimitedSemaphore>,
83        factor_specific: Option<LimitedSemaphore>,
84        factor: &'static str,
85        app_id: Arc<str>,
86        wait_timeout: Option<Duration>,
87    ) -> Self {
88        Self {
89            global,
90            factor_specific,
91            factor,
92            wait_timeout,
93            app_id,
94            rejecting: Arc::new(AtomicBool::new(false)),
95        }
96    }
97
98    /// Records that the semaphore just served a successful acquire, re-arming the rejection
99    /// warning so the next rejection is logged again.
100    fn mark_serving(&self) {
101        self.rejecting.store(false, Ordering::Relaxed);
102    }
103
104    /// Acquire both configured semaphore slots, returning a permit that holds
105    /// them until dropped.
106    ///
107    /// When both a global and a factor-specific semaphore are configured, this
108    /// method acquires factor-specific first, then global, ensuring the global
109    /// permit is never held while blocking on a factor-specific backlog.
110    ///
111    /// If `wait_timeout` is configured and the permits cannot be acquired within
112    /// that duration, an error is returned.
113    pub async fn acquire(&self) -> anyhow::Result<ConnectionPermit> {
114        // Fast path: all required permits are already available
115        if let Ok(permit) = self.try_acquire_permits() {
116            spin_telemetry::counter!(
117                outbound_connection_permits_acquired = 1,
118                kind = self.factor,
119                waited = false
120            );
121            self.mark_serving();
122            self.emit_utilization();
123            return Ok(permit);
124        }
125
126        match self.wait_timeout {
127            Some(timeout) => time::timeout(timeout, self.acquire_inner())
128                .await
129                .map_err(|_| {
130                    // Log a warning on the first rejection to make it easier for operators
131                    // to notice when limits are being hit, but avoid spamming.
132                    if !self.rejecting.swap(true, Ordering::Relaxed) {
133                        tracing::warn!(
134                            kind = self.factor,
135                            app_id = %self.app_id,
136                            "connection permit rejected: timeout waiting for permit"
137                        );
138                    }
139                    anyhow!("connection semaphore timed out after {timeout:?}")
140                })?,
141            None => self.acquire_inner().await,
142        }
143    }
144
145    /// Inner logic for [`Self::acquire`], separated so the caller can apply a timeout.
146    async fn acquire_inner(&self) -> anyhow::Result<ConnectionPermit> {
147        /// Acquires a single permit from `sem`, trying non-blocking first.
148        ///
149        /// Sets `*waited = true` if a blocking wait was required.
150        async fn acquire_one(
151            sem: &Arc<Semaphore>,
152            waited: &mut bool,
153            label: &str,
154        ) -> anyhow::Result<OwnedSemaphorePermit> {
155            match sem.clone().try_acquire_owned() {
156                Ok(p) => Ok(p),
157                Err(TryAcquireError::NoPermits) => {
158                    *waited = true;
159                    sem.clone()
160                        .acquire_owned()
161                        .await
162                        .map_err(|_| anyhow!("{label} connection semaphore closed"))
163                }
164                Err(_) => Err(anyhow!("{label} connection semaphore closed")),
165            }
166        }
167        let mut waited = false;
168        let start = std::time::Instant::now();
169
170        // Acquire factor-specific first, then global. This ensures we never hold
171        // the global permit while blocking on factor-specific backlog.
172        let factor_specific = match &self.factor_specific {
173            Some(f) => Some(acquire_one(&f.sem, &mut waited, "factor").await?),
174            None => None,
175        };
176        // It's fine to hold the factor-specific permit while waiting for the global slot, since
177        // other consumers of the factor-specific would also end up waiting for the same global slot.
178        let global = match &self.global {
179            Some(g) => Some(acquire_one(&g.sem, &mut waited, "global").await?),
180            None => None,
181        };
182
183        let factor = self.factor;
184        if waited {
185            spin_telemetry::histogram_f64!(
186                outbound_connection_permit_wait_duration_ms = start.elapsed().as_millis() as f64,
187                kind = factor
188            );
189        }
190        spin_telemetry::counter!(
191            outbound_connection_permits_acquired = 1,
192            kind = factor,
193            waited = waited
194        );
195        self.mark_serving();
196        self.emit_utilization();
197
198        Ok(ConnectionPermit {
199            global_permit: global,
200            factor_specific_permit: factor_specific,
201            semaphore: self.clone(),
202        })
203    }
204
205    /// Attempt to acquire both configured slots without waiting.
206    /// Returns `None` if either semaphore is exhausted.
207    ///
208    /// If the global permit is acquired but the factor-specific permit is not
209    /// available, the global permit is released before returning `None`.
210    pub fn try_acquire(&self) -> Option<ConnectionPermit> {
211        match self.try_acquire_permits() {
212            Ok(permit) => {
213                spin_telemetry::counter!(
214                    outbound_connection_permits_acquired = 1,
215                    kind = self.factor,
216                    waited = false
217                );
218                self.mark_serving();
219                self.emit_utilization();
220                Some(permit)
221            }
222            Err(limit) => {
223                spin_telemetry::counter!(
224                    outbound_connection_permits_rejected = 1,
225                    kind = self.factor,
226                    limit = limit
227                );
228                // Log a warning on the first rejection to make it easier for operators
229                // to notice when limits are being hit, but avoid spamming.
230                if !self.rejecting.swap(true, Ordering::Relaxed) {
231                    tracing::warn!(
232                        kind = self.factor,
233                        app_id = %self.app_id,
234                        limit = limit,
235                        "connection permit rejected: limit exhausted"
236                    );
237                }
238                None
239            }
240        }
241    }
242
243    /// Inner logic for [`Self::try_acquire`], separated so the caller can emit
244    /// telemetry based on whether a permit was obtained.
245    ///
246    /// Returns `Err("global")` or `Err("factor")` to indicate which limit was
247    /// exhausted, so the caller can tag the rejection metric accordingly.
248    fn try_acquire_permits(&self) -> Result<ConnectionPermit, &'static str> {
249        // Acquire global first. If it fails, nothing is consumed.
250        let global = match &self.global {
251            Some(s) => match s.sem.clone().try_acquire_owned() {
252                Ok(p) => Some(p),
253                Err(_) => return Err("global"),
254            },
255            None => None,
256        };
257        // Now attempt the factor-specific permit.
258        // On failure, `global` is dropped here, releasing the global slot.
259        let factor_specific = match &self.factor_specific {
260            Some(s) => match s.sem.clone().try_acquire_owned() {
261                Ok(p) => Some(p),
262                Err(_) => return Err("factor"),
263            },
264            None => None,
265        };
266        Ok(ConnectionPermit {
267            global_permit: global,
268            factor_specific_permit: factor_specific,
269            semaphore: self.clone(),
270        })
271    }
272
273    /// Emits one sample each of factor-specific and global utilization (0.0..=1.0) as histograms,
274    /// for whichever limits are configured.
275    ///
276    /// Factor-specific utilization is labeled with `kind` (one series per factor). Global
277    /// utilization carries no `kind` label: the global pool is shared across factors, so its
278    /// utilization is a single value — labeling by factor would emit redundant series all
279    /// reporting the same number.
280    fn emit_utilization(&self) {
281        if let Some(util) = utilization(self.factor_specific.as_ref()) {
282            spin_telemetry::histogram_f64!(
283                outbound_connection_factor_utilization = util,
284                kind = self.factor
285            );
286        }
287        if let Some(util) = utilization(self.global.as_ref()) {
288            spin_telemetry::histogram_f64!(outbound_connection_global_utilization = util);
289        }
290    }
291}
292
293/// Custom histogram bucket boundaries for the utilization metrics this crate emits.
294///
295/// Both `outbound_connection_factor_utilization` and `outbound_connection_global_utilization` are
296/// recorded on a 0.0..=1.0 scale, so the OTel default boundaries (tuned for millisecond durations,
297/// topping out at 10000) would collapse every sample into the lowest bucket. These boundaries sit
298/// near typical alerting cutoffs (75%, 90%, 95%, 99%). Pass the result to `spin_telemetry::init`.
299///
300/// The metric names here must match the identifiers used in the `histogram_f64!` calls above.
301pub fn metric_histogram_buckets() -> Vec<spin_telemetry::HistogramBuckets> {
302    let boundaries = vec![0.25, 0.5, 0.75, 0.9, 0.95, 0.99];
303    [
304        "outbound_connection_factor_utilization",
305        "outbound_connection_global_utilization",
306    ]
307    .into_iter()
308    .map(|metric_name| spin_telemetry::HistogramBuckets {
309        metric_name,
310        boundaries: boundaries.clone(),
311    })
312    .collect()
313}
314
315/// Computes utilization (0.0..=1.0) for a limited semaphore, or `None` when no limit is
316/// configured (or the limit is zero, which would make utilization undefined).
317fn utilization(limited: Option<&LimitedSemaphore>) -> Option<f64> {
318    let limited = limited?;
319    if limited.limit == 0 {
320        return None;
321    }
322    let in_flight = limited
323        .limit
324        .saturating_sub(limited.sem.available_permits());
325    Some(in_flight as f64 / limited.limit as f64)
326}
327
328/// Holds up to two semaphore permits (global + factor-specific).
329/// Both permits are released when this value is dropped.
330/// All-`None` permit fields are valid and represent the no-limits case.
331#[derive(Debug)]
332pub struct ConnectionPermit {
333    global_permit: Option<OwnedSemaphorePermit>,
334    factor_specific_permit: Option<OwnedSemaphorePermit>,
335    /// The issuing semaphore, retained so `Drop` can re-sample utilization *after* the inner
336    /// permits are released (its `LimitedSemaphore`s share the same `Arc`s these permits came from).
337    semaphore: ConnectionSemaphore,
338}
339
340impl Drop for ConnectionPermit {
341    fn drop(&mut self) {
342        // Explicitly release the inner permits before sampling so the histogram
343        // reflects post-release state. Without this, the implicit field drop would
344        // happen after our body — meaning we'd read the *pre-release* available count.
345        self.global_permit.take();
346        self.factor_specific_permit.take();
347        self.semaphore.emit_utilization();
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[tokio::test]
356    async fn no_limits_acquire_always_succeeds() {
357        let sem = ConnectionSemaphore::new(None, None, "test", Arc::from("test-app"), None);
358        let permit = sem.acquire().await.expect("should succeed");
359        drop(permit);
360        let _permit2 = sem.acquire().await.expect("should succeed again");
361    }
362
363    #[test]
364    fn no_limits_try_acquire_always_succeeds() {
365        let sem = ConnectionSemaphore::new(None, None, "test", Arc::from("test-app"), None);
366        let permit = sem.try_acquire().expect("should succeed");
367        drop(permit);
368        let _permit2 = sem.try_acquire().expect("should succeed again");
369    }
370
371    #[test]
372    fn global_limit_only_exhausted() {
373        let global = LimitedSemaphore::new(1);
374        let global_sem = global.semaphore();
375        let sem = ConnectionSemaphore::new(Some(global), None, "test", Arc::from("test-app"), None);
376        let permit1 = sem.try_acquire().expect("first should succeed");
377        assert!(
378            sem.try_acquire().is_none(),
379            "second should fail: global exhausted"
380        );
381        drop(permit1);
382        assert_eq!(global_sem.available_permits(), 1);
383        let _permit3 = sem.try_acquire().expect("after release should succeed");
384    }
385
386    #[test]
387    fn factor_limit_only_exhausted() {
388        let sem = ConnectionSemaphore::new(
389            None,
390            Some(LimitedSemaphore::new(1)),
391            "test",
392            Arc::from("test-app"),
393            None,
394        );
395        let permit1 = sem.try_acquire().expect("first should succeed");
396        assert!(
397            sem.try_acquire().is_none(),
398            "second should fail: factor exhausted"
399        );
400        drop(permit1);
401        let _permit3 = sem.try_acquire().expect("after release should succeed");
402    }
403
404    #[test]
405    fn both_limits_global_exhausted_first() {
406        let global = LimitedSemaphore::new(1);
407        let factor = LimitedSemaphore::new(2);
408        let factor_sem = factor.semaphore();
409        let sem = ConnectionSemaphore::new(
410            Some(global),
411            Some(factor),
412            "test",
413            Arc::from("test-app"),
414            None,
415        );
416
417        let permit1 = sem.try_acquire().expect("first should succeed");
418        // After permit1: global=0, factor=1
419        let factor_before = factor_sem.available_permits();
420
421        // Second try_acquire should fail because global is exhausted.
422        assert!(sem.try_acquire().is_none(), "should fail: global exhausted");
423        // Factor must NOT have been consumed by the failed attempt.
424        assert_eq!(
425            factor_sem.available_permits(),
426            factor_before,
427            "factor permits should not be consumed when global is exhausted"
428        );
429        drop(permit1);
430    }
431
432    #[test]
433    fn both_limits_factor_exhausted_global_released() {
434        let global = LimitedSemaphore::new(2);
435        let factor = LimitedSemaphore::new(1);
436        let global_sem = global.semaphore();
437        let sem = ConnectionSemaphore::new(
438            Some(global),
439            Some(factor),
440            "test",
441            Arc::from("test-app"),
442            None,
443        );
444
445        let permit1 = sem.try_acquire().expect("first should succeed");
446        // Global still has 1, factor exhausted
447        let result = sem.try_acquire();
448        assert!(result.is_none(), "should fail: factor exhausted");
449        // Global slot must have been released (back to 1)
450        assert_eq!(global_sem.available_permits(), 1);
451        drop(permit1);
452        assert_eq!(global_sem.available_permits(), 2);
453    }
454
455    #[tokio::test]
456    async fn acquire_waits_for_release() {
457        let sem = ConnectionSemaphore::new(
458            Some(LimitedSemaphore::new(1)),
459            None,
460            "test",
461            Arc::from("test-app"),
462            None,
463        );
464
465        let permit = sem.try_acquire().expect("first should succeed");
466
467        let sem2 = sem.clone();
468        let handle = tokio::spawn(async move {
469            let _p = sem2.acquire().await.expect("should eventually acquire");
470        });
471
472        drop(permit); // release so the spawned task can proceed
473        handle.await.expect("task should complete");
474    }
475
476    /// Verifies that when factor-specific is exhausted, acquire() doesn't hold
477    /// a global permit while waiting — so other connection types aren't blocked.
478    #[tokio::test]
479    async fn acquire_releases_global_while_waiting_for_factor() {
480        let global = LimitedSemaphore::new(1);
481        let factor = LimitedSemaphore::new(1);
482        let global_sem = global.semaphore();
483        let factor_sem = factor.semaphore();
484        let sem = ConnectionSemaphore::new(
485            Some(global),
486            Some(factor),
487            "test",
488            Arc::from("test-app"),
489            None,
490        );
491
492        // Exhaust factor-specific from outside.
493        let _factor_hold = factor_sem.acquire_owned().await.unwrap();
494
495        let sem_clone = sem.clone();
496        let handle = tokio::spawn(async move {
497            sem_clone
498                .acquire()
499                .await
500                .expect("should succeed after factor is released")
501        });
502
503        // Yield twice: first to let the spawned task run until it blocks waiting
504        // for factor-specific; second to confirm it has released the global permit.
505        tokio::task::yield_now().await;
506        tokio::task::yield_now().await;
507
508        assert_eq!(
509            global_sem.available_permits(),
510            1,
511            "global should be free while acquire() waits for factor-specific"
512        );
513
514        drop(_factor_hold);
515        handle.await.expect("task should complete");
516    }
517
518    #[tokio::test]
519    async fn acquire_times_out_when_semaphore_exhausted() {
520        let sem = ConnectionSemaphore::new(
521            Some(LimitedSemaphore::new(1)),
522            None,
523            "test",
524            Arc::from("test-app"),
525            Some(Duration::from_millis(10)),
526        );
527
528        let _permit = sem.try_acquire().expect("first should succeed");
529
530        let err = sem.acquire().await.expect_err("should time out");
531        assert!(
532            err.to_string().contains("timed out"),
533            "error message should mention timed out: {err}"
534        );
535    }
536
537    #[test]
538    fn utilization_reflects_post_release_state() {
539        let limited = LimitedSemaphore::new(2);
540        assert_eq!(utilization(Some(&limited)), Some(0.0));
541        let p1 = limited
542            .semaphore()
543            .try_acquire_owned()
544            .expect("first acquire");
545        assert_eq!(utilization(Some(&limited)), Some(0.5));
546        let p2 = limited
547            .semaphore()
548            .try_acquire_owned()
549            .expect("second acquire");
550        assert_eq!(utilization(Some(&limited)), Some(1.0));
551        drop(p1);
552        assert_eq!(utilization(Some(&limited)), Some(0.5));
553        drop(p2);
554        assert_eq!(utilization(Some(&limited)), Some(0.0));
555    }
556
557    #[test]
558    fn no_utilization_when_limit_is_none() {
559        assert_eq!(utilization(None), None);
560    }
561}