Skip to main content

aft/
cold_build_limiter.rs

1use std::collections::VecDeque;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::{Arc, LazyLock, Mutex};
4use std::time::{Duration, Instant};
5
6#[cfg(not(test))]
7const DEFAULT_COLD_BUILD_LIMIT: usize = 2;
8#[cfg(test)]
9const DEFAULT_COLD_BUILD_LIMIT: usize = 1024;
10
11// This is an internal harness record, not a user-facing limiter setting. The
12// test harness performs 32 release/admission cycles, so retain enough events to
13// cover that exercise while bounding memory use in a long-lived daemon.
14const ADMISSION_EVENT_RETENTION: usize = 64;
15
16static GLOBAL_COLD_BUILD_LIMITER: LazyLock<Arc<ColdBuildLimiter>> =
17    LazyLock::new(|| Arc::new(ColdBuildLimiter::new(DEFAULT_COLD_BUILD_LIMIT)));
18
19pub(crate) fn global_limiter() -> Arc<ColdBuildLimiter> {
20    Arc::clone(&GLOBAL_COLD_BUILD_LIMITER)
21}
22
23pub(crate) fn isolated_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
24    Arc::new(ColdBuildLimiter::new(limit))
25}
26
27pub fn try_acquire() -> Option<ColdBuildPermit> {
28    GLOBAL_COLD_BUILD_LIMITER.try_acquire()
29}
30
31/// Block until a build slot is free, then take it.
32///
33/// For build sites with no reschedule path (search-index builds spawn once per
34/// configure): skipping would strand the index, so past-cap work waits instead.
35/// Production captures showed concurrent per-root builds starving dispatch
36/// while CPU sat idle; waiting serializes that pressure at the source. Only
37/// call from dedicated background threads, never the dispatch thread or an
38/// executor worker.
39pub fn acquire_blocking(kind: &str) -> ColdBuildPermit {
40    acquire_blocking_while(kind, || true).expect("unconditional cold-build admission")
41}
42
43/// Wait for a build slot while `admitted` remains true. The predicate is checked
44/// before every attempt, so a root that becomes unbound does not consume a slot
45/// after spending time queued behind the process-wide cap.
46pub fn acquire_blocking_while(kind: &str, admitted: impl Fn() -> bool) -> Option<ColdBuildPermit> {
47    acquire_blocking_while_with_limiter(&GLOBAL_COLD_BUILD_LIMITER, kind, admitted)
48}
49
50pub(crate) fn acquire_blocking_while_with_limiter(
51    limiter: &Arc<ColdBuildLimiter>,
52    kind: &str,
53    admitted: impl Fn() -> bool,
54) -> Option<ColdBuildPermit> {
55    acquire_blocking_while_inner(limiter, kind, None, admitted, || false)
56}
57
58/// Identify the source of a cold-build request without exposing a limiter knob.
59///
60/// The classes deliberately have no priority ordering. When both classes are
61/// waiting, the limiter only avoids admitting the class that was admitted most
62/// recently, so a continuously eligible class cannot monopolize releases.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub(crate) enum ColdBuildAdmissionClass {
65    InspectTriggered,
66    Maintenance,
67}
68
69impl ColdBuildAdmissionClass {
70    const fn index(self) -> usize {
71        match self {
72            Self::InspectTriggered => 0,
73            Self::Maintenance => 1,
74        }
75    }
76
77    const fn other_index(self) -> usize {
78        match self {
79            Self::InspectTriggered => Self::Maintenance.index(),
80            Self::Maintenance => Self::InspectTriggered.index(),
81        }
82    }
83
84    const fn label(self) -> &'static str {
85        match self {
86            Self::InspectTriggered => "inspect-triggered",
87            Self::Maintenance => "maintenance",
88        }
89    }
90}
91
92/// Internal request metadata attached to an admission attempt.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub(crate) struct ColdBuildAdmissionRequest {
95    request_id: String,
96    class: ColdBuildAdmissionClass,
97}
98
99impl ColdBuildAdmissionRequest {
100    // Constructed by tests today; the inspect-path slice mints requests once it lands.
101    #[cfg_attr(not(test), allow(dead_code))]
102    pub(crate) fn new(request_id: impl Into<String>, class: ColdBuildAdmissionClass) -> Self {
103        Self {
104            request_id: request_id.into(),
105            class,
106        }
107    }
108}
109
110/// A structured record of a successful cold-build admission.
111///
112/// `admission_order` records the order in which permits were admitted, not the
113/// order in which waiters arrived. Arrival-order and overtake checks require a
114/// separate ticketed-ordering design.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub(crate) struct ColdBuildAdmissionEvent {
117    pub(crate) request_id: String,
118    pub(crate) class: ColdBuildAdmissionClass,
119    pub(crate) admission_order: u64,
120}
121
122/// Acquire a limiter permit for a classified request while it remains admitted
123/// and uncancelled.
124///
125/// Cancellation is sampled before every acquisition attempt and again after a
126/// permit has been acquired. The second check closes the gap before a build can
127/// start: a newly cancelled request returns the permit without emitting an
128/// admission event.
129// Read by the cancellation tests today; the inspect-path slice routes its
130// blocking acquisition through this wrapper once it lands.
131#[cfg_attr(not(test), allow(dead_code))]
132pub(crate) fn acquire_blocking_while_cancellable_with_limiter(
133    limiter: &Arc<ColdBuildLimiter>,
134    kind: &str,
135    request: ColdBuildAdmissionRequest,
136    admitted: impl Fn() -> bool,
137    cancelled: impl Fn() -> bool,
138) -> Option<ColdBuildPermit> {
139    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, cancelled)
140}
141
142fn acquire_blocking_while_inner(
143    limiter: &Arc<ColdBuildLimiter>,
144    kind: &str,
145    request: Option<&ColdBuildAdmissionRequest>,
146    admitted: impl Fn() -> bool,
147    cancelled: impl Fn() -> bool,
148) -> Option<ColdBuildPermit> {
149    let _waiter = request.map(|request| AdmissionWaiter::register(limiter, request.class));
150    let started = Instant::now();
151    let mut logged = false;
152    loop {
153        if !admitted() || cancelled() {
154            return None;
155        }
156        let class_is_eligible = request
157            .map(|request| limiter.class_is_eligible(request.class))
158            .unwrap_or(true);
159        if class_is_eligible {
160            if let Some(permit) = limiter.try_acquire() {
161                // A request can become unbound or cancelled after the pre-attempt
162                // check but before the permit is acquired. Recheck while owning
163                // the slot; dropping the permit returns it before any build starts.
164                if !admitted() || cancelled() {
165                    drop(permit);
166                    return None;
167                }
168                if let Some(request) = request {
169                    limiter.record_admission(request);
170                }
171                if logged {
172                    match request {
173                        Some(request) => crate::slog_info!(
174                            "{} cold-build slot acquired after {}ms wait: request={} kind={}",
175                            request.class.label(),
176                            started.elapsed().as_millis(),
177                            request.request_id,
178                            kind
179                        ),
180                        None => crate::slog_info!(
181                            "maintenance build slot acquired after {}ms wait: {}",
182                            started.elapsed().as_millis(),
183                            kind
184                        ),
185                    }
186                }
187                return Some(permit);
188            }
189        }
190        if !logged {
191            match request {
192                Some(request) => crate::slog_info!(
193                    "{} cold-build request queued behind concurrency cap ({}): request={} kind={}",
194                    request.class.label(),
195                    limiter.limit(),
196                    request.request_id,
197                    kind
198                ),
199                None => crate::slog_info!(
200                    "maintenance build queued behind concurrency cap ({}): {}",
201                    limiter.limit(),
202                    kind
203                ),
204            }
205            logged = true;
206        }
207        std::thread::sleep(Duration::from_millis(100));
208    }
209}
210
211pub fn limit() -> usize {
212    GLOBAL_COLD_BUILD_LIMITER.limit()
213}
214
215#[cfg(test)]
216pub(crate) fn test_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
217    Arc::new(ColdBuildLimiter::new(limit))
218}
219
220#[cfg(test)]
221pub(crate) fn acquire_blocking_while_with_test_limiter(
222    limiter: &Arc<ColdBuildLimiter>,
223    kind: &str,
224    admitted: impl Fn() -> bool,
225) -> Option<ColdBuildPermit> {
226    acquire_blocking_while_with_limiter(limiter, kind, admitted)
227}
228
229#[derive(Debug)]
230pub(crate) struct ColdBuildLimiter {
231    available: AtomicUsize,
232    limit: usize,
233    admission_state: Mutex<AdmissionState>,
234}
235
236#[derive(Debug)]
237struct AdmissionState {
238    waiting_by_class: [usize; 2],
239    last_admitted_class: Option<ColdBuildAdmissionClass>,
240    next_admission_order: u64,
241    events: VecDeque<ColdBuildAdmissionEvent>,
242}
243
244impl ColdBuildLimiter {
245    fn new(limit: usize) -> Self {
246        let limit = limit.max(1);
247        Self {
248            available: AtomicUsize::new(limit),
249            limit,
250            admission_state: Mutex::new(AdmissionState {
251                waiting_by_class: [0; 2],
252                last_admitted_class: None,
253                next_admission_order: 1,
254                events: VecDeque::with_capacity(ADMISSION_EVENT_RETENTION),
255            }),
256        }
257    }
258
259    pub(crate) fn limit(&self) -> usize {
260        self.limit
261    }
262
263    pub(crate) fn try_acquire(self: &Arc<Self>) -> Option<ColdBuildPermit> {
264        loop {
265            let available = self.available.load(Ordering::Acquire);
266            if available == 0 {
267                return None;
268            }
269            if self
270                .available
271                .compare_exchange(
272                    available,
273                    available - 1,
274                    Ordering::AcqRel,
275                    Ordering::Acquire,
276                )
277                .is_ok()
278            {
279                return Some(ColdBuildPermit {
280                    limiter: Arc::clone(self),
281                });
282            }
283        }
284    }
285
286    fn class_is_eligible(&self, class: ColdBuildAdmissionClass) -> bool {
287        let state = self
288            .admission_state
289            .lock()
290            .unwrap_or_else(std::sync::PoisonError::into_inner);
291        state.waiting_by_class[class.other_index()] == 0 || state.last_admitted_class != Some(class)
292    }
293
294    fn record_admission(&self, request: &ColdBuildAdmissionRequest) {
295        let mut state = self
296            .admission_state
297            .lock()
298            .unwrap_or_else(std::sync::PoisonError::into_inner);
299        let event = ColdBuildAdmissionEvent {
300            request_id: request.request_id.clone(),
301            class: request.class,
302            admission_order: state.next_admission_order,
303        };
304        state.next_admission_order += 1;
305        state.last_admitted_class = Some(request.class);
306        if state.events.len() == ADMISSION_EVENT_RETENTION {
307            state.events.pop_front();
308        }
309        state.events.push_back(event);
310    }
311
312    /// Expose recorded admissions to internal tests and harness code so they
313    /// can verify limiter behavior without parsing log output.
314    // Read by the admission tests today; the blocking-inspect wait-stamp assembly
315    // consumes these events once the inspect-path slice lands.
316    #[cfg_attr(not(test), allow(dead_code))]
317    pub(crate) fn admission_events(&self) -> Vec<ColdBuildAdmissionEvent> {
318        self.admission_state
319            .lock()
320            .unwrap_or_else(std::sync::PoisonError::into_inner)
321            .events
322            .iter()
323            .cloned()
324            .collect()
325    }
326
327    #[cfg(test)]
328    fn waiting_by_class_for_test(&self) -> [usize; 2] {
329        self.admission_state
330            .lock()
331            .unwrap_or_else(std::sync::PoisonError::into_inner)
332            .waiting_by_class
333    }
334}
335
336struct AdmissionWaiter {
337    limiter: Arc<ColdBuildLimiter>,
338    class: ColdBuildAdmissionClass,
339}
340
341impl AdmissionWaiter {
342    fn register(limiter: &Arc<ColdBuildLimiter>, class: ColdBuildAdmissionClass) -> Self {
343        let mut state = limiter
344            .admission_state
345            .lock()
346            .unwrap_or_else(std::sync::PoisonError::into_inner);
347        state.waiting_by_class[class.index()] += 1;
348        drop(state);
349        Self {
350            limiter: Arc::clone(limiter),
351            class,
352        }
353    }
354}
355
356impl Drop for AdmissionWaiter {
357    fn drop(&mut self) {
358        let mut state = self
359            .limiter
360            .admission_state
361            .lock()
362            .unwrap_or_else(std::sync::PoisonError::into_inner);
363        let waiting = &mut state.waiting_by_class[self.class.index()];
364        debug_assert!(*waiting > 0);
365        *waiting = waiting.saturating_sub(1);
366    }
367}
368
369#[derive(Debug)]
370pub struct ColdBuildPermit {
371    limiter: Arc<ColdBuildLimiter>,
372}
373
374impl Drop for ColdBuildPermit {
375    fn drop(&mut self) {
376        let previous = self.limiter.available.fetch_add(1, Ordering::Release);
377        debug_assert!(previous < self.limiter.limit);
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    // These tests mutate the process-global limiter; run them one at a time.
386    fn serial() -> std::sync::MutexGuard<'static, ()> {
387        static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
388        M.get_or_init(|| std::sync::Mutex::new(()))
389            .lock()
390            .unwrap_or_else(std::sync::PoisonError::into_inner)
391    }
392
393    fn wait_for_waiters(limiter: &ColdBuildLimiter) {
394        let deadline = Instant::now() + Duration::from_secs(3);
395        loop {
396            let waiting = limiter.waiting_by_class_for_test();
397            if waiting[ColdBuildAdmissionClass::InspectTriggered.index()] > 0
398                && waiting[ColdBuildAdmissionClass::Maintenance.index()] > 0
399            {
400                return;
401            }
402            assert!(
403                Instant::now() < deadline,
404                "both admission classes must remain queued; waiting={waiting:?}"
405            );
406            std::thread::yield_now();
407        }
408    }
409
410    #[test]
411    fn permits_release_on_drop() {
412        let _serial = serial();
413        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
414        {
415            let _a = acquire_blocking("test-a");
416            let _b = acquire_blocking("test-b");
417            assert_eq!(
418                GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
419                before - 2
420            );
421        }
422        assert_eq!(
423            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
424            before
425        );
426    }
427
428    #[test]
429    fn acquire_blocking_waits_until_release() {
430        let _serial = serial();
431        // Drain every slot, then prove a waiter blocks until one holder drops.
432        let mut held: Vec<ColdBuildPermit> = Vec::new();
433        while let Some(permit) = try_acquire() {
434            held.push(permit);
435        }
436        let waiter = std::thread::spawn(|| {
437            let _p = acquire_blocking("waiter");
438        });
439        std::thread::sleep(std::time::Duration::from_millis(250));
440        assert!(!waiter.is_finished(), "waiter must block while cap is full");
441        drop(held.pop());
442        waiter.join().expect("waiter finishes after release");
443        drop(held);
444    }
445
446    #[test]
447    fn admission_revoked_between_check_and_permit_drops_the_slot() {
448        let _serial = serial();
449        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
450        let checks = AtomicUsize::new(0);
451
452        let permit = acquire_blocking_while("revoked-after-cas", || {
453            checks.fetch_add(1, Ordering::SeqCst) == 0
454        });
455
456        assert!(permit.is_none());
457        assert_eq!(checks.load(Ordering::SeqCst), 2);
458        assert_eq!(
459            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
460            before,
461            "revoked admission must return the just-acquired slot"
462        );
463    }
464
465    #[test]
466    fn conditional_waiter_cancels_without_consuming_a_released_slot() {
467        let _serial = serial();
468        let mut held = Vec::new();
469        while let Some(permit) = try_acquire() {
470            held.push(permit);
471        }
472        let admitted = Arc::new(std::sync::atomic::AtomicBool::new(true));
473        let waiter_admitted = Arc::clone(&admitted);
474        let waiter = std::thread::spawn(move || {
475            acquire_blocking_while("conditional waiter", || {
476                waiter_admitted.load(Ordering::SeqCst)
477            })
478        });
479        std::thread::sleep(Duration::from_millis(150));
480        admitted.store(false, Ordering::SeqCst);
481        assert!(
482            waiter.join().expect("conditional waiter joins").is_none(),
483            "revoked work must leave the cold-build queue without taking a permit"
484        );
485        drop(held);
486    }
487
488    #[test]
489    fn cancellation_after_acquisition_returns_the_permit_without_an_event() {
490        let limiter = test_limiter(1);
491        let cancellation_checks = AtomicUsize::new(0);
492
493        let permit = acquire_blocking_while_cancellable_with_limiter(
494            &limiter,
495            "cancel-after-acquire",
496            ColdBuildAdmissionRequest::new(
497                "inspect-cancelled",
498                ColdBuildAdmissionClass::InspectTriggered,
499            ),
500            || true,
501            || cancellation_checks.fetch_add(1, Ordering::SeqCst) > 0,
502        );
503
504        assert!(permit.is_none());
505        assert_eq!(cancellation_checks.load(Ordering::SeqCst), 2);
506        assert_eq!(
507            limiter.available.load(Ordering::Acquire),
508            1,
509            "post-acquisition cancellation must return the permit"
510        );
511        assert!(
512            limiter.admission_events().is_empty(),
513            "cancelled work must not emit a successful admission"
514        );
515    }
516
517    #[test]
518    fn admission_events_cover_both_classes_across_the_fixed_32_release_schedule() {
519        const RELEASE_COUNT: usize = 32;
520
521        let limiter = test_limiter(1);
522        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
523        let (permit_tx, permit_rx) = std::sync::mpsc::channel();
524        let initial_permit = limiter.try_acquire().expect("hold the only slot");
525        let mut waiters = Vec::new();
526
527        for (request_id, class) in [
528            ("inspect-request", ColdBuildAdmissionClass::InspectTriggered),
529            ("maintenance-request", ColdBuildAdmissionClass::Maintenance),
530        ] {
531            let limiter = Arc::clone(&limiter);
532            let cancelled = Arc::clone(&cancelled);
533            let permit_tx = permit_tx.clone();
534            waiters.push(std::thread::spawn(move || {
535                while !cancelled.load(Ordering::SeqCst) {
536                    let permit = acquire_blocking_while_cancellable_with_limiter(
537                        &limiter,
538                        "fixed-release-test",
539                        ColdBuildAdmissionRequest::new(request_id, class),
540                        || true,
541                        || cancelled.load(Ordering::SeqCst),
542                    );
543                    let Some(permit) = permit else {
544                        return;
545                    };
546                    if permit_tx.send(permit).is_err() {
547                        return;
548                    }
549                }
550            }));
551        }
552        drop(permit_tx);
553
554        wait_for_waiters(&limiter);
555        let mut released_permit = Some(initial_permit);
556        for release in 1..RELEASE_COUNT {
557            wait_for_waiters(&limiter);
558            drop(released_permit.take());
559            released_permit = Some(
560                permit_rx
561                    .recv_timeout(Duration::from_secs(3))
562                    .unwrap_or_else(|error| {
563                        panic!("release {release} must admit a waiter: {error}")
564                    }),
565            );
566        }
567        wait_for_waiters(&limiter);
568        drop(released_permit);
569        let consumed_by_build = permit_rx
570            .recv_timeout(Duration::from_secs(3))
571            .unwrap_or_else(|error| panic!("release {RELEASE_COUNT} must admit a waiter: {error}"));
572
573        cancelled.store(true, Ordering::SeqCst);
574        for waiter in waiters {
575            waiter.join().expect("cancelled waiter joins");
576        }
577
578        let events = limiter.admission_events();
579        assert_eq!(events.len(), RELEASE_COUNT);
580        assert!(events
581            .iter()
582            .any(|event| event.class == ColdBuildAdmissionClass::InspectTriggered));
583        assert!(events
584            .iter()
585            .any(|event| event.class == ColdBuildAdmissionClass::Maintenance));
586        assert!(events.iter().all(|event| matches!(
587            event.request_id.as_str(),
588            "inspect-request" | "maintenance-request"
589        )));
590        assert!(events
591            .iter()
592            .enumerate()
593            .all(|(index, event)| event.admission_order == index as u64 + 1));
594
595        assert_eq!(
596            limiter.available.load(Ordering::Acquire),
597            0,
598            "the final acquired permit must remain accounted for by the consumed build"
599        );
600        drop(consumed_by_build);
601        assert_eq!(
602            limiter.available.load(Ordering::Acquire),
603            1,
604            "releasing the consumed build permit must restore the limiter slot"
605        );
606    }
607}