Skip to main content

drep/llm/
concurrency.rs

1//! Concurrency cap for LLM requests.
2//!
3//! One thing, on purpose: a bounded number of in-flight requests, nothing
4//! else. A local commit gate reviewing a handful of changed files does not need
5//! server-oriented rate-limit machinery:
6//!
7//! - **Per-repo semaphores** are a map with one entry. One invocation, one
8//!   repo, one bucket.
9//! - **A requests-per-minute window and a tokens-per-minute budget** are a
10//!   second, worse implementation of what `open-agent-sdk` 0.7.0 already
11//!   does: it classifies 429 as retryable and backs off, so a client-side
12//!   throttle duplicates the backoff without owning it.
13//! - **Two-step token accounting** exists to reconcile an estimate against
14//!   actuals across a queue. With no token budget, nothing to reconcile, so
15//!   the "never hold the lock across an await" hazard disappears with it.
16//! - **A circuit breaker** protects a shared service from a stampede. One
17//!   developer committing is not a stampede.
18//!
19//! If a real workload later shows `max_concurrent` is not enough, add the
20//! narrowest mechanism that fixes it.
21//!
22//! [`tokio::sync::Semaphore::acquire`] returns `Result` because the
23//! semaphore can be closed. This limiter never closes it, so the error
24//! case is unreachable in practice; it is mapped to an empty guard rather
25//! than propagated as an error the caller cannot act on.
26
27use std::sync::Arc;
28
29use tokio::sync::{Semaphore, SemaphorePermit};
30
31/// A bounded concurrency limiter.
32///
33/// Cheap to clone (the semaphore is wrapped in an `Arc`); typically built
34/// once per process and shared across the analyzer.
35#[derive(Clone, Debug)]
36pub struct Limiter {
37    semaphore: Arc<Semaphore>,
38}
39
40/// RAII guard holding one slot in the limiter.
41///
42/// Drop the guard to release the slot. In the (unreachable) error case
43/// where the semaphore was closed, the guard holds no permit and dropping
44/// it is a no-op - the limiter cannot make a phantom permit do useful
45/// work, but the type stays total so callers do not have to handle a
46/// second error variant.
47#[must_use = "dropping the guard immediately releases the slot, so a bare \
48              `limiter.acquire().await;` provides no backpressure at all"]
49pub struct LimiterGuard<'a> {
50    /// The held permit, if any. `Option` so the unreachable error case
51    /// can leave the guard empty; `Drop` below explicitly drops the
52    /// permit so the slot returns to the pool.
53    permit: Option<SemaphorePermit<'a>>,
54}
55
56impl Drop for LimiterGuard<'_> {
57    fn drop(&mut self) {
58        // Explicitly drop the held permit so the slot is released now
59        // rather than whenever the field happens to be reclaimed. The
60        // `take` also makes the field's role unambiguous to the
61        // dead-code lint: without it, holding a value that is "only
62        // dropped" can warn.
63        drop(self.permit.take());
64    }
65}
66
67impl Limiter {
68    /// Build a limiter that allows at most `max_concurrent` in-flight
69    /// requests. `max_concurrent = 0` is allowed by the type system but
70    /// makes every `acquire` await forever; callers are expected to set a
71    /// positive value from `LlmConfig::max_concurrent` (default 3).
72    pub fn new(max_concurrent: usize) -> Self {
73        Self {
74            semaphore: Arc::new(Semaphore::new(max_concurrent)),
75        }
76    }
77
78    /// Acquire a slot. The slot is held until the returned guard is
79    /// dropped.
80    ///
81    /// Backpressure is the entire job: callers await, and once `K` requests
82    /// are in flight, the next one queues here until one of the holders
83    /// drops its guard.
84    pub async fn acquire(&self) -> LimiterGuard<'_> {
85        // `Semaphore::acquire` only returns `Err(AcquireError)` when the
86        // semaphore has been closed. We never close it, so the error is
87        // unreachable; mapping it to an empty guard keeps the API total
88        // without resorting to `unwrap` outside tests.
89        let permit = self.semaphore.acquire().await.ok();
90        LimiterGuard { permit }
91    }
92
93    /// The number of slots currently available for acquisition.
94    ///
95    /// Reflects the configured maximum before any acquisition, decreases by
96    /// one per held guard, and returns to the maximum once every guard has
97    /// been dropped. Test-only - exposed via the API so tests can pin the
98    /// behaviour without reaching into the semaphore.
99    pub fn available(&self) -> usize {
100        self.semaphore.available_permits()
101    }
102}
103
104#[cfg(test)]
105mod tests;