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    let started = Instant::now();
34    let mut logged = false;
35    loop {
36        if !admitted() {
37            return None;
38        }
39        if let Some(permit) = GLOBAL_COLD_BUILD_LIMITER.try_acquire() {
40            // The root can become unbound after the pre-attempt check but
41            // before the permit CAS succeeds. Recheck while owning the slot;
42            // dropping the permit here returns it before any build starts.
43            if !admitted() {
44                drop(permit);
45                return None;
46            }
47            if logged {
48                crate::slog_info!(
49                    "maintenance build slot acquired after {}ms wait: {}",
50                    started.elapsed().as_millis(),
51                    kind
52                );
53            }
54            return Some(permit);
55        }
56        if !logged {
57            crate::slog_info!(
58                "maintenance build queued behind concurrency cap ({}): {}",
59                GLOBAL_COLD_BUILD_LIMITER.limit(),
60                kind
61            );
62            logged = true;
63        }
64        std::thread::sleep(Duration::from_millis(100));
65    }
66}
67
68pub fn limit() -> usize {
69    GLOBAL_COLD_BUILD_LIMITER.limit()
70}
71
72#[derive(Debug)]
73struct ColdBuildLimiter {
74    available: AtomicUsize,
75    limit: usize,
76}
77
78impl ColdBuildLimiter {
79    fn new(limit: usize) -> Self {
80        let limit = limit.max(1);
81        Self {
82            available: AtomicUsize::new(limit),
83            limit,
84        }
85    }
86
87    fn limit(&self) -> usize {
88        self.limit
89    }
90
91    fn try_acquire(self: &Arc<Self>) -> Option<ColdBuildPermit> {
92        loop {
93            let available = self.available.load(Ordering::Acquire);
94            if available == 0 {
95                return None;
96            }
97            if self
98                .available
99                .compare_exchange(
100                    available,
101                    available - 1,
102                    Ordering::AcqRel,
103                    Ordering::Acquire,
104                )
105                .is_ok()
106            {
107                return Some(ColdBuildPermit {
108                    limiter: Arc::clone(self),
109                });
110            }
111        }
112    }
113}
114
115#[derive(Debug)]
116pub struct ColdBuildPermit {
117    limiter: Arc<ColdBuildLimiter>,
118}
119
120impl Drop for ColdBuildPermit {
121    fn drop(&mut self) {
122        let previous = self.limiter.available.fetch_add(1, Ordering::Release);
123        debug_assert!(previous < self.limiter.limit);
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    // These tests mutate the process-global limiter; run them one at a time.
132    fn serial() -> std::sync::MutexGuard<'static, ()> {
133        static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
134        M.get_or_init(|| std::sync::Mutex::new(()))
135            .lock()
136            .unwrap_or_else(std::sync::PoisonError::into_inner)
137    }
138
139    #[test]
140    fn permits_release_on_drop() {
141        let _serial = serial();
142        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
143        {
144            let _a = acquire_blocking("test-a");
145            let _b = acquire_blocking("test-b");
146            assert_eq!(
147                GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
148                before - 2
149            );
150        }
151        assert_eq!(
152            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
153            before
154        );
155    }
156
157    #[test]
158    fn acquire_blocking_waits_until_release() {
159        let _serial = serial();
160        // Drain every slot, then prove a waiter blocks until one holder drops.
161        let mut held: Vec<ColdBuildPermit> = Vec::new();
162        while let Some(permit) = try_acquire() {
163            held.push(permit);
164        }
165        let waiter = std::thread::spawn(|| {
166            let _p = acquire_blocking("waiter");
167        });
168        std::thread::sleep(std::time::Duration::from_millis(250));
169        assert!(!waiter.is_finished(), "waiter must block while cap is full");
170        drop(held.pop());
171        waiter.join().expect("waiter finishes after release");
172        drop(held);
173    }
174
175    #[test]
176    fn admission_revoked_between_check_and_permit_drops_the_slot() {
177        let _serial = serial();
178        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
179        let checks = AtomicUsize::new(0);
180
181        let permit = acquire_blocking_while("revoked-after-cas", || {
182            checks.fetch_add(1, Ordering::SeqCst) == 0
183        });
184
185        assert!(permit.is_none());
186        assert_eq!(checks.load(Ordering::SeqCst), 2);
187        assert_eq!(
188            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
189            before,
190            "revoked admission must return the just-acquired slot"
191        );
192    }
193
194    #[test]
195    fn conditional_waiter_cancels_without_consuming_a_released_slot() {
196        let _serial = serial();
197        let mut held = Vec::new();
198        while let Some(permit) = try_acquire() {
199            held.push(permit);
200        }
201        let admitted = Arc::new(std::sync::atomic::AtomicBool::new(true));
202        let waiter_admitted = Arc::clone(&admitted);
203        let waiter = std::thread::spawn(move || {
204            acquire_blocking_while("conditional waiter", || {
205                waiter_admitted.load(Ordering::SeqCst)
206            })
207        });
208        std::thread::sleep(Duration::from_millis(150));
209        admitted.store(false, Ordering::SeqCst);
210        assert!(
211            waiter.join().expect("conditional waiter joins").is_none(),
212            "revoked work must leave the cold-build queue without taking a permit"
213        );
214        drop(held);
215    }
216}