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