Skip to main content

aft/
cold_build_limiter.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2use std::sync::{Arc, LazyLock};
3use std::time::{Duration, Instant};
4
5#[cfg(not(test))]
6const DEFAULT_COLD_BUILD_LIMIT: usize = 2;
7#[cfg(test)]
8const DEFAULT_COLD_BUILD_LIMIT: usize = 1024;
9
10static GLOBAL_COLD_BUILD_LIMITER: LazyLock<Arc<ColdBuildLimiter>> =
11    LazyLock::new(|| Arc::new(ColdBuildLimiter::new(DEFAULT_COLD_BUILD_LIMIT)));
12
13pub(crate) fn global_limiter() -> Arc<ColdBuildLimiter> {
14    Arc::clone(&GLOBAL_COLD_BUILD_LIMITER)
15}
16
17pub(crate) fn isolated_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
18    Arc::new(ColdBuildLimiter::new(limit))
19}
20
21pub fn try_acquire() -> Option<ColdBuildPermit> {
22    GLOBAL_COLD_BUILD_LIMITER.try_acquire()
23}
24
25/// Block until a build slot is free, then take it.
26///
27/// For build sites with no reschedule path (search-index builds spawn once per
28/// configure): skipping would strand the index, so past-cap work waits instead.
29/// Production captures showed concurrent per-root builds starving dispatch
30/// while CPU sat idle; waiting serializes that pressure at the source. Only
31/// call from dedicated background threads, never the dispatch thread or an
32/// executor worker.
33pub fn acquire_blocking(kind: &str) -> ColdBuildPermit {
34    acquire_blocking_while(kind, || true).expect("unconditional cold-build admission")
35}
36
37/// Wait for a build slot while `admitted` remains true. The predicate is checked
38/// before every attempt, so a root that becomes unbound does not consume a slot
39/// after spending time queued behind the process-wide cap.
40pub fn acquire_blocking_while(kind: &str, admitted: impl Fn() -> bool) -> Option<ColdBuildPermit> {
41    acquire_blocking_while_with_limiter(&GLOBAL_COLD_BUILD_LIMITER, kind, admitted)
42}
43
44pub(crate) fn acquire_blocking_while_with_limiter(
45    limiter: &Arc<ColdBuildLimiter>,
46    kind: &str,
47    admitted: impl Fn() -> bool,
48) -> Option<ColdBuildPermit> {
49    let started = Instant::now();
50    let mut logged = false;
51    loop {
52        if !admitted() {
53            return None;
54        }
55        if let Some(permit) = limiter.try_acquire() {
56            // The root can become unbound after the pre-attempt check but
57            // before the permit CAS succeeds. Recheck while owning the slot;
58            // dropping the permit here returns it before any build starts.
59            if !admitted() {
60                drop(permit);
61                return None;
62            }
63            if logged {
64                crate::slog_info!(
65                    "maintenance build slot acquired after {}ms wait: {}",
66                    started.elapsed().as_millis(),
67                    kind
68                );
69            }
70            return Some(permit);
71        }
72        if !logged {
73            crate::slog_info!(
74                "maintenance build queued behind concurrency cap ({}): {}",
75                limiter.limit(),
76                kind
77            );
78            logged = true;
79        }
80        std::thread::sleep(Duration::from_millis(100));
81    }
82}
83
84pub fn limit() -> usize {
85    GLOBAL_COLD_BUILD_LIMITER.limit()
86}
87
88#[cfg(test)]
89pub(crate) fn test_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
90    Arc::new(ColdBuildLimiter::new(limit))
91}
92
93#[cfg(test)]
94pub(crate) fn acquire_blocking_while_with_test_limiter(
95    limiter: &Arc<ColdBuildLimiter>,
96    kind: &str,
97    admitted: impl Fn() -> bool,
98) -> Option<ColdBuildPermit> {
99    acquire_blocking_while_with_limiter(limiter, kind, admitted)
100}
101
102#[derive(Debug)]
103pub(crate) struct ColdBuildLimiter {
104    available: AtomicUsize,
105    limit: usize,
106}
107
108impl ColdBuildLimiter {
109    fn new(limit: usize) -> Self {
110        let limit = limit.max(1);
111        Self {
112            available: AtomicUsize::new(limit),
113            limit,
114        }
115    }
116
117    pub(crate) fn limit(&self) -> usize {
118        self.limit
119    }
120
121    pub(crate) fn try_acquire(self: &Arc<Self>) -> Option<ColdBuildPermit> {
122        loop {
123            let available = self.available.load(Ordering::Acquire);
124            if available == 0 {
125                return None;
126            }
127            if self
128                .available
129                .compare_exchange(
130                    available,
131                    available - 1,
132                    Ordering::AcqRel,
133                    Ordering::Acquire,
134                )
135                .is_ok()
136            {
137                return Some(ColdBuildPermit {
138                    limiter: Arc::clone(self),
139                });
140            }
141        }
142    }
143}
144
145#[derive(Debug)]
146pub struct ColdBuildPermit {
147    limiter: Arc<ColdBuildLimiter>,
148}
149
150impl Drop for ColdBuildPermit {
151    fn drop(&mut self) {
152        let previous = self.limiter.available.fetch_add(1, Ordering::Release);
153        debug_assert!(previous < self.limiter.limit);
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    // These tests mutate the process-global limiter; run them one at a time.
162    fn serial() -> std::sync::MutexGuard<'static, ()> {
163        static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
164        M.get_or_init(|| std::sync::Mutex::new(()))
165            .lock()
166            .unwrap_or_else(std::sync::PoisonError::into_inner)
167    }
168
169    #[test]
170    fn permits_release_on_drop() {
171        let _serial = serial();
172        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
173        {
174            let _a = acquire_blocking("test-a");
175            let _b = acquire_blocking("test-b");
176            assert_eq!(
177                GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
178                before - 2
179            );
180        }
181        assert_eq!(
182            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
183            before
184        );
185    }
186
187    #[test]
188    fn acquire_blocking_waits_until_release() {
189        let _serial = serial();
190        // Drain every slot, then prove a waiter blocks until one holder drops.
191        let mut held: Vec<ColdBuildPermit> = Vec::new();
192        while let Some(permit) = try_acquire() {
193            held.push(permit);
194        }
195        let waiter = std::thread::spawn(|| {
196            let _p = acquire_blocking("waiter");
197        });
198        std::thread::sleep(std::time::Duration::from_millis(250));
199        assert!(!waiter.is_finished(), "waiter must block while cap is full");
200        drop(held.pop());
201        waiter.join().expect("waiter finishes after release");
202        drop(held);
203    }
204
205    #[test]
206    fn admission_revoked_between_check_and_permit_drops_the_slot() {
207        let _serial = serial();
208        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
209        let checks = AtomicUsize::new(0);
210
211        let permit = acquire_blocking_while("revoked-after-cas", || {
212            checks.fetch_add(1, Ordering::SeqCst) == 0
213        });
214
215        assert!(permit.is_none());
216        assert_eq!(checks.load(Ordering::SeqCst), 2);
217        assert_eq!(
218            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
219            before,
220            "revoked admission must return the just-acquired slot"
221        );
222    }
223
224    #[test]
225    fn conditional_waiter_cancels_without_consuming_a_released_slot() {
226        let _serial = serial();
227        let mut held = Vec::new();
228        while let Some(permit) = try_acquire() {
229            held.push(permit);
230        }
231        let admitted = Arc::new(std::sync::atomic::AtomicBool::new(true));
232        let waiter_admitted = Arc::clone(&admitted);
233        let waiter = std::thread::spawn(move || {
234            acquire_blocking_while("conditional waiter", || {
235                waiter_admitted.load(Ordering::SeqCst)
236            })
237        });
238        std::thread::sleep(Duration::from_millis(150));
239        admitted.store(false, Ordering::SeqCst);
240        assert!(
241            waiter.join().expect("conditional waiter joins").is_none(),
242            "revoked work must leave the cold-build queue without taking a permit"
243        );
244        drop(held);
245    }
246}