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